local addonName, ns = ...

-- ============================================================
-- GUI colour profile — the single source of truth for the options
-- panel skin. THE RULE: hue carries meaning, intensity carries state.
--   * Wine/ember = static identity (title, ember wash). Never stateful.
--   * Gold       = ALL interactivity, graded rest -> hover -> active.
--   * Candle     = the gold family's "light": glows, halos, text hover.
--   * Text       = monochrome tiers; contrast comes from tiers,
--                  not from brightening individual strings.
-- Entries are {r, g, b} (alpha supplied at the call site) unless the
-- alpha is part of the colour's identity, in which case it's baked in
-- as a 4th value and consumed via unpack().
-- ============================================================
local Palette = {
    -- Warm identity anchors (static roles only)
    wine          = { 0.66, 0.30, 0.22 },        -- title text (ember copper-red)
    warmWashTop   = { 0.34, 0.13, 0.08, 0.06 },  -- ember wash gradient (brown-red)
    warmWashBot   = { 0.27, 0.10, 0.06, 0.045 },
    panelBorder   = { 0.25, 0.25, 0.25, 0.80 },  -- neutral edge, merges with the surface

    -- Steel-blue interactive family
    accent        = { 0.85, 0.66, 0.22 },        -- active fills, select overlays
    hairlineRest  = { 0.95, 0.78, 0.34 },
    hairlineHover = { 1.00, 0.90, 0.58 },        -- the candle tier
    platformRest  = { 0.52, 0.40, 0.16 },
    platformRestDark  = { 0.20, 0.15, 0.07 },
    platformHover = { 0.78, 0.61, 0.26 },
    platformHoverDark = { 0.30, 0.23, 0.10 },
    glowCandle    = { 1.00, 0.93, 0.70 },        -- additive glows/halos

    -- Text tiers
    textBright    = { 1.00, 1.00, 1.00 },        -- titles + value readouts (loud)
    textLabel     = { 0.80, 0.78, 0.74 },        -- widget labels/hints (quiet, warm grey)
    textHover     = { 1.00, 0.92, 0.72 },        -- candlelight hover state (also
    -- the RESTING tint of banded headers — nudged a touch more yellow)
    textMuted     = { 0.50, 0.50, 0.50 },        -- de-emphasised (inactive tabs, credits)
    textError     = { 1.00, 0.30, 0.30 },        -- warnings/import errors

    -- Surfaces & controls
    panelBase     = { 0.11, 0.13, 0.15 },        -- opaque backdrop base (lifted
    -- half a step vs the Arena sibling — user wanted this panel brighter,
    -- settled midway between 0.08/0.10/0.12 and 0.14/0.15/0.17)
    trackOff      = { 0.18, 0.17, 0.15 },        -- idle control tracks (solid)
    knobOff       = { 0.55, 0.55, 0.55 },
    knobOn        = { 1.00, 0.96, 0.85 },        -- warm white to sit with the gold
    chromeBg      = { 0.11, 0.11, 0.11, 0.80 },
    chromeBgHover = { 0.15, 0.15, 0.15, 0.90 },
    chromeBgPress = { 0.08, 0.08, 0.08, 0.95 },  -- darker than rest: pressed reads as recessed
    chromeBorder  = { 0.35, 0.35, 0.35, 0.95 },
    cardTint      = { 0.22, 0.20, 0.17 },        -- raised-card backdrops (a step
    -- lighter than the floor — "closer to the light" elevation cue)
    cardBorder    = { 0.38, 0.35, 0.30, 0.70 },  -- quieter now the drop shadow
    -- carries the edge-definition job
}
ns.Palette = Palette

-- Helper function to toggle settings panel
function ns.ToggleSettingsPanel()
    if ns.settingsPanel then
        if ns.settingsPanel:IsShown() then
            ns.PlayUISound("PanelClose")
            ns.settingsPanel:Hide()
        else
            ns.PlayUISound("PanelOpen")
            ns.settingsPanel:Show()
        end
    end
end

-- Settings Panel
function ns.CreateSettingsPanel()
    local PANEL_WIDTH = 1620
    local PANEL_HEIGHT = 911
    local TAB_HEIGHT = 56
    local HEADER_HEIGHT = 36

    local panel = CreateFrame("Frame", "BlitzFramesTargetsSettings", UIParent, "BackdropTemplate")
    panel:SetSize(PANEL_WIDTH, PANEL_HEIGHT)
    panel:SetPoint("CENTER")
    -- Neutral border that merges with the panel surface — it exists to give
    -- the edge structure against the game world, not to be a colour accent.
    -- The warm side of the temperature contrast lives in the bronze title,
    -- the ember wash and the background art.
    panel:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        edgeSize = 14,
        insets = { left = 3, right = 3, top = 3, bottom = 3 },
    })
    panel:SetBackdropColor(0, 0, 0, 0)  -- transparent, manual gradient below
    panel:SetBackdropBorderColor(unpack(Palette.panelBorder))
    panel:SetMovable(true)
    panel:EnableMouse(true)
    panel:RegisterForDrag("LeftButton")
    panel:SetScript("OnDragStart", panel.StartMoving)
    panel:SetScript("OnDragStop", panel.StopMovingOrSizing)
    panel:SetClampedToScreen(true)
    panel:SetFrameStrata("DIALOG")
    panel:Hide()

    -- ============================================================
    -- Panel backdrop composite — 3 core layers + 1 optional enhancement.
    -- Each layer has ONE job; tune each in isolation without ripple effects.
    --   1. backdropBase  : fully OPAQUE solid colour. Blocks world bleed-through.
    --   2. panelBG       : the image, at any alpha you like (tunes texture strength).
    --   3. blueWash      : low-alpha colour gradient for hue/mood.
    --   4. headerStrip   : (optional) dark gradient over the top ~110px so the
    --                      panel title + tab labels read against a clean band.
    -- ============================================================

    -- 1) Opaque base colour (alpha = 1.0). The only layer that blocks the game world.
    local backdropBase = panel:CreateTexture(nil, "BACKGROUND", nil, -1)
    backdropBase:SetColorTexture(Palette.panelBase[1], Palette.panelBase[2], Palette.panelBase[3], 1.0)
    backdropBase:SetAllPoints(panel)

    -- 2) Atmospheric image. Alpha = how much of the base shows through (NOT world).
    --    Lower alpha = subtler image; higher = more dominant. Free to tune anywhere.
    local panelBG = panel:CreateTexture(nil, "BACKGROUND", nil, 0)
    panelBG:SetTexture("Interface\\AddOns\\BlitzFramesTargets\\Textures\\PanelBG_16x9_RGBA")
    panelBG:SetAllPoints(panel)
    panelBG:SetAlpha(0.35)
    -- The image was authored bright, by a different artist than the panel
    -- skin; runtime desaturation pulls it into the theme and the ember wash
    -- re-warms it. With the image desaturated, its alpha doubles as the
    -- panel's overall brightness knob (it's the lightest layer in the stack)
    panelBG:SetDesaturated(true)

    -- 3) Warm wine tint wash, same hue family as the border/title (with the
    --    whole UI now cool, the ambient wash carries the warm side of the
    --    temperature contrast). ~3-6% alpha keeps it mood, not colour cast.
    local warmWash = panel:CreateTexture(nil, "BACKGROUND", nil, 1)
    warmWash:SetTexture("Interface\\Buttons\\WHITE8x8")
    warmWash:SetAllPoints(panel)
    warmWash:SetGradient("VERTICAL", CreateColor(unpack(Palette.warmWashTop)), CreateColor(unpack(Palette.warmWashBot)))

    -- 4) Header strip (optional enhancement): dark gradient over the top ~110px so the
    --    panel title and tab labels sit on a clean dark band.
    local headerStrip = panel:CreateTexture(nil, "BACKGROUND", nil, 2)
    headerStrip:SetTexture("Interface\\Buttons\\WHITE8x8")
    headerStrip:SetPoint("TOPLEFT", panel, "TOPLEFT", 0, 0)
    headerStrip:SetPoint("TOPRIGHT", panel, "TOPRIGHT", 0, 0)
    headerStrip:SetHeight(HEADER_HEIGHT + TAB_HEIGHT + 18)
    -- 0.65 max — softened so the strip doesn't read as a dark cap on this
    -- panel's lifted brightness (keep in step if the art strength changes)
    headerStrip:SetGradient("VERTICAL", CreateColor(0.04, 0.04, 0.06, 0.0), CreateColor(0.04, 0.04, 0.06, 0.65))

    -- Outer shadow sitting flush on panel edges
    local panelGlow = CreateFrame("Frame", nil, panel)
    panelGlow:SetPoint("TOPLEFT", 0, 0)
    panelGlow:SetPoint("BOTTOMRIGHT", 0, 0)
    panelGlow:SetFrameLevel(panel:GetFrameLevel() - 1)
    local glowTop = panelGlow:CreateTexture(nil, "BACKGROUND")
    glowTop:SetTexture("Interface\\Buttons\\WHITE8x8")
    glowTop:SetPoint("BOTTOMLEFT", panelGlow, "TOPLEFT", 0, 0)
    glowTop:SetPoint("BOTTOMRIGHT", panelGlow, "TOPRIGHT", 0, 0)
    glowTop:SetHeight(3)
    glowTop:SetVertexColor(0, 0, 0, 0.15)
    local glowBottom = panelGlow:CreateTexture(nil, "BACKGROUND")
    glowBottom:SetTexture("Interface\\Buttons\\WHITE8x8")
    glowBottom:SetPoint("TOPLEFT", panelGlow, "BOTTOMLEFT", 0, 0)
    glowBottom:SetPoint("TOPRIGHT", panelGlow, "BOTTOMRIGHT", 0, 0)
    glowBottom:SetHeight(3)
    glowBottom:SetVertexColor(0, 0, 0, 0.2)
    local glowLeft = panelGlow:CreateTexture(nil, "BACKGROUND")
    glowLeft:SetTexture("Interface\\Buttons\\WHITE8x8")
    glowLeft:SetPoint("TOPRIGHT", panelGlow, "TOPLEFT", 0, 0)
    glowLeft:SetPoint("BOTTOMRIGHT", panelGlow, "BOTTOMLEFT", 0, 0)
    glowLeft:SetWidth(3)
    glowLeft:SetVertexColor(0, 0, 0, 0.15)
    local glowRight = panelGlow:CreateTexture(nil, "BACKGROUND")
    glowRight:SetTexture("Interface\\Buttons\\WHITE8x8")
    glowRight:SetPoint("TOPLEFT", panelGlow, "TOPRIGHT", 0, 0)
    glowRight:SetPoint("BOTTOMLEFT", panelGlow, "BOTTOMRIGHT", 0, 0)
    glowRight:SetWidth(3)
    glowRight:SetVertexColor(0, 0, 0, 0.15)

    -- Header bar
    local header = CreateFrame("Frame", nil, panel, "BackdropTemplate")
    header:SetHeight(HEADER_HEIGHT)
    header:SetPoint("TOPLEFT", 8, -8)
    header:SetPoint("TOPRIGHT", -8, -8)
    -- No header backdrop - text sits directly on panel background

    -- Header top highlight for glass edge (hidden - no backplate)
    local headerHighlight = header:CreateTexture(nil, "ARTWORK")
    headerHighlight:SetTexture("Interface\\Buttons\\WHITE8x8")
    headerHighlight:SetPoint("TOPLEFT", 0, 0)
    headerHighlight:SetPoint("TOPRIGHT", 0, 0)
    headerHighlight:SetHeight(1)
    headerHighlight:SetVertexColor(0.5, 0.5, 0.5, 0.2)
    headerHighlight:Hide()

    -- Title
    local title = header:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    title:SetPoint("LEFT", 12, 0)
    title:SetText("BlitzFramesTargets v1.0.0")
    title:SetTextColor(unpack(Palette.wine)) -- brand wine, the warm identity anchor; static identity, never a state

    -- Author credit (small grey, next to title, fades in after reveal)
    local versionHeader = header:CreateFontString(nil, "OVERLAY")
    versionHeader:SetFont(ns.LOCALE_FONT, 10)
    versionHeader:SetPoint("LEFT", title, "RIGHT", 8, 0)
    versionHeader:SetText("by Miracle")
    versionHeader:SetTextColor(unpack(Palette.textMuted))

    -- Close button: white square with X
    local closeBtn = CreateFrame("Button", nil, header, "BackdropTemplate")
    closeBtn:SetSize(HEADER_HEIGHT, HEADER_HEIGHT)
    closeBtn:SetPoint("RIGHT", -20, 0)
    closeBtn:SetBackdrop(nil)
    local closeTxt = closeBtn:CreateFontString(nil, "OVERLAY")
    closeTxt:SetFont("Fonts\\FRIZQT__.TTF", 18, "OUTLINE")
    closeTxt:SetPoint("CENTER", 1, 0)
    closeTxt:SetText("X")
    closeTxt:SetTextColor(0.8, 0.8, 0.8, 1)
    closeBtn:SetScript("OnEnter", function(self)
        closeTxt:SetTextColor(unpack(Palette.textHover))
    end)
    closeBtn:SetScript("OnLeave", function(self)
        closeTxt:SetTextColor(0.8, 0.8, 0.8, 1)
    end)
    closeBtn:SetScript("OnClick", function()
        ns.PlayUISound("PanelClose")
        panel:Hide()
    end)

    -- Options Scale input (text box to avoid slider feedback loop with SetScale)
    local scaleLabel = header:CreateFontString(nil, "OVERLAY")
    scaleLabel:SetFont(ns.LOCALE_FONT, 10, "")
    scaleLabel:SetPoint("RIGHT", closeBtn, "LEFT", -60, 0)
    scaleLabel:SetText("Options Scale")
    scaleLabel:SetTextColor(unpack(Palette.textMuted))

    local scaleBox = CreateFrame("EditBox", nil, header, "InputBoxTemplate")
    scaleBox:SetSize(45, 20)
    scaleBox:SetPoint("LEFT", scaleLabel, "RIGHT", 8, 0)
    scaleBox:SetAutoFocus(false)
    scaleBox:SetFont(ns.LOCALE_FONT, 10, "")

    local function ApplyScale(self)
        local val = tonumber(self:GetText())
        if val then
            val = math.floor(val / 5 + 0.5) * 5
            val = math.max(50, math.min(100, val))
            panel:SetScale(val / 100)
            if ns.db then ns.db.optionsScale = val / 100 end
            self:SetText(val .. "%")
        else
            local current = (ns.db and ns.db.optionsScale) or ns.defaults.optionsScale
            self:SetText(math.floor(current * 100 + 0.5) .. "%")
        end
        self:ClearFocus()
    end

    scaleBox:SetScript("OnEnterPressed", ApplyScale)
    scaleBox:SetScript("OnEscapePressed", function(self)
        local current = (ns.db and ns.db.optionsScale) or ns.defaults.optionsScale
        self:SetText(math.floor(current * 100 + 0.5) .. "%")
        self:ClearFocus()
    end)

    -- Apply saved scale
    local savedScale = (ns.db and ns.db.optionsScale) or ns.defaults.optionsScale
    scaleBox:SetText(math.floor(savedScale * 100 + 0.5) .. "%")
    panel:SetScale(savedScale)

    -- Tab bar
    local tabBar = CreateFrame("Frame", nil, panel, "BackdropTemplate")
    tabBar:SetHeight(TAB_HEIGHT)
    tabBar:SetPoint("TOPLEFT", header, "BOTTOMLEFT", 8, -1)
    tabBar:SetPoint("TOPRIGHT", header, "BOTTOMRIGHT", -8, -1)
    -- No bar-wide fill. A gradient across the whole row filled the gaps between
    -- the tabs and squared the ends off, which cancelled the rounded corners --
    -- the tabs looked square because the thing BEHIND them was. They sit on the
    -- panel now, as discrete cards.
    --
    -- The vestigial SetBackdrop goes with it; it drew WHITE8x8 at zero alpha.

    -- Content area
    local content = CreateFrame("Frame", nil, panel)
    -- Width comes from tabBar on BOTH sides. It used to take its left from
    -- tabBar and its right from the panel, which made it asymmetric -- inset 16
    -- on the left, 8 on the right -- and 8px wider than the row above it.
    --
    -- Everything downstream inherited that: the sub-tab bar fills `content`,
    -- while sub-tab WIDTHS are computed from the constant PANEL_WIDTH-32. Two
    -- different notions of "the row width" in one layout, which is why the
    -- sub-tabs would not sit under the main tabs however the numbers were
    -- nudged. One reference now, so the two rows cannot disagree.
    content:SetPoint("TOPLEFT",  tabBar, "BOTTOMLEFT",  0, -1)
    content:SetPoint("TOPRIGHT", tabBar, "BOTTOMRIGHT", 0, -1)
    content:SetPoint("BOTTOM",   panel,  "BOTTOM",      0, 8)

    -- Tab content frames
    local tabFrames = {}
    local tabButtons = {}
    local activeTab = nil
    local suppressTabSound = false

    local function CreateTabContent()
        local frame = CreateFrame("Frame", nil, content)
        frame:SetAllPoints()
        frame:Hide()
        return frame
    end

    -- Create tab button
    local NUM_TABS = 4
    local TAB_BAR_WIDTH = PANEL_WIDTH - 32  -- subtract 8px panel inset + 8px tab inset each side
    local TAB_WIDTH = math.floor(TAB_BAR_WIDTH / NUM_TABS)
    -- Gap between tabs. Rounded corners need somewhere to BE: flush against its
    -- neighbour, a rounded tab just looks like a rectangle with soft edges.
    local TAB_GAP = 6
    local TAB_EDGE_PAD = 0

    local GLOW_R, GLOW_G, GLOW_B = unpack(Palette.glowCandle) -- warm candlelight pop for hover/inactive radial glows
    local GLOW_TEXTURE = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\RadialGlow"

    -- Steel-blue accent at a given alpha — the one hue all interactive
    -- fills/overlays share; only the alpha varies per state
    local function Accent(alpha)
        return CreateColor(Palette.accent[1], Palette.accent[2], Palette.accent[3], alpha)
    end

    local function SetTabState(btn, state)
        -- state: "active", "hover", or "inactive"
        if state == "active" then
            btn.bgGrad:SetGradient("VERTICAL", CreateColor(0.14, 0.14, 0.14, 0.55), CreateColor(0.14, 0.14, 0.14, 0.40))
            -- Top highlight: bright glass edge catch
            btn.topHighlight:SetGradient("VERTICAL",
                CreateColor(0.85, 0.92, 1.0, 0.10), CreateColor(0.85, 0.92, 1.0, 0))
            btn.topHighlight:Show()
            btn.glassSheen:SetGradient("VERTICAL",
                CreateColor(1, 1, 1, 0), CreateColor(1, 1, 1, 0.07))
            btn.glassSheen:Show()
            -- Accent bloom rising from the bottom edge.
            btn.bottomGlow:SetGradient("VERTICAL",
                CreateColor(GLOW_R, GLOW_G, GLOW_B, 0.16), CreateColor(GLOW_R, GLOW_G, GLOW_B, 0))
            btn.bottomGlow:Show()
            btn.glow:SetGradient("VERTICAL",
                CreateColor(GLOW_R, GLOW_G, GLOW_B, 0.07), CreateColor(GLOW_R, GLOW_G, GLOW_B, 0))
            btn.glow:Show()
            -- Raised: no inner recess, but it DOES cast a shadow onto the panel
            -- below. The two are opposites and never both on.
            btn.shadowOverlay:Hide()
            btn.shade:SetAlpha(0.40)
            -- Selection = the interactive gold at full strength (hover uses
            -- the same hue fainter), keeping hue constant across states
            btn.selectOverlay:SetGradient("VERTICAL", Accent(0.22), Accent(0))
            btn.selectOverlay:Show()
            btn.selectTail:SetGradient("VERTICAL", Accent(0), Accent(0.12))
            btn.selectTail:Show()
            btn.text:SetTextColor(1, 1, 1, 1)
        elseif state == "hover" then
            btn.bgGrad:SetGradient("VERTICAL", CreateColor(0.12, 0.12, 0.12, 0.55), CreateColor(0.12, 0.12, 0.12, 0.40))
            btn.topHighlight:SetGradient("VERTICAL",
                CreateColor(0.85, 0.92, 1.0, 0.07), CreateColor(0.85, 0.92, 1.0, 0))
            btn.topHighlight:Show()
            btn.glassSheen:SetGradient("VERTICAL",
                CreateColor(1, 1, 1, 0), CreateColor(1, 1, 1, 0.05))
            btn.glassSheen:Show()
            btn.bottomGlow:SetGradient("VERTICAL",
                CreateColor(GLOW_R, GLOW_G, GLOW_B, 0.08), CreateColor(GLOW_R, GLOW_G, GLOW_B, 0))
            btn.bottomGlow:Show()
            btn.glow:SetGradient("VERTICAL",
                CreateColor(GLOW_R, GLOW_G, GLOW_B, 0.04), CreateColor(GLOW_R, GLOW_G, GLOW_B, 0))
            btn.glow:Show()
            -- Lifting, not yet raised: half the recess, no cast shadow.
            btn.shadowOverlay:SetAlpha(0.5)
            btn.shadowOverlay:Show()
            btn.shade:SetAlpha(0.22)
            btn.selectOverlay:SetGradient("VERTICAL", Accent(0.06), Accent(0))
            btn.selectOverlay:Show()
            btn.selectTail:SetGradient("VERTICAL", Accent(0), Accent(0.06))
            btn.selectTail:Show()
            btn.text:SetTextColor(0.92, 0.92, 0.92, 1)
        else
            btn.bgGrad:SetGradient("VERTICAL", CreateColor(0.08, 0.08, 0.08, 0.55), CreateColor(0.08, 0.08, 0.08, 0.40))
            btn.topHighlight:SetGradient("VERTICAL",
                CreateColor(0.85, 0.92, 1.0, 0.03), CreateColor(0.85, 0.92, 1.0, 0))
            btn.topHighlight:Show()
            btn.glassSheen:Hide()
            btn.bottomGlow:Hide()
            btn.glow:Hide()
            btn.shadowOverlay:SetAlpha(1)
            btn.shadowOverlay:Show()
            btn.shade:SetAlpha(0.14)
            btn.selectOverlay:Hide()
            btn.selectTail:Hide()
            btn.text:SetTextColor(unpack(Palette.textMuted))
        end
    end

    local function CreateTabButton(tabName, tabIndex)
        local btn = CreateFrame("Button", nil, tabBar, "BackdropTemplate")
        btn:SetSize(TAB_WIDTH - TAB_GAP, TAB_HEIGHT)
        btn:SetPoint("LEFT", tabBar, "LEFT", (tabIndex - 1) * TAB_WIDTH + TAB_GAP / 2, 0)
        -- No SetBackdrop. It drew WHITE8x8 at zero alpha -- purely vestigial --
        -- and a backdrop cannot be masked, which would have blocked the rounded
        -- corners below.
        btn.bgGrad = btn:CreateTexture(nil, "BACKGROUND")
        -- The enemy cards' background, not a flat WHITE8x8 wash. Blizzard's bar
        -- fill carries its own banding, and the gradient runs a real VALUE range
        -- rather than one colour at two alphas -- that range is what gives a card
        -- its form, and alpha alone cannot do it.
        --
        -- SetGradient("VERTICAL", a, b) puts `a` at the BOTTOM, so this is dark
        -- below rising to light above: lit from over the top, same as a card.
        btn.bgGrad:SetTexture("Interface\\RaidFrame\\Raid-Bar-Hp-Fill")
        btn.bgGrad:SetAllPoints(btn)
        btn.bgGrad:SetGradient("VERTICAL",
            CreateColor(0.03, 0.03, 0.04, 0.92),
            CreateColor(0.18, 0.18, 0.20, 0.80))

        -- NOT a 1px light catch. That is chrome-button vocabulary and it reads as
        -- a borrowed UI element sitting on the tab -- a card has no white strip
        -- anywhere. Its light comes from the background's value gradient plus a
        -- cool sheen, so this is bar.visualSheen: a blue-white wash, strongest at
        -- the bottom, fading out toward the top.
        --
        -- Kept under the name topHighlight so SetTabState stays uniform.
        btn.topHighlight = btn:CreateTexture(nil, "ARTWORK", nil, 2)
        btn.topHighlight:SetColorTexture(1, 1, 1, 1)
        btn.topHighlight:SetAllPoints(btn)

        -- Sheen and ambient fill as DIRECTIONAL GRADIENTS, not radials.
        --
        -- All three of these were RadialGlow stretched across a 391x56 button.
        -- Radial art on a wide rectangle concentrates in the middle and fades to
        -- nothing at the ends, so it reads as a smudge rather than a lit edge --
        -- the same reason the bottom shade had to be rebuilt. Everything in the
        -- addon built since uses directional gradients or purpose-baked art.
        --
        -- glassSheen: light falling from above, brightest at the top edge.
        btn.glassSheen = btn:CreateTexture(nil, "ARTWORK", nil, 1)
        btn.glassSheen:SetTexture("Interface\\Buttons\\WHITE8x8")
        btn.glassSheen:SetPoint("TOPLEFT",     btn, "TOPLEFT",  0, 0)
        btn.glassSheen:SetPoint("TOPRIGHT",    btn, "TOPRIGHT", 0, 0)
        btn.glassSheen:SetHeight(TAB_HEIGHT * 0.55)
        btn.glassSheen:SetGradient("VERTICAL",
            CreateColor(1, 1, 1, 0), CreateColor(1, 1, 1, 0.06))
        btn.glassSheen:Hide()

        -- bottomGlow: the accent bloom along the bottom edge. Kept ADD so it
        -- still reads as light rather than paint.
        btn.bottomGlow = btn:CreateTexture(nil, "ARTWORK", nil, 0)
        btn.bottomGlow:SetTexture("Interface\\Buttons\\WHITE8x8")
        btn.bottomGlow:SetBlendMode("ADD")
        btn.bottomGlow:SetPoint("BOTTOMLEFT",  btn, "BOTTOMLEFT",  0, 0)
        btn.bottomGlow:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", 0, 0)
        btn.bottomGlow:SetHeight(TAB_HEIGHT * 0.5)
        btn.bottomGlow:Hide()

        -- glow: ambient fill across the whole face, brightest low where the
        -- accent sits, so the two read as one light source.
        btn.glow = btn:CreateTexture(nil, "ARTWORK")
        btn.glow:SetTexture("Interface\\Buttons\\WHITE8x8")
        btn.glow:SetBlendMode("ADD")
        btn.glow:SetAllPoints(btn)
        btn.glow:Hide()

        -- Shadow overlay: dark radial at bottom for 3D depth
        -- Grounding shade. NOT the tab's soft edge -- that is the mask's job now,
        -- feathered inward so the tab dissolves into its own perimeter rather
        -- than ending on a line. This only sits under it.
        --
        -- Tight on purpose, and ROUNDED. At 8px it read as a backdrop plate
        -- parked behind the tab, the give-away being that you could see where
        -- the plate ended. Worse, the first version was a feathered RECTANGLE,
        -- so its square corners poked out at each of the tab's rounded ones --
        -- a hard geometric tell that something separate was back there. Its
        -- silhouette matches the tab now, offset 2px.
        --
        -- The cards get their depth from ColumnShade.tga: a black plate with a
        -- feathered perimeter, sitting behind them and extending past on every
        -- side. Not gradient strips and not a 1px light catch -- a halo. That is
        -- what makes a card read as floating above the world, and it is what the
        -- tabs were missing.
        --
        -- TabShade is the same idea baked at tab shape. ColumnShade itself is cut
        -- 256x512 for a tall column, and a texture stretches to what it covers,
        -- so on a wide tab its feather would smear ~40px across and ~7px down.
        --
        -- Behind everything and outside the button, so NOT masked: a shadow falls
        -- on the panel, it is not clipped to its caster.
        btn.shade = btn:CreateTexture(nil, "BACKGROUND", nil, -8)
        btn.shade:SetTexture("Interface\\AddOns\\BlitzFramesTargets\\Textures\\TabShade.tga")
        btn.shade:SetVertexColor(0, 0, 0, 1)
        btn.shade:SetPoint("TOPLEFT",     btn, "TOPLEFT",     -2,  2)
        btn.shade:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT",  2, -2)
        btn.shade:SetAlpha(0)

        -- Recess shade: a vertical gradient darkening toward the bottom, NOT a
        -- radial blob. The old version was GLOW_TEXTURE -- radial art stretched
        -- across a wide rectangle, which reads as a smudge under the middle and
        -- nothing at the ends. A directional gradient is the right shape for a
        -- flat edge, and it is what makes an inactive tab sit BELOW the active
        -- one rather than merely darker than it.
        --
        -- Masked with the rest of the shape so it respects the rounded corners.
        btn.shadowOverlay = btn:CreateTexture(nil, "ARTWORK", nil, -1)
        btn.shadowOverlay:SetTexture("Interface\\Buttons\\WHITE8x8")
        btn.shadowOverlay:SetPoint("BOTTOMLEFT",  btn, "BOTTOMLEFT",  0, 0)
        btn.shadowOverlay:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", 0, 0)
        btn.shadowOverlay:SetHeight(TAB_HEIGHT * 0.45)
        btn.shadowOverlay:SetGradient("VERTICAL",
            CreateColor(0, 0, 0, 0.35), CreateColor(0, 0, 0, 0))

        -- Active selection: gold gradient bottom 13% -> 0% over bottom 2/3
        btn.selectOverlay = btn:CreateTexture(nil, "OVERLAY", nil, 7)
        btn.selectOverlay:SetTexture("Interface\\Buttons\\WHITE8x8")
        btn.selectOverlay:SetPoint("BOTTOMLEFT", btn, "BOTTOMLEFT", 0, 0)
        btn.selectOverlay:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", 0, 0)
        btn.selectOverlay:SetHeight(TAB_HEIGHT * 2 / 3)
        btn.selectOverlay:SetGradient("VERTICAL", Accent(0.17), Accent(0))
        btn.selectOverlay:Hide()

        -- Active selection tail: gold gradient 0% -> 3.5% over top 1/3
        btn.selectTail = btn:CreateTexture(nil, "OVERLAY", nil, 7)
        btn.selectTail:SetTexture("Interface\\Buttons\\WHITE8x8")
        btn.selectTail:SetPoint("TOPLEFT", btn, "TOPLEFT", 0, 0)
        btn.selectTail:SetPoint("TOPRIGHT", btn, "TOPRIGHT", 0, 0)
        btn.selectTail:SetHeight(TAB_HEIGHT / 3)
        btn.selectTail:SetGradient("VERTICAL", Accent(0), Accent(0.09))
        btn.selectTail:Hide()

        -- No separator line. It existed to divide tabs that touched; the gap
        -- does that now, and a hairline inside a rounded edge reads as a seam.

        -- Rounded TOP corners, matching the player cards.
        --
        -- Everything drawn INSIDE the tab is masked. That is now all of it
        -- except the drop shadow: the sheen, bloom and ambient fill became
        -- gradients bounded by the button, so unlike the radials they replaced
        -- they no longer spill past the edges and must respect the corners.
        --
        -- dropShadow is deliberately excluded. It hangs below the button and is
        -- cast ONTO the panel; masking it to the caster's own silhouette would
        -- erase it entirely.
        --
        -- Top only because the tabs are laid out flush: rounding all four would
        -- open a notch at every join, and the bottom edge has to stay square
        -- where the active tab meets the content area it belongs to.
        local roundMask = btn:CreateMaskTexture()
        roundMask:SetTexture("Interface\\AddOns\\BlitzFramesTargets\\Textures\\TabRoundMask.tga",
            "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE")
        roundMask:SetAllPoints(btn)
        for _, tex in ipairs({ btn.bgGrad, btn.topHighlight, btn.selectOverlay,
                               btn.selectTail, btn.shadowOverlay,
                               btn.glassSheen, btn.bottomGlow, btn.glow }) do
            tex:AddMaskTexture(roundMask)
        end

        -- Pixel snapping off, the same pair the player cards use. WoW snaps
        -- textures to the pixel grid by default, which stair-steps any curved
        -- edge -- it is what made the rounded corners look chewed rather than
        -- drawn. Both calls always go together; one without the other does
        -- nothing useful.
        for _, tex in ipairs({ btn.bgGrad, btn.topHighlight, btn.selectOverlay,
                               btn.selectTail, btn.shadowOverlay, btn.glassSheen,
                               btn.bottomGlow, btn.glow, btn.shade }) do
            if tex then
                tex:SetSnapToPixelGrid(false)
                tex:SetTexelSnappingBias(0)
            end
        end
        roundMask:SetSnapToPixelGrid(false)
        roundMask:SetTexelSnappingBias(0)

        -- Main text on top
        btn.text = btn:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
        btn.text:SetPoint("CENTER", 0, 1)
        btn.text:SetText(tabName)
        btn.text:SetTextColor(unpack(Palette.textMuted))

                -- Press feedback. Tabs were the only clickable thing in the addon
        -- without any: bars drop 0.04 scale, chrome buttons drop the label and
        -- kill the top light, ping icons shrink. This matches the chrome button
        -- language -- label down 1px, top light out, face darkened.
        --
        -- OnLeave releases too: dragging off a held button never fires
        -- OnMouseUp on it, so the label would otherwise stay pushed down.
        local function PressTab(self)
            self.text:SetPoint("CENTER", 0, 0)
            self.bgGrad:SetAlpha(0.72)
        end
        local function ReleaseTab(self)
            self.text:SetPoint("CENTER", 0, 1)
            self.bgGrad:SetAlpha(1)
        end
        btn:HookScript("OnMouseDown", PressTab)
        btn:HookScript("OnMouseUp", ReleaseTab)
        btn:HookScript("OnLeave", ReleaseTab)

btn:SetScript("OnEnter", function(self)
            if activeTab ~= tabIndex then
                SetTabState(self, "hover")
            end
        end)

        btn:SetScript("OnLeave", function(self)
            if activeTab ~= tabIndex then
                SetTabState(self, "inactive")
            end
        end)

        btn:RegisterForClicks("LeftButtonDown")
        btn:SetScript("OnClick", function(self)
            if activeTab == tabIndex then return end  -- already open; don't replay
            if not suppressTabSound then ns.PlayUISound() end
            -- Deactivate other tabs: fade out visible ones, hide the rest
            for i, tabBtn in ipairs(tabButtons) do
                if i ~= tabIndex then
                    SetTabState(tabBtn, "inactive")
                    if tabFrames[i].fadeIn then tabFrames[i].fadeIn:Stop() end
                    local frame = tabFrames[i]
                    if frame:IsShown() then
                        if not frame.fadeOut then
                            frame.fadeOut = frame:CreateAnimationGroup()
                            frame.fadeOut:SetToFinalAlpha(true)
                            local fade = frame.fadeOut:CreateAnimation("Alpha")
                            fade:SetFromAlpha(1)
                            fade:SetToAlpha(0)
                            fade:SetDuration(0.1)
                            fade:SetSmoothing("IN")
                            frame.fadeOut:SetScript("OnFinished", function()
                                frame:Hide()
                            end)
                        end
                        frame.fadeOut:Stop()
                        frame.fadeOut:Play()
                    else
                        frame:Hide()
                    end
                end
            end
            -- Activate this tab
            activeTab = tabIndex
            SetTabState(self, "active")
            -- Stop a still-running fade-out so its OnFinished Hide() can't hide the newly active tab
            if tabFrames[tabIndex].fadeOut then tabFrames[tabIndex].fadeOut:Stop() end
            tabFrames[tabIndex]:SetAlpha(0)
            tabFrames[tabIndex]:Show()
            -- Fade in content
            if not tabFrames[tabIndex].fadeIn then
                tabFrames[tabIndex].fadeIn = tabFrames[tabIndex]:CreateAnimationGroup()
                tabFrames[tabIndex].fadeIn:SetToFinalAlpha(true)
                local fade = tabFrames[tabIndex].fadeIn:CreateAnimation("Alpha")
                fade:SetFromAlpha(0)
                fade:SetToAlpha(1)
                fade:SetDuration(0.2)
                fade:SetSmoothing("OUT")
            end
            tabFrames[tabIndex].fadeIn:Play()
        end)

        return btn
    end

    -- Shared capsule/knob artwork and palette — used by both the sliders
    -- and the toggle switches so the two controls read as one family
    local TOGGLE_TRACK_TEX = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\ToggleTrack"
    local TOGGLE_KNOB_TEX  = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\ToggleKnob"
    local TOGGLE_GLOW_TEX  = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\ToggleGlow"
    -- Same capsule/halo designs authored at the slider's 280x16 aspect,
    -- so the end caps stay truly round instead of stretching into ovals
    local SLIDER_TRACK_TEX = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\SliderTrack"
    local SLIDER_GLOW_TEX  = "Interface\\AddOns\\BlitzFramesTargets\\Textures\\SliderGlow"
    local TOGGLE_GLOW_ALPHA = 0.06 -- halo strength at full "on"; candle-white
    -- (GLOW_R/G/B) over the gold track — kept very faint, just a
    -- whisper of edge light (user found 0.12 still overpowering)
    -- NOTE: the toggle/slider capsules deliberately have NO drop shadow —
    -- tried 2026-06-11 (silhouette art tinted black, offset down) and even
    -- at low alpha it overweighted the small pills; the halo + sheen stack
    -- already carries their depth. Elevation shadows live on the chrome
    -- buttons and card backdrops only.
    local TRACK_OFF  = Palette.trackOff
    local TRACK_ON   = Palette.accent -- the panel's gold highlight:
    -- already established in the scheme (hover tints, chrome hover glow), and
    -- contrasts the wine identity accents
    local KNOB_OFF   = Palette.knobOff
    local KNOB_ON    = Palette.knobOn -- cool white to sit with the blue

    -- UI volume, sitting in the HEADER to the left of Options Scale.
    --
    -- Built here rather than up with the rest of the header for a concrete
    -- reason: the slider artwork constants above (SLIDER_TRACK_TEX, TRACK_ON,
    -- the knob) are locals of this function assigned only a few lines ago. At
    -- the point the header is constructed they are still nil, so the styled
    -- track could not be drawn there at all. It is parented and anchored back
    -- into the header, so where it is BUILT and where it SITS differ.
    --
    -- Not CreateSlider either: that makes a 280x50 block with the label stacked
    -- above a full-width bar, which is a tab-content shape, not a header row.
    -- The visual language is shared by hand instead -- same capsule track, same
    -- gold fill, same knob and sheen -- just laid out in a line and smaller.
    do
        -- Laid out RIGHT TO LEFT from Options Scale so the group can never grow
        -- into it. Anchoring the label first and letting the slider extend
        -- rightward is what put the readout on top of the Scale text.
        local volValue = header:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        volValue:SetFont(ns.LOCALE_FONT, 10, "")
        volValue:SetPoint("RIGHT", scaleLabel, "LEFT", -18, 0)
        volValue:SetJustifyH("RIGHT")
        volValue:SetWidth(28)

        local volSlider = CreateFrame("Slider", nil, header)
        volSlider:SetSize(64, 14)
        volSlider:SetPoint("RIGHT", volValue, "LEFT", -8, 0)
        volSlider:SetOrientation("HORIZONTAL")
        volSlider:SetMinMaxValues(0, 100)
        volSlider:SetValueStep(20)
        volSlider:SetObeyStepOnDrag(true)
        volSlider:SetHitRectInsets(0, 0, -6, -6)

        local volLabel = header:CreateFontString(nil, "OVERLAY")
        volLabel:SetFont(ns.LOCALE_FONT, 10, "")
        volLabel:SetPoint("RIGHT", volSlider, "LEFT", -10, 0)
        volLabel:SetText("UI Volume")
        volLabel:SetTextColor(unpack(Palette.textMuted))

        local glow = volSlider:CreateTexture(nil, "BACKGROUND")
        glow:SetTexture(SLIDER_GLOW_TEX)
        glow:SetBlendMode("ADD")
        glow:SetSnapToPixelGrid(false)
        glow:SetTexelSnappingBias(0)
        glow:SetVertexColor(GLOW_R, GLOW_G, GLOW_B, TOGGLE_GLOW_ALPHA)

        local track = volSlider:CreateTexture(nil, "ARTWORK", nil, 0)
        track:SetTexture(SLIDER_TRACK_TEX)
        track:SetHeight(12)
        track:SetPoint("LEFT", volSlider, "LEFT", 0, 0)
        track:SetPoint("RIGHT", volSlider, "RIGHT", 0, 0)
        track:SetVertexColor(TRACK_OFF[1], TRACK_OFF[2], TRACK_OFF[3], 0.95)
        track:SetSnapToPixelGrid(false)
        track:SetTexelSnappingBias(0)
        glow:SetPoint("TOPLEFT", track, "TOPLEFT", -5, 5)
        glow:SetPoint("BOTTOMRIGHT", track, "BOTTOMRIGHT", 5, -5)

        local fill = volSlider:CreateTexture(nil, "ARTWORK", nil, 1)
        fill:SetTexture(SLIDER_TRACK_TEX)
        fill:SetVertexColor(TRACK_ON[1], TRACK_ON[2], TRACK_ON[3], 0.75)
        fill:SetHeight(12)
        fill:SetPoint("LEFT", volSlider, "LEFT", 0, 0)
        fill:SetSnapToPixelGrid(false)
        fill:SetTexelSnappingBias(0)

        local sheen = volSlider:CreateTexture(nil, "ARTWORK", nil, 2)
        sheen:SetTexture(SLIDER_TRACK_TEX)
        sheen:SetAllPoints(track)
        sheen:SetSnapToPixelGrid(false)
        sheen:SetTexelSnappingBias(0)
        sheen:SetGradient("VERTICAL", CreateColor(1, 1, 1, 0), CreateColor(1, 1, 1, 0.22))

        volSlider:SetThumbTexture(TOGGLE_KNOB_TEX)
        local thumb = volSlider:GetThumbTexture()
        thumb:SetSize(10, 10)
        thumb:SetDrawLayer("ARTWORK", 3)
        thumb:SetVertexColor(KNOB_ON[1], KNOB_ON[2], KNOB_ON[3])
        thumb:SetSnapToPixelGrid(false)
        thumb:SetTexelSnappingBias(0)

        local thumbSheen = volSlider:CreateTexture(nil, "ARTWORK", nil, 4)
        thumbSheen:SetTexture(TOGGLE_KNOB_TEX)
        thumbSheen:SetAllPoints(thumb)
        thumbSheen:SetSnapToPixelGrid(false)
        thumbSheen:SetTexelSnappingBias(0)
        thumbSheen:SetGradient("VERTICAL", CreateColor(1, 1, 1, 0), CreateColor(1, 1, 1, 0.35))

        -- Same trick the tab sliders use: the fill runs to the thumb centre and
        -- is texcoord-cropped to that fraction, so the capsule keeps its rounded
        -- left cap instead of being squashed.
        local function UpdateVolFill()
            local frac  = volSlider:GetValue() / 100
            local fullW = volSlider:GetWidth()
            if frac <= 0 or fullW <= 0 then
                fill:Hide()
                return
            end
            local w = 5 + (fullW - 10) * frac
            fill:Show()
            fill:SetWidth(w)
            fill:SetTexCoord(0, w / fullW, 0, 1)
        end
        volSlider:SetScript("OnSizeChanged", UpdateVolFill)

        volSlider:HookScript("OnEnter", function()
            thumb:SetSize(12, 12)
            glow:SetVertexColor(GLOW_R, GLOW_G, GLOW_B, TOGGLE_GLOW_ALPHA * 2)
        end)
        volSlider:HookScript("OnLeave", function()
            thumb:SetSize(10, 10)
            glow:SetVertexColor(GLOW_R, GLOW_G, GLOW_B, TOGGLE_GLOW_ALPHA)
        end)

        volSlider:SetScript("OnValueChanged", function(self, value)
            value = math.floor(value / 20 + 0.5) * 20
            volValue:SetText(value == 0 and "Off" or (value .. "%"))
            UpdateVolFill()
            if ns.GetUISoundVolume() == value then return end
            ns.SetUISoundVolume(value)
            -- Auditioned on release only; firing per step crossed while
            -- dragging retriggers the sound and stutters.
            if not self.dragging then ns.PlayUISound() end
        end)
        volSlider:HookScript("OnMouseDown", function(self) self.dragging = true end)
        volSlider:HookScript("OnMouseUp", function(self)
            self.dragging = false
            ns.PlayUISound()
        end)

        local saved = ns.GetUISoundVolume() or 100
        volSlider:SetValue(saved)
        volValue:SetText(saved == 0 and "Off" or (saved .. "%"))
        UpdateVolFill()
    end

    -- Helper to create a slider in a tab
    local function CreateSlider(parent, xPos, yPos, label, min, max, step, settingKey, rebuildOnChange)
        local container = CreateFrame("Frame", nil, parent)
        container:SetSize(280, 50)
        container:SetPoint("TOPLEFT", xPos, yPos)

        local text = container:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        text:SetPoint("TOPLEFT", 0, 0)
        text:SetText(label)
        text:SetTextColor(unpack(Palette.textLabel))

        local slider = CreateFrame("Slider", nil, container, "OptionsSliderTemplate")
        slider:SetPoint("TOPLEFT", 0, -18)
        slider:SetPoint("TOPRIGHT", 0, -18)
        slider:SetMinMaxValues(min, max)
        slider:SetValueStep(step)
        slider:SetObeyStepOnDrag(true)
        slider:SetScale(1.2)
        -- No min/max end labels, but a single white value readout sits at
        -- the end of the bar so identical values are easy to dial in across
        -- bars; the preview remains the primary feedback
        slider.Low:Hide()
        slider.High:Hide()
        slider.Text:SetText("")

        local valueText = container:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        valueText:SetPoint("LEFT", slider, "RIGHT", 8, 0)

        -- Modern track: drop the template's groove backdrop and draw the
        -- same capsule artwork the toggles use, so the fill can be opaque
        -- instead of a translucent bar with the groove bleeding through
        if slider.SetBackdrop then slider:SetBackdrop(nil) end

        -- The slider is a stretched toggle: same 16px pill, knob riding
        -- inside the track, gold fill behind it, same gloss stack and the
        -- same faint additive halo
        local glow = slider:CreateTexture(nil, "BACKGROUND")
        glow:SetTexture(SLIDER_GLOW_TEX)
        glow:SetBlendMode("ADD")
        glow:SetSnapToPixelGrid(false)
        glow:SetTexelSnappingBias(0)
        glow:SetVertexColor(GLOW_R, GLOW_G, GLOW_B, TOGGLE_GLOW_ALPHA)

        local track = slider:CreateTexture(nil, "ARTWORK", nil, 0)
        track:SetTexture(SLIDER_TRACK_TEX)
        track:SetHeight(16)
        track:SetPoint("LEFT", slider, "LEFT", 0, 0)
        track:SetPoint("RIGHT", slider, "RIGHT", 0, 0)
        track:SetVertexColor(TRACK_OFF[1], TRACK_OFF[2], TRACK_OFF[3], 0.95)
        track:SetSnapToPixelGrid(false)
        track:SetTexelSnappingBias(0)

        glow:SetPoint("TOPLEFT", track, "TOPLEFT", -6, 6)
        glow:SetPoint("BOTTOMRIGHT", track, "BOTTOMRIGHT", 6, -6)

        local fill = slider:CreateTexture(nil, "ARTWORK", nil, 1)
        fill:SetTexture(SLIDER_TRACK_TEX)
        fill:SetVertexColor(TRACK_ON[1], TRACK_ON[2], TRACK_ON[3], 0.75)
        fill:SetHeight(16)
        fill:SetPoint("LEFT", slider, "LEFT", 0, 0)
        fill:SetSnapToPixelGrid(false)
        fill:SetTexelSnappingBias(0)

        -- Glass sheen over track and fill, same as the toggle's
        local sheen = slider:CreateTexture(nil, "ARTWORK", nil, 2)
        sheen:SetTexture(SLIDER_TRACK_TEX)
        sheen:SetAllPoints(track)
        sheen:SetSnapToPixelGrid(false)
        sheen:SetTexelSnappingBias(0)
        sheen:SetGradient("VERTICAL", CreateColor(1, 1, 1, 0), CreateColor(1, 1, 1, 0.22))

        -- The toggle's knob, riding inside the track
        slider:SetThumbTexture(TOGGLE_KNOB_TEX)
        local thumb = slider:GetThumbTexture()
        thumb:SetSize(12, 12)
        thumb:SetDrawLayer("ARTWORK", 3)
        thumb:SetVertexColor(KNOB_ON[1], KNOB_ON[2], KNOB_ON[3])
        thumb:SetSnapToPixelGrid(false)
        thumb:SetTexelSnappingBias(0)

        local thumbSheen = slider:CreateTexture(nil, "ARTWORK", nil, 4)
        thumbSheen:SetTexture(TOGGLE_KNOB_TEX)
        thumbSheen:SetAllPoints(thumb)
        thumbSheen:SetSnapToPixelGrid(false)
        thumbSheen:SetTexelSnappingBias(0)
        thumbSheen:SetGradient("VERTICAL", CreateColor(1, 1, 1, 0), CreateColor(1, 1, 1, 0.35))

        -- Fill runs to the thumb centre and is texcoord-cropped to its
        -- fraction, so the capsule art is never squashed: rounded left cap,
        -- straight cut under the thumb
        local function UpdateFill()
            local lo, hi = slider:GetMinMaxValues()
            local frac = (hi > lo) and (slider:GetValue() - lo) / (hi - lo) or 0
            local fullW = slider:GetWidth()
            if frac <= 0 or fullW <= 0 then
                fill:Hide()
                return
            end
            local w = 6 + (fullW - 12) * frac -- thumb centre travels 6..W-6
            fill:Show()
            fill:SetWidth(w)
            fill:SetTexCoord(0, w / fullW, 0, 1)
        end
        slider:SetScript("OnSizeChanged", UpdateFill)
        -- Exposed for tab files that replace OnValueChanged (shared-key
        -- wrappers in Extras) -- they must keep the fill in sync themselves
        slider.UpdateFill = UpdateFill

        -- Hover feel: thumb grows a step and the halo doubles — intensity
        -- lift only, no hue change (disabled sliders don't fire these)
        slider:HookScript("OnMouseDown", function(self) self._userDriving = true end)
        slider:HookScript("OnMouseUp",   function(self) self._userDriving = nil  end)

        slider:HookScript("OnEnter", function()
            thumb:SetSize(14, 14)
            glow:SetVertexColor(GLOW_R, GLOW_G, GLOW_B, TOGGLE_GLOW_ALPHA * 2)
        end)
        slider:HookScript("OnLeave", function()
            thumb:SetSize(12, 12)
            glow:SetVertexColor(GLOW_R, GLOW_G, GLOW_B, TOGGLE_GLOW_ALPHA)
        end)

        slider:SetScript("OnValueChanged", function(self, value)
            value = math.floor(value / step + 0.5) * step
            -- Per STEP, not per pixel, and only while the USER is driving.
            --
            -- Every tab's OnShow refresh calls SetValue on a dozen sliders to
            -- restore stored settings, and each of those fires this handler --
            -- so without the _userDriving gate, simply opening the panel or
            -- changing tab machine-guns ticks. Gating on the mouse being down
            -- is deterministic; a suppression flag would have to be threaded
            -- through every tab file and would break the moment one was missed.
            --
            -- ns.PlayUISound rate-limits SliderTick on top of this, since a fast
            -- drag can still cross steps faster than the sample is long.
            if self._userDriving and value ~= self._lastTickValue then
                ns.PlayUISound("SliderTick")
            end
            self._lastTickValue = value
            valueText:SetText(value)
            UpdateFill()
            if ns.settings then
                ns.settings[settingKey] = value
                if rebuildOnChange and not InCombatLockdown() then
                    if ns.RebuildBars then ns.RebuildBars() end
                end
                if ns.RefreshTestBars then ns.RefreshTestBars() end
            end
        end)

        slider.settingKey = settingKey
        slider.valueText = valueText
        return slider
    end

    -- Compact number input box with label
    local function CreateNumberBox(parent, xPos, yPos, label, min, max, settingKey)
        local container = CreateFrame("Frame", nil, parent)
        container:SetSize(120, 22)
        container:SetPoint("TOPLEFT", xPos, yPos)

        local text = container:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        text:SetPoint("LEFT", 0, 0)
        text:SetText(label)
        text:SetTextColor(unpack(Palette.textLabel))

        local box = CreateFrame("EditBox", nil, container, "InputBoxTemplate")
        box:SetSize(45, 20)
        box:SetPoint("RIGHT", 0, 0)
        box:SetAutoFocus(false)
        box:SetScript("OnEnterPressed", function(self)
            local val = tonumber(self:GetText())
            if val then
                val = math.floor(val + 0.5)
                val = math.max(min, math.min(max, val))
                if ns.settings then ns.settings[settingKey] = val end
                self:SetText(tostring(val))
                if ns.RefreshTestBars then ns.RefreshTestBars() end
            else
                self:SetText(tostring(ns.settings and ns.settings[settingKey] or ns.defaults[settingKey]))
            end
            self:ClearFocus()
        end)
        box:SetScript("OnEscapePressed", function(self)
            self:SetText(tostring(ns.settings and ns.settings[settingKey] or ns.defaults[settingKey]))
            self:ClearFocus()
        end)

        box.settingKey = settingKey
        return box
    end

    -- Toggle-switch visuals on a bare CheckButton: custom antialiased pill
    -- track + circular knob (Textures/ToggleTrack & ToggleKnob — white
    -- shapes tinted via vertex colour, same technique as the WHITE8x8
    -- hairlines). The knob glides between ends over TOGGLE_ANIM_TIME with
    -- smoothstep easing, blending the track grey -> gold along the way.
    -- The widget stays a CheckButton so GetChecked/SetChecked and
    -- IsObjectType-based sync code work unchanged.
    local TOGGLE_ANIM_TIME = 0.25

    local function StyleToggleSwitch(checkbox)
        local TRACK_W, TRACK_H, KNOB = 34, 16, 12
        local OFF_X, ON_X = 2, TRACK_W - KNOB - 2

        local pill = CreateFrame("Frame", nil, checkbox)
        pill:SetSize(TRACK_W, TRACK_H)
        pill:SetPoint("CENTER")

        -- Additive halo behind the track — the "lit edge" language of the
        -- hairline glows. Alpha rides the slide, so the pill lights up as
        -- it switches on.
        local glow = pill:CreateTexture(nil, "BACKGROUND")
        glow:SetTexture(TOGGLE_GLOW_TEX)
        glow:SetBlendMode("ADD")
        glow:SetSize(TRACK_W + 12, TRACK_H + 12)
        glow:SetPoint("CENTER")
        glow:SetSnapToPixelGrid(false)
        glow:SetTexelSnappingBias(0)
        glow:SetVertexColor(GLOW_R, GLOW_G, GLOW_B, 0)

        local trackTex = pill:CreateTexture(nil, "ARTWORK", nil, 0)
        trackTex:SetTexture(TOGGLE_TRACK_TEX)
        trackTex:SetAllPoints(pill)
        -- Default pixel-grid snapping jaggies non-integer scales; let the
        -- baked AA edge do the smoothing instead
        trackTex:SetSnapToPixelGrid(false)
        trackTex:SetTexelSnappingBias(0)

        -- Glass sheen: the same capsule texture, white-gradient from the top,
        -- so the highlight sits OVER the state colour (a tinted texture can
        -- never exceed its tint) — same light language as the tab buttons'
        -- top highlight and the chrome buttons' sheen
        local trackSheen = pill:CreateTexture(nil, "ARTWORK", nil, 1)
        trackSheen:SetTexture(TOGGLE_TRACK_TEX)
        trackSheen:SetAllPoints(pill)
        trackSheen:SetSnapToPixelGrid(false)
        trackSheen:SetTexelSnappingBias(0)
        trackSheen:SetGradient("VERTICAL", CreateColor(1, 1, 1, 0), CreateColor(1, 1, 1, 0.22))

        local knob = pill:CreateTexture(nil, "ARTWORK", nil, 2)
        knob:SetTexture(TOGGLE_KNOB_TEX)
        knob:SetSize(KNOB, KNOB)
        knob:SetPoint("LEFT", pill, "LEFT", OFF_X, 0)
        knob:SetSnapToPixelGrid(false)
        knob:SetTexelSnappingBias(0)

        -- Specular catch on the knob, anchored to it so it rides the slide
        local knobSheen = pill:CreateTexture(nil, "ARTWORK", nil, 3)
        knobSheen:SetTexture(TOGGLE_KNOB_TEX)
        knobSheen:SetAllPoints(knob)
        knobSheen:SetSnapToPixelGrid(false)
        knobSheen:SetTexelSnappingBias(0)
        knobSheen:SetGradient("VERTICAL", CreateColor(1, 1, 1, 0), CreateColor(1, 1, 1, 0.35))

        -- progress runs 0 (off) -> 1 (on); Render eases it for both the
        -- knob position and the colour blend
        local progress = 0
        local hovered = false

        local function lerp(a, b, t) return a + (b - a) * t end
        local function Render()
            local e = progress * progress * (3 - 2 * progress) -- smoothstep
            knob:SetPoint("LEFT", pill, "LEFT", lerp(OFF_X, ON_X, e), 0)
            local tr = lerp(TRACK_OFF[1], TRACK_ON[1], e)
            local tg = lerp(TRACK_OFF[2], TRACK_ON[2], e)
            local tb = lerp(TRACK_OFF[3], TRACK_ON[3], e)
            -- the lit gold is slightly translucent; the dark off-track stays solid
            trackTex:SetVertexColor(tr, tg, tb, lerp(0.95, 0.75, e))
            -- Hover = an intensity lift, never a hue change: knob brightens
            -- a step and the halo gets a faint floor even while off
            local lift = hovered and 0.12 or 0
            glow:SetVertexColor(GLOW_R, GLOW_G, GLOW_B,
                math.max(TOGGLE_GLOW_ALPHA * e, hovered and 0.05 or 0))
            knob:SetVertexColor(
                math.min(lerp(KNOB_OFF[1], KNOB_ON[1], e) + lift, 1),
                math.min(lerp(KNOB_OFF[2], KNOB_ON[2], e) + lift, 1),
                math.min(lerp(KNOB_OFF[3], KNOB_ON[3], e) + lift, 1), 1)
        end

        local function OnUpdate(_, elapsed)
            local target = checkbox:GetChecked() and 1 or 0
            local step = elapsed / TOGGLE_ANIM_TIME
            if progress < target then
                progress = math.min(progress + step, target)
            else
                progress = math.max(progress - step, target)
            end
            Render()
            if progress == target then
                pill:SetScript("OnUpdate", nil)
            end
        end

        checkbox.togglePill = pill
        -- instant=true snaps without animating (used while the tab is
        -- hidden, so reopening the panel doesn't replay every slide)
        function checkbox:UpdateToggleVisual(instant)
            local target = self:GetChecked() and 1 or 0
            if instant then
                pill:SetScript("OnUpdate", nil)
                progress = target
                Render()
            elseif progress ~= target then
                pill:SetScript("OnUpdate", OnUpdate)
            else
                Render()
            end
        end

        -- Programmatic state changes (widget value sync) must move the knob
        -- too — the native method doesn't fire any script
        local origSetChecked = checkbox.SetChecked
        checkbox.SetChecked = function(self, checked)
            origSetChecked(self, checked)
            self:UpdateToggleVisual(not pill:IsVisible())
        end

        -- Extend the click target rightward across the attached label so
        -- clicking the header text toggles too (FontStrings can't take
        -- clicks themselves)
        function checkbox:ExtendHitToText()
            if not self.text then return end
            local w = self.text:GetStringWidth() or 0
            self:SetHitRectInsets(0, -(w + 10), 0, 0)
        end

        -- Hover feel: the pill itself responds (knob lift + halo floor) so
        -- toggles without header decorations still acknowledge the cursor;
        -- header toggles additionally brighten their decoration band via
        -- AttachHeaderHover.
        -- Full hover response: pill lift + halo floor + label stepping to
        -- the text-hover tier (remembering its resting colour — labels vary,
        -- quiet grey vs white headers). Exposed as a method so
        -- AttachHeaderHover can drive it from the decoration band; decorated
        -- toggles get _bandHover set and route ALL hover through the band's
        -- debounced counter, avoiding a one-frame dip when the cursor
        -- crosses between band and checkbox.
        function checkbox:SetHoverVisual(on)
            hovered = on
            Render()
            if not self.text then return end
            if on and not self._hoverTextR then
                self._hoverTextR, self._hoverTextG, self._hoverTextB = self.text:GetTextColor()
                -- Banded headers already REST at the candlelight tint, so
                -- their hover is a no-op colour-wise (the tint persists);
                -- the band artwork carries the hover response instead
                self.text:SetTextColor(unpack(self._hoverTextColor or Palette.textHover))
            elseif not on and self._hoverTextR then
                self.text:SetTextColor(self._hoverTextR, self._hoverTextG, self._hoverTextB)
                self._hoverTextR, self._hoverTextG, self._hoverTextB = nil, nil, nil
            end
        end
        checkbox:HookScript("OnEnter", function(self)
            if not self._bandHover then self:SetHoverVisual(true) end
        end)
        checkbox:HookScript("OnLeave", function(self)
            if not self._bandHover then self:SetHoverVisual(false) end
        end)

        checkbox:UpdateToggleVisual(true)
        return checkbox
    end

    -- Helper to create a checkbox (toggle switch) in a tab
    local function CreateCheckbox(parent, xPos, yPos, label, settingKey, onChange)
        local checkbox = CreateFrame("CheckButton", nil, parent)
        checkbox:SetSize(32, 32)
        checkbox:SetPoint("TOPLEFT", xPos, yPos)
        StyleToggleSwitch(checkbox)
        checkbox.text = checkbox:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        checkbox.text:SetPoint("LEFT", checkbox, "RIGHT", 5, 0)
        checkbox.text:SetText(label)
        checkbox.text:SetTextColor(unpack(Palette.textLabel))
        checkbox:ExtendHitToText()
        -- Tab files may enlarge the label font right after creation;
        -- recompute next frame so the hit area matches the final width
        C_Timer.After(0, function() checkbox:ExtendHitToText() end)

        checkbox:SetScript("OnClick", function(self)
            self:UpdateToggleVisual()
            -- One sound both ways: the state is already obvious from the knob,
            -- and two near-identical sounds read as inconsistency, not feedback.
            ns.PlayUISound("Toggle")
            if ns.settings then
                ns.settings[settingKey] = self:GetChecked()
                if onChange then
                    onChange(self:GetChecked())
                end
                if ns.RefreshTestBars then ns.RefreshTestBars() end
            end
        end)

        checkbox.settingKey = settingKey
        return checkbox
    end

    -- Helper to create a dropdown in a tab
    local function CreateDropdown(parent, xPos, yPos, label, width, options, settingKey, getFunc, onChange)
        local container = CreateFrame("Frame", nil, parent)
        container:SetSize(width + 16, 50)
        container:SetPoint("TOPLEFT", xPos, yPos)

        local text = container:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        text:SetPoint("TOPLEFT", 0, 0)
        text:SetText(label)
        text:SetTextColor(unpack(Palette.textLabel))

        local dropdown = CreateFrame("DropdownButton", nil, container, "WowStyle1DropdownTemplate")
        dropdown:SetPoint("TOPLEFT", 0, -16)
        dropdown:SetWidth(width)

        dropdown:SetupMenu(function(_, rootDescription)
            local function AddRadio(value, display)
                rootDescription:CreateRadio(display,
                    function() return getFunc() == value end,
                    function()
                        if ns.settings then
                            ns.settings[settingKey] = value
                            if onChange then onChange(value) end
                            if ns.RefreshTestBars then ns.RefreshTestBars() end
                        end
                    end
                )
            end
            -- pairs() has no defined order. That is tolerable for a handful of
            -- named modes, but wrong for anything the user reads as a sequence
            -- -- 60/40/20 shuffling between sessions looks broken. An optional
            -- `order` array on the options table pins it; without one the old
            -- pairs() behaviour is untouched, so existing dropdowns are unaffected.
            if options.order then
                for _, value in ipairs(options.order) do AddRadio(value, options[value]) end
            else
                for value, display in pairs(options) do AddRadio(value, display) end
            end
        end)

        dropdown.settingKey = settingKey
        dropdown.options = options
        dropdown.getFunc = getFunc
        return dropdown
    end

    -- ============================================
    -- Shared utilities
    -- ============================================

    -- Helper to reset CC frames (stub for now)
    local function ResetCCFrames()
        -- No-op stub; individual modules can override if needed
    end

    -- ============================================
    -- Shared grey-state helpers (used by tab files)
    -- ============================================
    -- Disabled widgets dim to this rather than vanish — a faint ghost so
    -- users can see a setting exists behind its parent toggle (0.03 made
    -- disabled controls effectively invisible; 0.25 let the coloured
    -- sections show through too harshly)
    local GREY_ALPHA = 0.05
    local GREY_FADE_TIME = 0.25

    -- Smooth alpha fade for grey-state changes (a hard snap to GREY_ALPHA reads
    -- as abrupt). Lazily attaches one reusable Alpha animation per region;
    -- repeated calls with the same target are no-ops so the per-refresh
    -- grey-state sweep doesn't restart animations.
    local function FadeTo(region, target)
        -- Mid-cascade: hand the new target to the reveal driver instead of
        -- starting a competing animation — the cascade lands it there
        if region._bfaRevealItem then
            region._bfaRevealItem.target = target
            return
        end
        -- Off-screen: snap instead of animating. Playing AnimationGroups on
        -- hidden widgets (panel-open grey sweep) queues them all up to run
        -- and commit moments after the tab opens — visible as a rapid
        -- whole-menu flicker once the entrance settles.
        if not region:IsVisible() then
            if region._bfaFade then region._bfaFade:Stop() end
            region:SetAlpha(target)
            return
        end
        local ag = region._bfaFade
        if ag and ag:IsPlaying() then
            if ag.anim:GetToAlpha() == target then return end
        elseif region:GetAlpha() == target then
            return
        end
        if not ag then
            ag = region:CreateAnimationGroup()
            ag:SetToFinalAlpha(true)
            ag.anim = ag:CreateAnimation("Alpha")
            ag.anim:SetDuration(GREY_FADE_TIME)
            ag.anim:SetSmoothing("OUT")
            region._bfaFade = ag
        end
        ag:Stop()
        ag.anim:SetFromAlpha(region:GetAlpha())
        ag.anim:SetToAlpha(target)
        ag:Play()
    end

    local function SetGreyState(widget, enabled)
        FadeTo(widget, enabled and 1 or GREY_ALPHA)
        if widget.SetEnabled then widget:SetEnabled(enabled) end
    end

    -- Grey a decorated text header: the label and its artwork band fade
    -- together (_headerDecos lives on the label itself for FontString
    -- headers, copied to the widget for toggle headers)
    local function SetHeaderGreyState(header, enabled)
        local target = enabled and 1 or GREY_ALPHA
        FadeTo(header, target)
        if header._headerDecos then
            for _, d in ipairs(header._headerDecos) do
                FadeTo(d, target)
            end
        end
    end

    local function SetSliderGreyState(slider, enabled)
        FadeTo(slider:GetParent(), enabled and 1 or GREY_ALPHA)
        slider:SetEnabled(enabled)
    end

    local function SetNumberBoxGreyState(box, enabled)
        FadeTo(box:GetParent(), enabled and 1 or GREY_ALPHA)
        if box.SetEnabled then box:SetEnabled(enabled) end
    end

    -- CreateDropdown returns the inner dropdown button, but its label
    -- FontString lives on the parent container. Grey both via the container.
    local function SetDropdownGreyState(dropdown, enabled)
        FadeTo(dropdown:GetParent(), enabled and 1 or GREY_ALPHA)
        if dropdown.SetEnabled then dropdown:SetEnabled(enabled) end
    end

    -- Helper to create a color swatch with color picker
    -- Chrome button: dark grey/black backdrop with gold hover state.
    -- Shared visual chrome for color swatches, profile buttons, etc.
    local function CreateChromeButton(parent, w, h, label)
        local btn = CreateFrame("Button", nil, parent, "BackdropTemplate")
        btn:SetSize(w, h)
        btn:SetBackdrop({
            bgFile   = "Interface\\Buttons\\WHITE8x8",
            edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
            edgeSize = 8,
            insets   = { left = 2, right = 2, top = 2, bottom = 2 },
        })
        btn:SetBackdropColor(unpack(Palette.chromeBg))
        btn:SetBackdropBorderColor(unpack(Palette.chromeBorder))

        -- Elevation: 1px top light catch + downward-biased drop shadow —
        -- same light-from-above language as the tabs and toggle pills
        local topLight = btn:CreateTexture(nil, "ARTWORK")
        topLight:SetTexture("Interface\\Buttons\\WHITE8x8")
        topLight:SetPoint("TOPLEFT", 2, -2)
        topLight:SetPoint("TOPRIGHT", -2, -2)
        topLight:SetHeight(1)
        -- Kept as a named value because the press feedback has to put it back
        -- EXACTLY, and the alpha lives in the vertex colour rather than in
        -- SetAlpha. Restoring it with SetAlpha(1) does not undo SetAlpha(0) --
        -- it leaves the line at full white, which is a bright 1px stripe across
        -- the top of the button that persists until reload.
        local TOP_LIGHT_A = 0.10
        topLight:SetVertexColor(1, 1, 1, TOP_LIGHT_A)

        local shadowB = btn:CreateTexture(nil, "BACKGROUND", nil, -8)
        shadowB:SetTexture("Interface\\Buttons\\WHITE8x8")
        shadowB:SetPoint("TOPLEFT",  btn, "BOTTOMLEFT",  -2, 2)
        shadowB:SetPoint("TOPRIGHT", btn, "BOTTOMRIGHT",  2, 2)
        shadowB:SetHeight(5)
        shadowB:SetGradient("VERTICAL", CreateColor(0, 0, 0, 0), CreateColor(0, 0, 0, 0.35))

        local shadowL = btn:CreateTexture(nil, "BACKGROUND", nil, -8)
        shadowL:SetTexture("Interface\\Buttons\\WHITE8x8")
        shadowL:SetPoint("TOPRIGHT",    btn, "TOPLEFT",    2, -3)
        shadowL:SetPoint("BOTTOMRIGHT", btn, "BOTTOMLEFT", 2,  2)
        shadowL:SetWidth(3)
        shadowL:SetGradient("HORIZONTAL", CreateColor(0, 0, 0, 0), CreateColor(0, 0, 0, 0.20))

        local shadowR = btn:CreateTexture(nil, "BACKGROUND", nil, -8)
        shadowR:SetTexture("Interface\\Buttons\\WHITE8x8")
        shadowR:SetPoint("TOPLEFT",    btn, "TOPRIGHT",    -2, -3)
        shadowR:SetPoint("BOTTOMLEFT", btn, "BOTTOMRIGHT", -2,  2)
        shadowR:SetWidth(3)
        shadowR:SetGradient("HORIZONTAL", CreateColor(0, 0, 0, 0.20), CreateColor(0, 0, 0, 0))

        btn.text = btn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        btn.text:SetPoint("CENTER", 0, 0)
        btn.text:SetText(label or "")
        btn.text:SetTextColor(unpack(Palette.textLabel))

        -- State glow — accent fade from horizontal center to edges. Always
        -- on at a whisper so the resting button keeps a soft lit-glass
        -- depth; hover raises it to full strength.
        -- Stack two textures (left half + right half) since WoW's SetGradient is only 2-stop linear.
        btn.hoverGlowL = btn:CreateTexture(nil, "OVERLAY", nil, 7)
        btn.hoverGlowL:SetTexture("Interface\\Buttons\\WHITE8x8")
        btn.hoverGlowL:SetBlendMode("ADD")
        btn.hoverGlowL:SetPoint("TOPLEFT",     btn, "TOPLEFT",     0, 0)
        btn.hoverGlowL:SetPoint("BOTTOMRIGHT", btn, "BOTTOM",      0, 0)
        btn.hoverGlowL:SetGradient("HORIZONTAL",
            Accent(0.00),
            Accent(0.08))

        btn.hoverGlowR = btn:CreateTexture(nil, "OVERLAY", nil, 7)
        btn.hoverGlowR:SetTexture("Interface\\Buttons\\WHITE8x8")
        btn.hoverGlowR:SetBlendMode("ADD")
        btn.hoverGlowR:SetPoint("TOPLEFT",     btn, "TOP",         0, 0)
        btn.hoverGlowR:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", 0, 0)
        btn.hoverGlowR:SetGradient("HORIZONTAL",
            Accent(0.08),
            Accent(0.00))

        btn:SetScript("OnEnter", function(self)
            self:SetBackdropColor(unpack(Palette.chromeBgHover))
            self.text:SetTextColor(unpack(Palette.textHover))
            self.hoverGlowL:SetGradient("HORIZONTAL", Accent(0.00), Accent(0.20))
            self.hoverGlowR:SetGradient("HORIZONTAL", Accent(0.20), Accent(0.00))
        end)
        btn:SetScript("OnLeave", function(self)
            self:SetBackdropColor(unpack(Palette.chromeBg))
            self:SetBackdropBorderColor(unpack(Palette.chromeBorder))
            self.text:SetTextColor(unpack(Palette.textLabel))
            self.hoverGlowL:SetGradient("HORIZONTAL", Accent(0.00), Accent(0.08))
            self.hoverGlowR:SetGradient("HORIZONTAL", Accent(0.08), Accent(0.00))
        end)

        -- Click feedback for every chrome button -- Create, Rename, Delete,
        -- Reset, Export, Import, the Confirm/Cancel dialogs, the keybind button
        -- and Border Colour all come through here, so this is the one place.
        --
        -- OnMouseDown rather than OnClick: the sound should answer the press,
        -- not the release, and some of these buttons open a confirm dialog on
        -- click, which would put the sound after the dialog appears.
        -- Press feedback: the label and the top light drop 1px while held, so
        -- the button reads as pushed IN rather than merely recoloured. The
        -- backdrop deliberately does not move -- shifting the whole frame would
        -- fight the caller's anchors, and the elevation cue is carried by the
        -- top light, so removing that is what sells the press.
        btn:HookScript("OnMouseDown", function(self)
            ns.PlayUISound("ButtonClick")
            self.text:SetPoint("CENTER", self, "CENTER", 0, -1)
            topLight:SetVertexColor(1, 1, 1, 0)
            self:SetBackdropColor(unpack(Palette.chromeBgPress))
        end)

        -- Two releases, not one. OnMouseUp leaves the pointer ON the button, so
        -- it goes back to the hover colour; OnLeave means the pointer has gone,
        -- so it must go to rest. Sharing one function and asking IsMouseOver()
        -- got this wrong: during OnLeave the cursor can still test as over, so
        -- the button was left lit after the mouse had left.
        local function Unpress(self)
            self.text:SetPoint("CENTER", self, "CENTER", 0, 0)
            topLight:SetVertexColor(1, 1, 1, TOP_LIGHT_A)
        end
        btn:HookScript("OnMouseUp", function(self)
            Unpress(self)
            self:SetBackdropColor(unpack(Palette.chromeBgHover))
        end)
        -- Dragging off a held button never fires OnMouseUp on it, so without
        -- this the label would stay pushed down permanently.
        btn:HookScript("OnLeave", function(self)
            Unpress(self)
            self:SetBackdropColor(unpack(Palette.chromeBg))
        end)

        return btn
    end

    -- Trapezoidal "platform" decoration that sits below or above a header widget.
    -- Single gradient texture; the side away from the header fades to transparent.
    -- Auto-fits width to (checkbox + label) * widthMult if frontWidth not given.
    -- Uses SetVertexOffset (Shadowlands+) to skew the texture into a trapezoid.
    local function AddHeaderPlatform(anchorWidget, opts)
        opts = opts or {}
        local placement  = opts.placement  or "below"  -- "below" or "above"
        -- Geometry direction: by default "above" placement uses inverted geometry
        -- (wide-top, narrow-bottom). `flipped` opt overrides explicitly so a
        -- "below" placement can also use inverted geometry (e.g. hanging under
        -- a horizontal accent line).
        local flipped    = opts.flipped
        if flipped == nil then flipped = (placement == "above") end

        local labelW = 0
        if anchorWidget.text and anchorWidget.text.GetStringWidth then
            labelW = anchorWidget.text:GetStringWidth() or 0
        end
        local boxW       = anchorWidget:GetWidth() or 32
        local widthMult  = opts.widthMult  or 1.20
        local frontWidth = opts.frontWidth or ((boxW + 5 + labelW) * widthMult)
        local backWidth  = opts.backWidth  or (frontWidth * 0.6)
        local height     = opts.height     or 16
        local yOffset    = opts.yOffset    or 12
        local frontAlpha = opts.frontAlpha or 0.26

        local parent = anchorWidget:GetParent()
        local inset  = (frontWidth - backWidth) / 2

        local platform = CreateFrame("Frame", nil, parent)
        platform:SetSize(frontWidth, height)

        -- Default: center on the checkbox+label combo. Override via opts.centerOffsetX.
        local centerOffsetX = opts.centerOffsetX
        if centerOffsetX == nil then
            centerOffsetX = (labelW > 0) and ((labelW + 5) / 2) or 0
        end
        if placement == "above" then
            -- Platform's BOTTOM sits yOffset px below anchor's TOP (overlap into checkbox)
            platform:SetPoint("BOTTOM", anchorWidget, "TOP", centerOffsetX, -yOffset)
        else
            -- Platform's TOP sits yOffset px above anchor's BOTTOM (overlap into checkbox)
            platform:SetPoint("TOP", anchorWidget, "BOTTOM", centerOffsetX, yOffset)
        end
        local anchorLevel = (anchorWidget.GetFrameLevel and anchorWidget:GetFrameLevel())
                         or (parent.GetFrameLevel  and parent:GetFrameLevel())
                         or 1
        platform:SetFrameLevel(math.max(anchorLevel - 1, 1))

        -- Single trapezoid body. Bright side is always the side closer to the header
        -- (bottom for "below" placement, top for "above"). Narrow side fades.
        local body = platform:CreateTexture(nil, "BACKGROUND", nil, 0)
        body:SetTexture("Interface\\Buttons\\WHITE8x8")
        body:SetAllPoints(platform)
        if body.SetVertexOffset then
            if flipped then
                body:SetVertexOffset(2,  inset, 0)   -- LOWER_LEFT pulled in
                body:SetVertexOffset(4, -inset, 0)   -- LOWER_RIGHT pulled in
            else
                body:SetVertexOffset(1,  inset, 0)
                body:SetVertexOffset(3, -inset, 0)
            end
        end

        platform.body       = body
        platform.flipped    = flipped
        platform.frontAlpha = frontAlpha
        -- Same single-hue rule as the hairlines: dim gold at rest,
        -- brighter gold on hover
        function platform:SetTint(mode)
            -- Ladder shifted up a step for readability against the dark
            -- background art: rest sits just under the old hover, hover
            -- steps toward the candle tier
            local brightC, darkC
            if mode == "hover" then
                brightC, darkC = Palette.platformHover, Palette.platformHoverDark
            else -- "rest"
                brightC, darkC = Palette.platformRest, Palette.platformRestDark
            end
            local bright = CreateColor(brightC[1], brightC[2], brightC[3], self.frontAlpha)
            local dark   = CreateColor(darkC[1],   darkC[2],   darkC[3],   0)
            if self.flipped then
                self.body:SetGradient("VERTICAL", dark, bright)
            else
                self.body:SetGradient("VERTICAL", bright, dark)
            end
        end
        platform:SetTint("rest")

        anchorWidget._headerDecos = anchorWidget._headerDecos or {}
        table.insert(anchorWidget._headerDecos, platform)
        return platform
    end

    -- Thin gold hairline rule above a header widget. Horizontal gradient fade
    -- (transparent at both edges, brightest in the center) — built by stacking
    -- two textures since WoW's SetGradient is only 2-stop linear.
    local function AddHeaderHairline(anchorWidget, opts)
        opts = opts or {}
        local placement = opts.placement or "above"  -- "above" or "below"

        local labelW = 0
        if anchorWidget.text and anchorWidget.text.GetStringWidth then
            labelW = anchorWidget.text:GetStringWidth() or 0
        end
        local boxW      = anchorWidget:GetWidth() or 32
        local widthMult = opts.widthMult or 1.1
        local lineWidth = opts.width     or ((boxW + 5 + labelW) * widthMult)
        local thickness = opts.thickness or 1
        local yOffset   = opts.yOffset   or 4
        -- 0.8 was tuned against the old darker floor; quieter now the
        -- surfaces are lifted. Gold also reads perceptually brighter than
        -- the Arena sibling's blue at equal alpha, so don't raise this to
        -- match a blue-panel screenshot by number
        local alpha     = opts.alpha     or 0.65
        -- Initial paint only — SetTint("rest") below repaints in the gold
        -- ladder before the line is ever visible
        local r         = opts.r         or Palette.hairlineRest[1]
        local g         = opts.g         or Palette.hairlineRest[2]
        local b         = opts.b         or Palette.hairlineRest[3]

        local parent = anchorWidget:GetParent()
        local centerOffsetX = opts.centerOffsetX
        if centerOffsetX == nil then
            centerOffsetX = (labelW > 0) and ((labelW + 5) / 2) or 0
        end

        local line = CreateFrame("Frame", nil, parent)
        line:SetSize(lineWidth, thickness)
        if placement == "below" then
            line:SetPoint("TOP", anchorWidget, "BOTTOM", centerOffsetX, -yOffset)
        else
            line:SetPoint("BOTTOM", anchorWidget, "TOP", centerOffsetX, yOffset)
        end
        local anchorLevel = (anchorWidget.GetFrameLevel and anchorWidget:GetFrameLevel())
                         or (parent.GetFrameLevel  and parent:GetFrameLevel())
                         or 1
        line:SetFrameLevel(math.max(anchorLevel - 1, 1))

        -- Left half: transparent at outer edge → bright at center
        local leftHalf = line:CreateTexture(nil, "BACKGROUND", nil, 0)
        leftHalf:SetTexture("Interface\\Buttons\\WHITE8x8")
        leftHalf:SetPoint("TOPLEFT",     line, "TOPLEFT",     0, 0)
        leftHalf:SetPoint("BOTTOMRIGHT", line, "BOTTOM",      0, 0)
        leftHalf:SetGradient("HORIZONTAL",
            CreateColor(r, g, b, 0),
            CreateColor(r, g, b, alpha))

        -- Right half: bright at center → transparent at outer edge
        local rightHalf = line:CreateTexture(nil, "BACKGROUND", nil, 0)
        rightHalf:SetTexture("Interface\\Buttons\\WHITE8x8")
        rightHalf:SetPoint("TOPLEFT",     line, "TOP",         0, 0)
        rightHalf:SetPoint("BOTTOMRIGHT", line, "BOTTOMRIGHT", 0, 0)
        rightHalf:SetGradient("HORIZONTAL",
            CreateColor(r, g, b, alpha),
            CreateColor(r, g, b, 0))

        line.leftHalf  = leftHalf
        line.rightHalf = rightHalf
        line.alpha     = alpha
        -- One hue, two intensities: state changes modulate brightness/alpha
        -- within the gold family, never swap hue (hue = meaning, intensity
        -- = state)
        function line:SetTint(mode)
            -- Ladder shifted up a step: rest = roughly the old hover,
            -- hover = the candle tier
            local nr, ng, nb, na
            if mode == "hover" then
                nr, ng, nb = unpack(Palette.hairlineHover)
                na = math.min(self.alpha * 1.25, 1)
            else -- "rest"
                nr, ng, nb = unpack(Palette.hairlineRest)
                na = self.alpha * 0.85
            end
            self.leftHalf:SetGradient("HORIZONTAL",
                CreateColor(nr, ng, nb, 0),
                CreateColor(nr, ng, nb, na))
            self.rightHalf:SetGradient("HORIZONTAL",
                CreateColor(nr, ng, nb, na),
                CreateColor(nr, ng, nb, 0))
        end

        line:SetTint("rest")

        anchorWidget._headerDecos = anchorWidget._headerDecos or {}
        table.insert(anchorWidget._headerDecos, line)
        return line
    end

    -- Hover hit area over a header's decoration band: brightens all tracked decorations on hover (rest -> hover within the gold family).
    -- Hit area sits BELOW the anchor widget by frame level so it only captures the band area outside the anchor;
    -- the anchor's own OnEnter/OnLeave is hooked so clicks on the checkbox keep working and the tint stays on
    -- whether the cursor is over the band or the checkbox itself.
    local function AttachHeaderHover(anchorWidget, opts)
        local decos = anchorWidget._headerDecos
        if not decos or #decos == 0 then return end
        opts = opts or {}

        local parent = anchorWidget:GetParent()
        local hitArea = CreateFrame("Frame", nil, parent)
        hitArea:EnableMouse(true)

        -- Span vertically from top of first decoration to bottom of last; horizontally from the widest.
        local widest = decos[1]
        for _, d in ipairs(decos) do
            if d:GetWidth() and widest:GetWidth() and d:GetWidth() > widest:GetWidth() then
                widest = d
            end
        end
        hitArea:SetPoint("TOP",    decos[1],       "TOP",    0, 0)
        hitArea:SetPoint("BOTTOM", decos[#decos],  "BOTTOM", 0, 0)
        hitArea:SetPoint("LEFT",   widest,         "LEFT",   0, 0)
        hitArea:SetPoint("RIGHT",  widest,         "RIGHT",  0, 0)

        -- Sit just below the anchor widget's frame level so the checkbox stays clickable.
        local anchorLevel = (anchorWidget.GetFrameLevel and anchorWidget:GetFrameLevel())
                         or (parent.GetFrameLevel  and parent:GetFrameLevel())
                         or 1
        hitArea:SetFrameLevel(math.max(anchorLevel - 1, 1))

        -- Shared hover counter (incremented on enter, decremented on leave) so the cursor moving
        -- between the band and the checkbox doesn't briefly drop to rest mid-transition.
        local hoverCount, pending = 0, false
        local function applySoon()
            if pending then return end
            pending = true
            C_Timer.After(0, function()
                pending = false
                local mode = (hoverCount > 0) and "hover" or "rest"
                for _, d in ipairs(decos) do
                    if d.SetTint then d:SetTint(mode) end
                end
                -- The control itself responds with the band, not just its
                -- own rect — entering the artwork lights everything
                if anchorWidget.SetHoverVisual then
                    anchorWidget:SetHoverVisual(mode == "hover")
                end
            end)
        end

        hitArea:SetScript("OnEnter", function() hoverCount = hoverCount + 1; applySoon() end)
        hitArea:SetScript("OnLeave", function() hoverCount = hoverCount - 1; applySoon() end)

        -- Frame-based anchors (checkboxes) capture mouse themselves; hook their motion events too.
        if anchorWidget.HookScript then
            anchorWidget:HookScript("OnEnter", function() hoverCount = hoverCount + 1; applySoon() end)
            anchorWidget:HookScript("OnLeave", function() hoverCount = hoverCount - 1; applySoon() end)
        end

        -- Route the anchor's own hover visuals through this counter too
        anchorWidget._bandHover = true

        -- Banded (double-edged) headers wear the accent text tint as their
        -- RESTING colour — the label tiers read: colour-toned banded headers
        -- > white plain headers > grey widget labels. The tint persists on
        -- hover (no step to white); the band artwork brightening carries the
        -- hover cue. (FontString anchors are their own label.)
        local label = anchorWidget.text or (anchorWidget.SetTextColor and anchorWidget) or nil
        if label then label:SetTextColor(unpack(Palette.textHover)) end

        -- The band reads as part of the control, so it clicks like one
        hitArea:SetScript("OnMouseDown", function()
            if anchorWidget.Click and anchorWidget:IsEnabled() then
                anchorWidget:Click()
            end
        end)

        return hitArea
    end

    -- THE header artwork build: hairline / platform / hairline, vertical
    -- profile derived from the label's rendered string. Accepts a plain
    -- FontString (band sized from and centered on the text) or a
    -- checkbox-style widget (band widened to encapsulate the toggle pill,
    -- centered on the pill+label combo). The band anchors to the label
    -- itself, so it tracks the text vertically, and _headerDecos is copied
    -- onto the widget so AttachHeaderHover keeps working. Banded headers
    -- wear the candlelight tint as their resting colour.
    local function DecorateTextHeader(target)
        local label = target.text or target
        local w = (label.GetStringWidth and label:GetStringWidth()) or 100
        local width, centerX = w, 0
        if target ~= label then
            -- Toggle anchors: extend the band leftward so it encapsulates
            -- the pill too — label gap + checkbox half + scaled pill track
            -- half (34px track centered in the box) — and shift the band's
            -- center onto the pill+label combo. Pill scale and label gap
            -- must already be final when this runs.
            local _, _, _, gap = label:GetPoint(1)
            gap = gap or 5
            local pillScale = (target.togglePill and target.togglePill:GetScale()) or 1
            local leftExtent = gap + ((target:GetWidth() or 32) / 2) + (17 * pillScale)
            width = w + leftExtent
            centerX = -leftExtent / 2
        end
        AddHeaderHairline(label, { width = width * 1.1, centerOffsetX = centerX })
        AddHeaderPlatform(label, { frontWidth = width * 1.2, centerOffsetX = centerX })
        AddHeaderHairline(label, { placement = "below", yOffset = 4, width = width * 1.1, centerOffsetX = centerX })
        if label.SetTextColor then label:SetTextColor(unpack(Palette.textHover)) end
        if target ~= label then
            target._headerDecos = label._headerDecos
        end
    end

    -- Color swatch: chrome button that opens the color picker
    local function CreateColorSwatch(parent, xPos, yPos, settingKeyR, settingKeyG, settingKeyB, getColorFunc)
        local btn = CreateChromeButton(parent, 300, 40, "Border Colour")
        btn:SetPoint("TOPLEFT", xPos, yPos)
        btn:SetScript("OnClick", function()
            local r, g, b = getColorFunc()
            local info = {
                r = r, g = g, b = b,
                hasOpacity = false,
                swatchFunc = function()
                    local cr, cg, cb = ColorPickerFrame:GetColorRGB()
                    if ns.settings then
                        ns.settings[settingKeyR] = cr
                        ns.settings[settingKeyG] = cg
                        ns.settings[settingKeyB] = cb
                    end
                    if ns.RefreshTestBars then ns.RefreshTestBars() end
                end,
                cancelFunc = function()
                    if ns.settings then
                        ns.settings[settingKeyR] = r
                        ns.settings[settingKeyG] = g
                        ns.settings[settingKeyB] = b
                    end
                    if ns.RefreshTestBars then ns.RefreshTestBars() end
                end,
            }
            ColorPickerFrame:SetupColorPickerAndShow(info)
        end)
        return btn
    end

    -- ============================================
    -- GENERIC Sub-Tab Factory
    -- ============================================
    -- Factory function that creates a sub-tab system inside a parent tab frame
    -- Returns: subFrames table, subButtons table, resetFunc, selectFunc
    -- Usage: local subFrames, subButtons, resetSubs, selectSub = ns.UI.CreateSubTabSystem(parentFrame, {"Tab1", "Tab2", "Tab3"})
    local function CreateSubTabSystem(parentFrame, tabNames)
        local SUB_HEIGHT = 32
        local numSubs = #tabNames
        -- Match the top tab-bar width (PANEL_WIDTH - 32) so each sub-tab is the
        -- same 794px as a top tab and they align directly underneath.
        -- Taken from the CONSTANT rather than the frame on purpose: subBar is
        -- anchored to both edges of `content`, which is now exactly tabBar's
        -- width, so the constant and the frame agree. If that ever stops being
        -- true this is the line that silently misaligns the two rows -- measure
        -- with the snippet in TODO.md rather than nudging offsets.
        local subBarWidth = PANEL_WIDTH - 32
        local subTabWidth = math.floor(subBarWidth / numSubs)
        local SUB_GAP = 6
        local activeSubTab = nil

        -- Sub-tab bar
        local subBar = CreateFrame("Frame", nil, parentFrame, "BackdropTemplate")
        parentFrame._subBar = subBar   -- for ns.DebugTabLayout
        subBar:SetHeight(SUB_HEIGHT)
        subBar:SetPoint("TOPLEFT", 0, 0)
        -- Flush right. The old -8 inset claimed to match the top tab bar and did
        -- the opposite: `content` is already inset to the tab bar's width, so
        -- taking another 8px made the sub bar NARROWER than the row above --
        -- while subTabWidth was still computed from the tab bar's width, so the
        -- sub-tabs were laid out 8px wider than the bar holding them and the row
        -- pushed right.
        subBar:SetPoint("TOPRIGHT", 0, 0)
        -- No bar-wide fill and no backdrop, same as the top tab bar: a gradient
        -- across the whole row filled the gaps between the tabs and squared the
        -- ends off, which cancelled the rounded corners. The tabs sit on the
        -- panel now, as discrete cards.

        -- Sub-tab content area
        local subContent = CreateFrame("Frame", nil, parentFrame)
        -- Both horizontal edges from subBar, bottom from parentFrame. It used to
        -- take its left from subBar and its right from parentFrame -- the same
        -- mixed-reference shape that put the tab rows out of line. Harmless while
        -- the two happen to be equal width, which is exactly why it survives
        -- unnoticed until something upstream moves.
        subContent:SetPoint("TOPLEFT",  subBar, "BOTTOMLEFT",  0, -2)
        subContent:SetPoint("TOPRIGHT", subBar, "BOTTOMRIGHT", 0, -2)
        subContent:SetPoint("BOTTOM",   parentFrame, "BOTTOM", 0, 0)

        local subFrames = {}
        local subButtons = {}

        local function CreateSubContent()
            local frame = CreateFrame("Frame", nil, subContent)
            frame:SetAllPoints()
            frame:Hide()
            return frame
        end

        -- Sub-tab state handler
        local function SetSubTabState(btn, state)
            if state == "active" then
                btn.bgGrad:SetGradient("VERTICAL", CreateColor(0.14, 0.14, 0.14, 0.55), CreateColor(0.14, 0.14, 0.14, 0.40))
                btn.topHighlight:SetGradient("VERTICAL",
                    CreateColor(0.85, 0.92, 1.0, 0.10), CreateColor(0.85, 0.92, 1.0, 0))
                btn.topHighlight:Show()
                btn.glassSheen:SetGradient("VERTICAL",
                    CreateColor(1, 1, 1, 0), CreateColor(1, 1, 1, 0.07))
                btn.glassSheen:Show()
                btn.bottomGlow:SetGradient("VERTICAL",
                    CreateColor(GLOW_R, GLOW_G, GLOW_B, 0.16), CreateColor(GLOW_R, GLOW_G, GLOW_B, 0))
                btn.bottomGlow:Show()
                btn.glow:SetGradient("VERTICAL",
                    CreateColor(GLOW_R, GLOW_G, GLOW_B, 0.07), CreateColor(GLOW_R, GLOW_G, GLOW_B, 0))
                btn.glow:Show()
                -- Raised: no inner recess, but it casts onto the panel below.
                btn.shadowOverlay:Hide()
                btn.shade:SetAlpha(0.36)
                btn.selectOverlay:SetGradient("VERTICAL", Accent(0.30), Accent(0))
                btn.selectOverlay:Show()
                btn.selectTail:SetGradient("VERTICAL", Accent(0), Accent(0.16))
                btn.selectTail:Show()
                btn.text:SetTextColor(1, 1, 1, 1)
            elseif state == "hover" then
                btn.bgGrad:SetGradient("VERTICAL", CreateColor(0.12, 0.12, 0.12, 0.55), CreateColor(0.12, 0.12, 0.12, 0.40))
                btn.topHighlight:SetGradient("VERTICAL",
                    CreateColor(0.85, 0.92, 1.0, 0.07), CreateColor(0.85, 0.92, 1.0, 0))
                btn.topHighlight:Show()
                btn.glassSheen:SetGradient("VERTICAL",
                    CreateColor(1, 1, 1, 0), CreateColor(1, 1, 1, 0.05))
                btn.glassSheen:Show()
                btn.bottomGlow:SetGradient("VERTICAL",
                    CreateColor(GLOW_R, GLOW_G, GLOW_B, 0.08), CreateColor(GLOW_R, GLOW_G, GLOW_B, 0))
                btn.bottomGlow:Show()
                btn.glow:SetGradient("VERTICAL",
                    CreateColor(GLOW_R, GLOW_G, GLOW_B, 0.07), CreateColor(GLOW_R, GLOW_G, GLOW_B, 0))
                btn.glow:Show()
                btn.shadowOverlay:SetAlpha(0.5)
                btn.shadowOverlay:Show()
                btn.shade:SetAlpha(0.20)
                btn.selectOverlay:SetGradient("VERTICAL", Accent(0.06), Accent(0))
                btn.selectOverlay:Show()
                btn.selectTail:SetGradient("VERTICAL", Accent(0), Accent(0.06))
                btn.selectTail:Show()
                btn.text:SetTextColor(0.92, 0.92, 0.92, 1)
            else
                btn.bgGrad:SetGradient("VERTICAL", CreateColor(0.08, 0.08, 0.08, 0.55), CreateColor(0.08, 0.08, 0.08, 0.40))
                btn.topHighlight:SetGradient("VERTICAL",
                    CreateColor(0.85, 0.92, 1.0, 0.03), CreateColor(0.85, 0.92, 1.0, 0))
                btn.topHighlight:Show()
                btn.glassSheen:Hide()
                btn.bottomGlow:Hide()
                btn.glow:Hide()
                btn.shadowOverlay:SetAlpha(1)
                btn.shadowOverlay:Show()
                btn.shade:SetAlpha(0.12)
                btn.selectOverlay:Hide()
                btn.selectTail:Hide()
                btn.text:SetTextColor(unpack(Palette.textMuted))
            end
        end

        -- Sub-tab button creator
        local function CreateSubButton(tabName, tabIndex)
            local btn = CreateFrame("Button", nil, subBar, "BackdropTemplate")
            btn:SetSize(subTabWidth - SUB_GAP, SUB_HEIGHT)
            -- The -2 is a NUDGE, not a fix. The sub-tab row measures as exactly
            -- aligned with the main tabs -- same left edges, same right edges,
            -- verified in the arithmetic and unchanged by fixing the container
            -- width -- yet it reads visibly right on screen. The real cause is
            -- unfound; see TODO.md, and /bft layout prints the real numbers.
            --
            -- Remove this the moment the cause is known. Leaving a nudge in
            -- place is how the next person ends up debugging a 5px lie.
            btn:SetPoint("LEFT", subBar, "LEFT",
                (tabIndex - 1) * subTabWidth + SUB_GAP / 2 - 2, 0)
            -- No SetBackdrop, same as the main tabs: it drew WHITE8x8 at zero
            -- alpha and a backdrop cannot be masked.
            btn.bgGrad = btn:CreateTexture(nil, "BACKGROUND")
            btn.bgGrad:SetTexture("Interface\\RaidFrame\\Raid-Bar-Hp-Fill")
            btn.bgGrad:SetAllPoints(btn)
            btn.bgGrad:SetGradient("VERTICAL",
                CreateColor(0.03, 0.03, 0.04, 0.92),
                CreateColor(0.16, 0.16, 0.18, 0.80))

            -- Cool sheen, not a 1px strip -- see the main tab builder.
            btn.topHighlight = btn:CreateTexture(nil, "ARTWORK", nil, 2)
            btn.topHighlight:SetColorTexture(1, 1, 1, 1)
            btn.topHighlight:SetAllPoints(btn)

            -- Glass sheen: radial glow in upper half for glassy reflection
            -- Directional gradients, same as the main tabs. These were all
            -- RadialGlow stretched across a 788x32 button, which concentrates in
            -- the middle and dies at the ends -- a smudge, not a lit edge.
            btn.glassSheen = btn:CreateTexture(nil, "ARTWORK", nil, 1)
            btn.glassSheen:SetTexture("Interface\\Buttons\\WHITE8x8")
            btn.glassSheen:SetPoint("TOPLEFT",  btn, "TOPLEFT",  0, 0)
            btn.glassSheen:SetPoint("TOPRIGHT", btn, "TOPRIGHT", 0, 0)
            btn.glassSheen:SetHeight(SUB_HEIGHT * 0.55)
            btn.glassSheen:SetGradient("VERTICAL",
                CreateColor(1, 1, 1, 0), CreateColor(1, 1, 1, 0.06))
            btn.glassSheen:Hide()

            btn.bottomGlow = btn:CreateTexture(nil, "ARTWORK", nil, 0)
            btn.bottomGlow:SetTexture("Interface\\Buttons\\WHITE8x8")
            btn.bottomGlow:SetBlendMode("ADD")
            btn.bottomGlow:SetPoint("BOTTOMLEFT",  btn, "BOTTOMLEFT",  0, 0)
            btn.bottomGlow:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", 0, 0)
            btn.bottomGlow:SetHeight(SUB_HEIGHT * 0.5)
            btn.bottomGlow:Hide()

            btn.glow = btn:CreateTexture(nil, "ARTWORK")
            btn.glow:SetTexture("Interface\\Buttons\\WHITE8x8")
            btn.glow:SetBlendMode("ADD")
            btn.glow:SetAllPoints(btn)
            btn.glow:Hide()

            -- Soft feathered shade, the enemy bars' shadowing at sub-tab shape.
            -- Its own file: a texture stretches to what it covers, and this is
            -- 788x32 against the main tab's 391x56.
            btn.shade = btn:CreateTexture(nil, "BACKGROUND", nil, -8)
            btn.shade:SetTexture("Interface\\AddOns\\BlitzFramesTargets\\Textures\\SubTabShade.tga")
            btn.shade:SetVertexColor(0, 0, 0, 1)
            btn.shade:SetPoint("TOPLEFT",     btn, "TOPLEFT",     -2,  2)
            btn.shade:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT",  2, -2)
            btn.shade:SetAlpha(0)

            -- Shadow overlay: dark radial at bottom for 3D depth
            -- Directional recess, same as the main tabs: a vertical gradient,
            -- not a radial blob stretched across a wide rectangle.
            btn.shadowOverlay = btn:CreateTexture(nil, "ARTWORK", nil, -1)
            btn.shadowOverlay:SetTexture("Interface\\Buttons\\WHITE8x8")
            btn.shadowOverlay:SetPoint("BOTTOMLEFT",  btn, "BOTTOMLEFT",  0, 0)
            btn.shadowOverlay:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", 0, 0)
            btn.shadowOverlay:SetHeight(SUB_HEIGHT * 0.45)
            btn.shadowOverlay:SetGradient("VERTICAL",
                CreateColor(0, 0, 0, 0.30), CreateColor(0, 0, 0, 0))

            -- Active selection: gold gradient bottom 17% -> 0% over bottom 2/3
            btn.selectOverlay = btn:CreateTexture(nil, "OVERLAY", nil, 7)
            btn.selectOverlay:SetTexture("Interface\\Buttons\\WHITE8x8")
            btn.selectOverlay:SetPoint("BOTTOMLEFT", btn, "BOTTOMLEFT", 0, 0)
            btn.selectOverlay:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", 0, 0)
            btn.selectOverlay:SetHeight(SUB_HEIGHT * 2 / 3)
            btn.selectOverlay:SetGradient("VERTICAL", Accent(0.17), Accent(0))
            btn.selectOverlay:Hide()

            -- Active selection tail: gold gradient 0% -> 9% over top 1/3
            btn.selectTail = btn:CreateTexture(nil, "OVERLAY", nil, 7)
            btn.selectTail:SetTexture("Interface\\Buttons\\WHITE8x8")
            btn.selectTail:SetPoint("TOPLEFT", btn, "TOPLEFT", 0, 0)
            btn.selectTail:SetPoint("TOPRIGHT", btn, "TOPRIGHT", 0, 0)
            btn.selectTail:SetHeight(SUB_HEIGHT / 3)
            btn.selectTail:SetGradient("VERTICAL", Accent(0), Accent(0.09))
            btn.selectTail:Hide()

            -- Rounded top corners, matching the main tabs. Its own mask, not
            -- theirs: a sub-tab is 794x32 against a main tab's 397x56, and a
            -- mask stretches to what it covers, so sharing one file would land
            -- 8x2px smears here. SubTabRoundMask is pre-distorted for this size
            -- (see bake_tabmask.py) and lands circular at 3px.
            --
            -- Everything drawn INSIDE the button is masked; only dropShadow is
            -- not, because it is cast onto the panel below and clipping it to
            -- the caster's own silhouette would erase it.
            local subMask = btn:CreateMaskTexture()
            subMask:SetTexture("Interface\\AddOns\\BlitzFramesTargets\\Textures\\SubTabRoundMask.tga",
                "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE")
            subMask:SetAllPoints(btn)
            for _, tex in ipairs({ btn.bgGrad, btn.topHighlight, btn.selectOverlay,
                                   btn.selectTail, btn.shadowOverlay,
                                   btn.glassSheen, btn.bottomGlow, btn.glow }) do
                if tex then tex:AddMaskTexture(subMask) end
            end

            for _, tex in ipairs({ btn.bgGrad, btn.topHighlight, btn.selectOverlay,
                                   btn.selectTail, btn.shadowOverlay, btn.glassSheen,
                                   btn.bottomGlow, btn.glow, btn.shade }) do
                if tex then
                    tex:SetSnapToPixelGrid(false)
                    tex:SetTexelSnappingBias(0)
                end
            end
            subMask:SetSnapToPixelGrid(false)
            subMask:SetTexelSnappingBias(0)


            btn.text = btn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
            btn.text:SetPoint("CENTER", 0, 1)
            btn.text:SetText(tabName)
            btn.text:SetTextColor(unpack(Palette.textMuted))

                        -- Press feedback, matching the main tabs.
            local function PressSub(self)
                self.text:SetPoint("CENTER", 0, 0)
                self.bgGrad:SetAlpha(0.72)
            end
            local function ReleaseSub(self)
                self.text:SetPoint("CENTER", 0, 1)
                self.bgGrad:SetAlpha(1)
            end
            btn:HookScript("OnMouseDown", PressSub)
            btn:HookScript("OnMouseUp", ReleaseSub)
            btn:HookScript("OnLeave", ReleaseSub)

btn:SetScript("OnEnter", function(self)
                if activeSubTab ~= tabIndex then
                    SetSubTabState(self, "hover")
                end
            end)

            btn:SetScript("OnLeave", function(self)
                if activeSubTab ~= tabIndex then
                    SetSubTabState(self, "inactive")
                end
            end)

            btn:RegisterForClicks("LeftButtonDown")
            btn:SetScript("OnClick", function(self)
                if activeSubTab == tabIndex then return end  -- already open; don't replay
                if not suppressTabSound then ns.PlayUISound() end
                for i, subBtn in ipairs(subButtons) do
                    if i ~= tabIndex then
                        SetSubTabState(subBtn, "inactive")
                        if subFrames[i].expandAG then subFrames[i].expandAG:Stop() end
                        local frame = subFrames[i]
                        if frame:IsShown() then
                            if not frame.collapseAG then
                                frame.collapseAG = frame:CreateAnimationGroup()
                                frame.collapseAG:SetToFinalAlpha(true)
                                local fade = frame.collapseAG:CreateAnimation("Alpha")
                                fade:SetFromAlpha(1)
                                fade:SetToAlpha(0)
                                fade:SetDuration(0.3)
                                fade:SetSmoothing("IN")
                                local scale = frame.collapseAG:CreateAnimation("Scale")
                                scale:SetScaleFrom(1, 1)
                                scale:SetScaleTo(1, 0.85)
                                scale:SetDuration(0.3)
                                scale:SetSmoothing("IN")
                                scale:SetOrigin("CENTER", 0, 0)
                                frame.collapseAG:SetScript("OnFinished", function()
                                    frame:Hide()
                                end)
                            end
                            frame.collapseAG:Stop()
                            frame.collapseAG:Play()
                        else
                            frame:Hide()
                        end
                    end
                end
                activeSubTab = tabIndex
                SetSubTabState(self, "active")
                -- Stop a still-running collapse so its OnFinished Hide() can't hide the newly active sub-tab
                if subFrames[tabIndex].collapseAG then subFrames[tabIndex].collapseAG:Stop() end
                subFrames[tabIndex]:SetAlpha(0)
                subFrames[tabIndex]:Show()
                if not subFrames[tabIndex].expandAG then
                    subFrames[tabIndex].expandAG = subFrames[tabIndex]:CreateAnimationGroup()
                    subFrames[tabIndex].expandAG:SetToFinalAlpha(true)
                    local fade = subFrames[tabIndex].expandAG:CreateAnimation("Alpha")
                    fade:SetFromAlpha(0)
                    fade:SetToAlpha(1)
                    fade:SetDuration(0.5)
                    fade:SetSmoothing("OUT")
                    local scale = subFrames[tabIndex].expandAG:CreateAnimation("Scale")
                    scale:SetScaleFrom(1, 0.85)
                    scale:SetScaleTo(1, 1)
                    scale:SetDuration(0.5)
                    scale:SetSmoothing("OUT")
                    scale:SetOrigin("CENTER", 0, 0)
                end
                subFrames[tabIndex].expandAG:Stop()
                subFrames[tabIndex].expandAG:Play()
            end)

            return btn
        end

        -- Create sub-tab content frames and buttons
        for i, name in ipairs(tabNames) do
            subFrames[i] = CreateSubContent()
            subButtons[i] = CreateSubButton(name, i)
        end

        -- Reset function: deactivate all sub-tabs
        local function resetFunc()
            for i, subBtn in ipairs(subButtons) do
                SetSubTabState(subBtn, "inactive")
                if subFrames[i].expandAG then subFrames[i].expandAG:Stop() end
                subFrames[i]:Hide()
            end
            activeSubTab = nil
        end

        -- Select function: programmatically select a sub-tab by index
        local function selectFunc(index)
            if index < 1 or index > numSubs then return end
            -- Deactivate all others
            for i, subBtn in ipairs(subButtons) do
                if i ~= index then
                    SetSubTabState(subBtn, "inactive")
                    if subFrames[i].expandAG then subFrames[i].expandAG:Stop() end
                    subFrames[i]:Hide()
                end
            end
            activeSubTab = index
            SetSubTabState(subButtons[index], "active")
            if subFrames[index].collapseAG then subFrames[index].collapseAG:Stop() end
            subFrames[index]:SetAlpha(0)
            subFrames[index]:Show()
            if not subFrames[index].expandAG then
                subFrames[index].expandAG = subFrames[index]:CreateAnimationGroup()
                subFrames[index].expandAG:SetToFinalAlpha(true)
                local fade = subFrames[index].expandAG:CreateAnimation("Alpha")
                fade:SetFromAlpha(0)
                fade:SetToAlpha(1)
                fade:SetDuration(0.5)
                fade:SetSmoothing("OUT")
                local scale = subFrames[index].expandAG:CreateAnimation("Scale")
                scale:SetScaleFrom(1, 0.85)
                scale:SetScaleTo(1, 1)
                scale:SetDuration(0.5)
                scale:SetSmoothing("OUT")
                scale:SetOrigin("CENTER", 0, 0)
            end
            subFrames[index].expandAG:Stop()
            subFrames[index].expandAG:Play()
        end

        -- Drop-in animation for sub-tab bar on parent show
        parentFrame:HookScript("OnShow", function()
            local DROP_DIST = SUB_HEIGHT
            local DROP_DUR = 0.25
            subBar:ClearAllPoints()
            subBar:SetPoint("TOPLEFT", parentFrame, "TOPLEFT", 0, DROP_DIST)
            subBar:SetPoint("TOPRIGHT", parentFrame, "TOPRIGHT", 0, DROP_DIST)
            subBar._dropElapsed = 0
            if not subBar._dropDriver then
                subBar._dropDriver = CreateFrame("Frame", nil, subBar)
            end
            subBar._dropDriver:SetScript("OnUpdate", function(self, elapsed)
                subBar._dropElapsed = subBar._dropElapsed + elapsed
                local t = subBar._dropElapsed / DROP_DUR
                if t >= 1 then
                    subBar:ClearAllPoints()
                    subBar:SetPoint("TOPLEFT", parentFrame, "TOPLEFT", 0, 0)
                    subBar:SetPoint("TOPRIGHT", parentFrame, "TOPRIGHT", 0, 0)
                    self:SetScript("OnUpdate", nil)
                else
                    local eased = 1 - (1 - t) * (1 - t)
                    local yOff = DROP_DIST * (1 - eased)
                    subBar:ClearAllPoints()
                    subBar:SetPoint("TOPLEFT", parentFrame, "TOPLEFT", 0, yOff)
                    subBar:SetPoint("TOPRIGHT", parentFrame, "TOPRIGHT", 0, yOff)
                end
            end)
        end)

        -- Reset sub-tabs when parent is hidden
        parentFrame:HookScript("OnHide", function()
            resetFunc()
        end)

        parentFrame._subButtons = subButtons   -- for ns.DebugTabLayout
        return subFrames, subButtons, resetFunc, selectFunc
    end

    -- ============================================
    -- Raised-card backdrop helper (sits behind a section for visual isolation).
    -- Use AddSectionBackdrop for grouping a checkbox + its sub-widgets into one
    -- card; AddBackdrop wraps a single widget if needed.
    -- ============================================
    local BACKDROP_BG_FILE   = "Interface\\Buttons\\WHITE8x8"
    local BACKDROP_EDGE_FILE = "Interface\\Tooltips\\UI-Tooltip-Border"

    local CARD_SHADOW_BOTTOM = 0.40 -- drop-shadow strength below the card
    local CARD_SHADOW_SIDE   = 0.22 -- fainter on the sides (light from above)

    local function ApplyBackdropChrome(backdrop, alpha, tint)
        backdrop:SetBackdrop({
            bgFile   = BACKDROP_BG_FILE,
            edgeFile = BACKDROP_EDGE_FILE,
            edgeSize = 8,
            insets   = { left = 2, right = 2, top = 2, bottom = 2 },
        })
        backdrop:SetBackdropColor(tint[1], tint[2], tint[3], alpha)
        backdrop:SetBackdropBorderColor(unpack(Palette.cardBorder))

        -- Elevation sandwich: 1px light catch on the top edge, soft drop
        -- shadow pooling below and beside — bottom strongest, sides
        -- fainter, none on the lit side
        local topLight = backdrop:CreateTexture(nil, "ARTWORK")
        topLight:SetTexture("Interface\\Buttons\\WHITE8x8")
        topLight:SetPoint("TOPLEFT", 2, -2)
        topLight:SetPoint("TOPRIGHT", -2, -2)
        topLight:SetHeight(1)
        topLight:SetVertexColor(1, 1, 1, 0.07)

        local shadowB = backdrop:CreateTexture(nil, "BACKGROUND", nil, -8)
        shadowB:SetTexture("Interface\\Buttons\\WHITE8x8")
        shadowB:SetPoint("TOPLEFT",  backdrop, "BOTTOMLEFT",  -3, 2)
        shadowB:SetPoint("TOPRIGHT", backdrop, "BOTTOMRIGHT",  3, 2)
        shadowB:SetHeight(7)
        shadowB:SetGradient("VERTICAL", CreateColor(0, 0, 0, 0), CreateColor(0, 0, 0, CARD_SHADOW_BOTTOM))

        local shadowL = backdrop:CreateTexture(nil, "BACKGROUND", nil, -8)
        shadowL:SetTexture("Interface\\Buttons\\WHITE8x8")
        shadowL:SetPoint("TOPRIGHT",    backdrop, "TOPLEFT",    2, -4)
        shadowL:SetPoint("BOTTOMRIGHT", backdrop, "BOTTOMLEFT", 2,  2)
        shadowL:SetWidth(4)
        shadowL:SetGradient("HORIZONTAL", CreateColor(0, 0, 0, 0), CreateColor(0, 0, 0, CARD_SHADOW_SIDE))

        local shadowR = backdrop:CreateTexture(nil, "BACKGROUND", nil, -8)
        shadowR:SetTexture("Interface\\Buttons\\WHITE8x8")
        shadowR:SetPoint("TOPLEFT",    backdrop, "TOPRIGHT",    -2, -4)
        shadowR:SetPoint("BOTTOMLEFT", backdrop, "BOTTOMRIGHT", -2,  2)
        shadowR:SetWidth(4)
        shadowR:SetGradient("HORIZONTAL", CreateColor(0, 0, 0, CARD_SHADOW_SIDE), CreateColor(0, 0, 0, 0))
    end

    local function AnchorRightEdge(backdrop, widget, paddingX)
        -- Checkboxes have a label fontstring sticking out past the box; anchor
        -- to its right edge so the backdrop wraps the visible label.
        if widget:IsObjectType("CheckButton") and widget.text then
            backdrop:SetPoint("RIGHT", widget.text, "RIGHT", paddingX, 0)
        else
            backdrop:SetPoint("RIGHT", widget, "RIGHT", paddingX, 0)
        end
    end

    local function AddSectionBackdrop(opts)
        opts = opts or {}
        local paddingX = opts.paddingX or opts.padding or 8
        local paddingY = opts.paddingY or opts.padding or 16
        local alpha    = opts.alpha    or 0.5
        local tint     = opts.tint     or Palette.cardTint
        local parent   = opts.parent or (opts.top and opts.top:GetParent()) or (opts.left and opts.left:GetParent())
        if not parent then return nil end

        local backdrop = CreateFrame("Frame", nil, parent, "BackdropTemplate")

        if opts.top    then backdrop:SetPoint("TOP",    opts.top,    "TOP",     0,  paddingY) end
        if opts.left   then backdrop:SetPoint("LEFT",   opts.left,   "LEFT",   -paddingX, 0) end
        if opts.right  then AnchorRightEdge(backdrop, opts.right, paddingX) end
        if opts.bottom then backdrop:SetPoint("BOTTOM", opts.bottom, "BOTTOM",  0, -paddingY) end

        -- Sit behind everything in the section
        local minLevel = math.huge
        for _, side in ipairs({ "top", "left", "right", "bottom" }) do
            local w = opts[side]
            if w and w.GetFrameLevel then
                local lvl = w:GetFrameLevel() or 1
                if lvl < minLevel then minLevel = lvl end
            end
        end
        if minLevel == math.huge then minLevel = 2 end
        backdrop:SetFrameLevel(math.max(minLevel - 1, 1))

        ApplyBackdropChrome(backdrop, alpha, tint)
        return backdrop
    end

    local function AddBackdrop(widget, opts)
        opts = opts or {}
        local paddingX = opts.paddingX or opts.padding or 8
        local paddingY = opts.paddingY or opts.padding or 16
        local alpha    = opts.alpha    or 0.5
        local tint     = opts.tint     or Palette.cardTint

        local parent = widget:GetParent()
        local backdrop = CreateFrame("Frame", nil, parent, "BackdropTemplate")

        -- Checkboxes have a label fontstring sitting outside the 32x32 box;
        -- extend the backdrop's right edge to cover it. Other widgets are
        -- containers whose bounds already include their label.
        if widget:IsObjectType("CheckButton") and widget.text then
            backdrop:SetPoint("TOPLEFT",    widget, "TOPLEFT",    -paddingX,  paddingY)
            backdrop:SetPoint("BOTTOMLEFT", widget, "BOTTOMLEFT", -paddingX, -paddingY)
            backdrop:SetPoint("RIGHT",      widget.text, "RIGHT",  paddingX,  0)
        else
            backdrop:SetPoint("TOPLEFT",     widget, "TOPLEFT",     -paddingX,  paddingY)
            backdrop:SetPoint("BOTTOMRIGHT", widget, "BOTTOMRIGHT",  paddingX, -paddingY)
        end

        backdrop:SetFrameLevel(math.max(widget:GetFrameLevel() - 1, 1))
        ApplyBackdropChrome(backdrop, alpha, tint)
        return backdrop
    end

    -- ============================================
    -- Staggered content reveal — menu items cascade in as a positional
    -- wave (top-left first, flowing down-right) instead of one
    -- synchronized fade. Alpha-only: each element fades 0 -> its own
    -- current alpha, so grey-state ghosts stay ghosts. Delay derives
    -- from layout position, so a header and its hairline/platform
    -- decorations (same neighbourhood) arrive together.
    --
    -- STATUS 2026-06-11: DORMANT — not attached anywhere. Both an
    -- AnimationGroup version and this OnUpdate-driver version produced a
    -- whole-menu flicker as the entrance settled (suspected interaction
    -- with the tab/sub-tab fade+scale envelopes, whose base alpha is 0
    -- while their AG plays). Needs live in-game debugging before
    -- re-attaching via UI.AttachStaggeredReveal(frame).
    -- ============================================
    local REVEAL_WINDOW = 0.22  -- spread of start delays across the frame
    local REVEAL_FADE   = 0.30  -- per-element fade once its turn comes

    -- Driven by a single OnUpdate (same pattern as the tab-deal cascade and
    -- accent sweep) rather than per-element AnimationGroups: an Alpha AG
    -- finishing while the parent frame's own fade envelope is animating
    -- causes a one-frame alpha pop per element at commit time.
    local function AttachStaggeredReveal(frame)
        local driver = CreateFrame("Frame", nil, frame)
        local items = {}

        -- Restore exact target alphas and stop driving; safe to call any
        -- time (start of a new pass, OnHide mid-cascade, natural finish)
        local function Finish()
            driver:SetScript("OnUpdate", nil)
            for _, it in ipairs(items) do
                it.el:SetAlpha(it.target)
                it.el._bfaRevealItem = nil
            end
            wipe(items)
        end

        frame:HookScript("OnShow", function(self)
            Finish() -- clean up any interrupted pass first
            local fl, ft = self:GetLeft(), self:GetTop()
            local fw, fh = self:GetWidth() or 0, self:GetHeight() or 0
            if not fl or not ft or fw <= 0 or fh <= 0 then return end

            local function Collect(el)
                if not el:IsShown() then return end
                -- Don't fight an in-flight grey-state fade
                if el._bfaFade and el._bfaFade:IsPlaying() then return end
                local target = el:GetAlpha()
                if target <= 0 then return end
                local l = el.GetLeft and el:GetLeft() or fl
                local t = el.GetTop  and el:GetTop()  or ft
                local fracX = math.min(math.max((l - fl) / fw, 0), 1)
                local fracY = math.min(math.max((ft - t) / fh, 0), 1)
                items[#items + 1] = {
                    el = el, target = target,
                    delay = (fracX * 0.55 + fracY * 0.45) * REVEAL_WINDOW,
                }
                el._bfaRevealItem = items[#items]
                el:SetAlpha(0)
            end

            for _, region in ipairs({ self:GetRegions() }) do Collect(region) end
            for _, child in ipairs({ self:GetChildren() }) do Collect(child) end
            if #items == 0 then return end

            local elapsed = 0
            driver:SetScript("OnUpdate", function(_, dt)
                elapsed = elapsed + dt
                local done = true
                for _, it in ipairs(items) do
                    local p = (elapsed - it.delay) / REVEAL_FADE
                    if p < 1 then done = false end
                    p = math.min(math.max(p, 0), 1)
                    local eased = 1 - (1 - p) * (1 - p)
                    it.el:SetAlpha(it.target * eased)
                end
                if done then Finish() end
            end)
        end)

        -- Fires on effective hide too (panel closing), so nothing is ever
        -- left stuck at partial alpha
        frame:HookScript("OnHide", Finish)
    end

    -- ============================================
    -- Confirmation prompt for destructive actions
    -- ============================================
    -- One shared frame, reused. Lives here rather than in a tab file so any
    -- tab can gate a destructive action behind it. Styled to match the profile
    -- dialog: same backdrop, same neutral border, same chrome buttons.
    local confirmDialog

    local function ShowConfirm(opts)
        if not confirmDialog then
            local f = CreateFrame("Frame", "BlitzFramesTargetsConfirmDialog", UIParent, "BackdropTemplate")
            f:SetSize(420, 175)
            f:SetPoint("CENTER")
            f:SetFrameStrata("FULLSCREEN_DIALOG")
            -- Above the profile dialog (level 200): a confirm can be raised
            -- from it and must not open behind it.
            f:SetFrameLevel(250)
            f:SetToplevel(true)
            f:SetBackdrop({
                bgFile   = "Interface\\Buttons\\WHITE8x8",
                edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
                edgeSize = 14,
                insets   = { left = 4, right = 4, top = 4, bottom = 4 },
            })
            f:SetBackdropColor(0.05, 0.05, 0.05, 0.95)
            f:SetBackdropBorderColor(unpack(Palette.panelBorder))
            f:EnableMouse(true)
            f:Hide()

            f.title = f:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
            f.title:SetPoint("TOP", 0, -16)
            f.title:SetTextColor(unpack(Palette.textBright))

            f.body = f:CreateFontString(nil, "OVERLAY", "GameFontNormal")
            f.body:SetPoint("TOPLEFT",  20, -50)
            f.body:SetPoint("TOPRIGHT", -20, -50)
            f.body:SetJustifyH("CENTER")
            f.body:SetJustifyV("TOP")
            f.body:SetHeight(66)
            f.body:SetTextColor(unpack(Palette.textLabel))

            f.acceptBtn = CreateChromeButton(f, 130, 26, "Confirm")
            f.acceptBtn:SetPoint("BOTTOMRIGHT", -16, 14)

            f.cancelBtn = CreateChromeButton(f, 130, 26, "Cancel")
            f.cancelBtn:SetPoint("RIGHT", f.acceptBtn, "LEFT", -8, 0)
            f.cancelBtn:SetScript("OnClick", function() f:Hide() end)

            -- Escape cancels, as every other dialog in the game does.
            f:SetScript("OnKeyDown", function(self, key)
                if key == "ESCAPE" then
                    self:SetPropagateKeyboardInput(false)
                    self:Hide()
                else
                    self:SetPropagateKeyboardInput(true)
                end
            end)
            confirmDialog = f
        end

        local f = confirmDialog
        f.title:SetText(opts.title or "Are you sure?")
        f.body:SetText(opts.body or "")
        f.acceptBtn.text:SetText(opts.acceptText or "Confirm")
        f.acceptBtn:SetScript("OnClick", function()
            f:Hide()
            if opts.onAccept then opts.onAccept() end
        end)
        f:EnableKeyboard(true)
        f:SetPropagateKeyboardInput(true)
        f:Show()
        f:Raise()
    end

    -- ============================================
    -- Export shared infrastructure for tab files
    -- ============================================
    -- ============================================
    -- Peek keybind widget
    -- ============================================
    -- ONE global binding ("BLITZFRAMESTARGETS_PEEK"), shown in four places.
    -- Battlegrounds > Enemy Bars owns it; the other three are greyed,
    -- non-interactive mirrors, so the key is visible wherever Text Display is
    -- set without implying it can differ per frame set.
    --
    -- The LABEL is per-widget though, read from that frame set's own Text
    -- Display mode: holding the key swaps to the other source, so on a "Name"
    -- frame set it reveals specs and on a "Spec" one it reveals names.
    --
    -- Binding is stored by Blizzard, NOT in the addon profile — it belongs to
    -- the character/account binding set, and duplicating it into a profile
    -- would let the two disagree.
    local PEEK_BINDING = "BLITZFRAMESTARGETS_PEEK"
    local keybindWidgets = {}

    local function PeekKeyText()
        local key = GetBindingKey(PEEK_BINDING)
        return key and GetBindingText(key) or "Not Bound"
    end

    local function RefreshKeybindWidgets()
        local text = PeekKeyText()
        for _, w in ipairs(keybindWidgets) do
            if not w.listening then
                w.btn.text:SetText(text)
            end
            -- The variable half is the point of the label: the key reveals
            -- whichever source this frame set is NOT currently showing.
            --
            -- On "Show Both" there is nothing to reveal, so the control greys
            -- out for THAT frame set only. The binding itself is untouched —
            -- it stays set, other frame sets keep using it, and switching back
            -- to Name or Spec re-enables this widget with the key intact.
            local mode = w.getMode and w.getMode() or "name"
            local both = (mode == "both")
            w.modeText:SetText(both and "Both already shown"
                or ("Hold to show " .. (mode == "spec" and "Names" or "Specs")))
            if not (w.listening or w.pendingClear) then
                w.hint:SetText(both and "not used while Show Both is selected"
                    or "click to bind  |  right-click clears")
            end
            w.btn:SetEnabled(not both)
            w:SetAlpha(both and 0.45 or 1)
        end
    end

    -- Exposed so the tabs can refresh after their Text Display dropdown changes.
    ns.RefreshKeybindWidgets = RefreshKeybindWidgets

    local function ApplyPeekBinding(widget, key)
        -- SetBinding is combat-locked. The whole point of the declarative
        -- Bindings.xml entry is that USING the key works in combat; only
        -- REBINDING it has to wait.
        if InCombatLockdown() then
            widget.btn.text:SetText("In combat")
            return false
        end
        -- Set the NEW key before clearing the old one. The previous order
        -- cleared first, so a SetBinding failure left the user with no binding
        -- at all rather than the one they already had.
        local old = GetBindingKey(PEEK_BINDING)
        if key then
            if not SetBinding(key, PEEK_BINDING) then
                return false                      -- old binding still intact
            end
            if old and old ~= key then SetBinding(old, nil) end
        elseif old then
            SetBinding(old, nil)
        end
        SaveBindings(GetCurrentBindingSet())
        return true
    end

    -- How long an armed widget waits before giving up, and how long the
    -- "you are about to steal a binding" confirmation stays open.
    local ARM_TIMEOUT    = 8
    local CONFIRM_WINDOW = 6

    local function BindingLabel(action)
        return _G["BINDING_NAME_" .. action] or action
    end

    -- Modifier-only presses are held, not committed: the user is still on the
    -- way to the real key.
    local IGNORED = {
        LSHIFT = true, RSHIFT = true, LCTRL = true, RCTRL = true,
        LALT = true, RALT = true, UNKNOWN = true,
    }

    -- Forward declaration: StartListening installs CommitKey as the OnKeyDown
    -- handler, but CommitKey is defined below it (it needs StopListening).
    -- Without this the closure would capture a nil GLOBAL of the same name and
    -- fail silently at the one moment it matters.
    local CommitKey

    local function StopListening(widget)
        widget.listening    = false
        widget.pendingKey   = nil
        widget.btn:SetScript("OnKeyDown", nil)
        widget:SetScript("OnUpdate", nil)
        if widget.armGlow then widget.armGlow:Hide() end
        widget.btn:EnableKeyboard(false)
        widget.btn:SetPropagateKeyboardInput(true)
        RefreshKeybindWidgets()
    end

    -- Arming is the dangerous state, so it is the one that gets the guards.
    --
    -- While armed the button holds SetPropagateKeyboardInput(false), which
    -- SWALLOWS the next keypress — the key does not do its normal job, it
    -- silently becomes the peek binding instead. An armed widget the user has
    -- forgotten about is therefore invisible until it eats something, which is
    -- how this binding changes without anyone meaning to change it.
    --
    -- Three things close that hole: it will not arm in combat, it disarms
    -- itself after ARM_TIMEOUT, and it is unmistakable while it is armed.
    local function StartListening(widget)
        if InCombatLockdown() then
            widget.btn.text:SetText("Not while in combat")
            C_Timer.After(1.5, RefreshKeybindWidgets)
            return
        end
        widget.listening  = true
        widget.pendingKey = nil
        widget.btn:SetScript("OnKeyDown", function(_, key) CommitKey(widget, key) end)
        widget.expires    = GetTime() + ARM_TIMEOUT
        if widget.armGlow then widget.armGlow:Show() end
        widget.btn:EnableKeyboard(true)
        widget.btn:SetPropagateKeyboardInput(false)
        widget.hint:SetText("press ESC to cancel")
        widget:SetScript("OnUpdate", function(self)
            -- Combat starting while armed is the same trap as arming in it.
            if InCombatLockdown() then StopListening(self) return end
            local left = self.expires - GetTime()
            if left <= 0 then StopListening(self) return end
            if self.pendingKey then
                self.btn.text:SetText(("Press again  %d"):format(math.ceil(left)))
            else
                self.btn.text:SetText(("Press a key...  %d"):format(math.ceil(left)))
            end
        end)
        RefreshKeybindWidgets()
    end

    function CommitKey(widget, key)
        -- THE guard. Everything else here protects the arming; this protects
        -- the binding itself. Without it CommitKey binds on any OnKeyDown that
        -- reaches the button, armed or not -- so a keypress could be captured
        -- without the panel ever having been opened, which is exactly what was
        -- happening. EnableKeyboard(false) was the only thing standing in the
        -- way, and one frame handing this button keyboard input defeated it.
        if not widget.listening then return end
        if key == "ESCAPE" then
            StopListening(widget)
            return
        end
        if IGNORED[key] then return end
        if IsShiftKeyDown() then key = "SHIFT-" .. key end
        if IsControlKeyDown() then key = "CTRL-" .. key end
        if IsAltKeyDown() then key = "ALT-" .. key end

        -- Same key twice confirms a steal.
        if widget.pendingKey == key then
            ApplyPeekBinding(widget, key)
            StopListening(widget)
            return
        end

        -- SetBinding takes a key from whatever already owns it, silently. If
        -- something else owns this one, say what and make the user press it a
        -- second time — one stray keystroke should not cost them an ability.
        local action = GetBindingAction(key)
        if action and action ~= "" and action ~= PEEK_BINDING then
            widget.pendingKey = key
            widget.expires    = GetTime() + CONFIRM_WINDOW
            widget.hint:SetText(("|cffd9a838%s|r is %s — press again to take it")
                :format(GetBindingText(key), BindingLabel(action)))
            return
        end

        ApplyPeekBinding(widget, key)
        StopListening(widget)
    end

    -- getMode returns "name"/"spec" for this frame set. Every instance is
    -- interactive: one binding sits behind all four, but a greyed mirror read
    -- as broken — a visible setting the user could not touch. All four write
    -- the same binding and all four refresh together.
    --
    -- Laid out as a centred stack under the Keybinds header:
    --     Text Toggle Keybind      <- mini header, names the control
    --     [    CTRL-X    ]         <- chrome button, same as the Profiles page
    --     Hold to show Specs       <- what it does, varies per frame set
    --     click to bind | right-click clears
    local KEYBIND_W = 220

    local function CreateKeybindWidget(parent, anchorTo, getMode)
        local w = CreateFrame("Frame", nil, parent)
        w:SetSize(KEYBIND_W, 92)
        w:SetPoint("TOP", anchorTo, "BOTTOM", 0, -34)

        local title = w:CreateFontString(nil, "OVERLAY")
        title:SetFont(ns.LOCALE_FONT, 13, "")
        title:SetPoint("TOP", w, "TOP", 0, 0)
        title:SetJustifyH("CENTER")
        title:SetText("Text Toggle Keybind")
        title:SetTextColor(unpack(Palette.textHover))
        AddHeaderHairline(title, { placement = "below", yOffset = 3,
                                   width = title:GetStringWidth() * 1.25 })
        w.title = title

        local btn = CreateChromeButton(w, 150, 26, "Not Bound")
        btn:SetPoint("TOP", title, "BOTTOM", 0, -12)
        w.btn = btn

        -- Armed tell. Its own texture rather than a backdrop recolour, because
        -- the chrome button's OnLeave resets the border and would wipe it.
        local armGlow = btn:CreateTexture(nil, "OVERLAY", nil, 6)
        armGlow:SetTexture("Interface\\Buttons\\WHITE8x8")
        armGlow:SetAllPoints(btn)
        armGlow:SetBlendMode("ADD")
        armGlow:SetVertexColor(0.80, 0.22, 0.16, 0.25)
        armGlow:Hide()
        w.armGlow = armGlow

        local modeText = w:CreateFontString(nil, "OVERLAY")
        modeText:SetFont(ns.LOCALE_FONT, 12, "")
        modeText:SetPoint("TOP", btn, "BOTTOM", 0, -7)
        modeText:SetJustifyH("CENTER")
        modeText:SetTextColor(unpack(Palette.textLabel))
        w.modeText = modeText

        local hint = w:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        hint:SetPoint("TOP", modeText, "BOTTOM", 0, -4)
        hint:SetJustifyH("CENTER")
        hint:SetTextColor(unpack(Palette.textMuted))
        hint:SetText("click to bind  |  right-click clears")
        w.hint = hint

        w.getMode = getMode
        keybindWidgets[#keybindWidgets + 1] = w

        -- Keyboard capture lives on the BUTTON, which is what the user clicks.
        btn:RegisterForClicks("LeftButtonUp", "RightButtonUp")
        btn:SetScript("OnClick", function(_, button)
            if button == "RightButton" then
                -- Clearing needs the same confirmation a steal does. This was
                -- the one path with no guard on it at all: a single stray
                -- right-click -- a misclick, a click meant for the panel behind
                -- it -- wiped the binding instantly, silently and with no way
                -- back, which presents exactly like the key having changed by
                -- itself.
                if w.pendingClear then
                    w.pendingClear = nil
                    ApplyPeekBinding(w, nil)
                    RefreshKeybindWidgets()
                    return
                end
                if w.listening then StopListening(w) end
                w.pendingClear = true
                w.hint:SetText("|cffd9a838right-click again|r to clear")
                C_Timer.After(CONFIRM_WINDOW, function()
                    if w.pendingClear then
                        w.pendingClear = nil
                        RefreshKeybindWidgets()
                    end
                end)
                return
            end
            w.pendingClear = nil
            if w.listening then
                StopListening(w)
                return
            end
            StartListening(w)
        end)
        -- Set explicitly rather than trusting the defaults: this button must
        -- start deaf, and StartListening is the only thing that opens it.
        btn:EnableKeyboard(false)
        btn:SetPropagateKeyboardInput(true)
        btn:SetScript("OnHide", function()
            if w.listening then StopListening(w) end
        end)
        return w
    end



    -- ============================================
    -- "How to move the bars" note
    -- ============================================
    -- Sits ABOVE the preview column, so the instruction is read before the
    -- thing it describes rather than discovered underneath it.
    --
    -- Styled to the panel rather than to Blizzard's defaults: the old version
    -- was GameFontNormalLarge with raw #ffd100 yellow and #ff0000 red, which
    -- shouted in a palette that has no pure red or pure yellow in it. Attention
    -- now comes from the ACCENT gold on the two words that matter (the modifier
    -- and the restriction) against quiet body text — the same trick the rest of
    -- the panel uses for interactive elements.
    local function AccentHex()
        return string.format("%02x%02x%02x",
            math.floor(Palette.accent[1] * 255 + 0.5),
            math.floor(Palette.accent[2] * 255 + 0.5),
            math.floor(Palette.accent[3] * 255 + 0.5))
    end

    local NOTE_WIDTH = 430

    -- A DOUBLE rule: a bright hairline with a dimmer one under it. Both fade
    -- out at each end via a gradient, so the frame reads as a printed header
    -- rather than as two boxes drawn on the panel.
    local function DoubleRule(parent, yOff)
        local a = Palette.accent
        -- Two hairlines: a brighter one and a dimmer one 3px under it.
        for _, spec in ipairs({ { 0, 0.55 }, { -3, 0.22 } }) do
            local dy, peak = spec[1], spec[2]
            -- Each rule is two halves so the gradient can fade out at BOTH
            -- ends; a single texture can only ramp one way.
            local left = parent:CreateTexture(nil, "ARTWORK")
            left:SetHeight(1)
            left:SetPoint("TOPLEFT",  parent, "TOPLEFT", 0, yOff + dy)
            left:SetPoint("TOPRIGHT", parent, "TOP",     0, yOff + dy)
            left:SetColorTexture(1, 1, 1, 1)
            left:SetGradient("HORIZONTAL",
                CreateColor(a[1], a[2], a[3], 0),
                CreateColor(a[1], a[2], a[3], peak))

            local right = parent:CreateTexture(nil, "ARTWORK")
            right:SetHeight(1)
            right:SetPoint("TOPLEFT",  parent, "TOP",      0, yOff + dy)
            right:SetPoint("TOPRIGHT", parent, "TOPRIGHT", 0, yOff + dy)
            right:SetColorTexture(1, 1, 1, 1)
            right:SetGradient("HORIZONTAL",
                CreateColor(a[1], a[2], a[3], peak),
                CreateColor(a[1], a[2], a[3], 0))
        end
    end

    -- ============================================
    -- Themed help popup
    -- ============================================
    -- GameTooltip is Blizzard's chrome and reads as borrowed the moment it
    -- opens next to this panel, so anything that needs explaining gets this
    -- instead: the same atmospheric plate, the same desaturate-and-dim
    -- treatment, the same gold double rule and candle glow.
    --
    -- Meant for features with no legacy equivalent, where a two-word label
    -- cannot carry the meaning on its own.
    local HELP_WIDTH = 300
    local HELP_PAD   = 14
    local HELP_HEAD  = 30      -- title band: font + rule + breathing room

    -- The surface itself: panel plate, candle glow, tooltip border. Split out
    -- of AttachHelpTip so the hover tips and the move note are made of exactly
    -- the same thing and cannot drift apart.
    --
    -- `glowAt` is "TOP" when there is a title for the glow to sit behind, and
    -- "CENTER" when the card is body text only.
    local function StyleHelpSurface(f, width, glowAt)
        f:SetBackdrop({
            bgFile   = "Interface\\Buttons\\WHITE8x8",
            edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
            edgeSize = 12,
            insets   = { left = 3, right = 3, top = 3, bottom = 3 },
        })
        f:SetBackdropColor(Palette.panelBase[1], Palette.panelBase[2], Palette.panelBase[3], 0.97)
        f:SetBackdropBorderColor(unpack(Palette.panelBorder))

        -- The panel's own backdrop plate, dimmer. Desaturated for the same
        -- reason the panel desaturates it: it was authored bright and by a
        -- different hand, and the theme has to pull it back in.
        local art = f:CreateTexture(nil, "BACKGROUND", nil, 0)
        art:SetTexture("Interface\\AddOns\\BlitzFramesTargets\\Textures\\PanelBG_16x9_RGBA")
        art:SetPoint("TOPLEFT", 3, -3)
        art:SetPoint("BOTTOMRIGHT", -3, 3)
        art:SetDesaturated(true)
        art:SetAlpha(0.22)

        local glow = f:CreateTexture(nil, "ARTWORK")
        glow:SetTexture(GLOW_TEXTURE)
        glow:SetBlendMode("ADD")
        glow:SetVertexColor(GLOW_R, GLOW_G, GLOW_B, 0.10)
        glow:SetSize(width * 0.9, 64)
        if glowAt == "CENTER" then
            glow:SetPoint("CENTER", f, "CENTER", 0, 0)
        else
            glow:SetPoint("TOP", f, "TOP", 0, 12)
        end
        return f
    end

    local function AttachHelpTip(widget, title, body)
        -- Parented to the widget's own frame so switching tabs takes the
        -- popup with it.
        local tip = CreateFrame("Frame", nil, widget:GetParent(), "BackdropTemplate")
        tip:SetFrameStrata("TOOLTIP")
        tip:SetWidth(HELP_WIDTH)
        tip:Hide()
        -- Below-left rather than beside: the settings columns have no
        -- horizontal room to spare, and on TOOLTIP strata overlapping the
        -- controls underneath costs nothing, because it is transient.
        tip:SetPoint("TOPLEFT", widget, "BOTTOMLEFT", 18, -6)
        StyleHelpSurface(tip, HELP_WIDTH, "TOP")

        local head = tip:CreateFontString(nil, "OVERLAY")
        head:SetFont(ns.LOCALE_FONT, 14, "")
        head:SetPoint("TOPLEFT",  HELP_PAD, -HELP_PAD)
        head:SetPoint("TOPRIGHT", -HELP_PAD, -HELP_PAD)
        head:SetJustifyH("LEFT")
        head:SetTextColor(unpack(Palette.textHover))
        head:SetText(title)

        DoubleRule(tip, -(HELP_PAD + 19))

        local text = tip:CreateFontString(nil, "OVERLAY")
        text:SetFont(ns.LOCALE_FONT, 12, "")
        text:SetPoint("TOPLEFT",  HELP_PAD, -(HELP_PAD + HELP_HEAD))
        text:SetPoint("TOPRIGHT", -HELP_PAD, -(HELP_PAD + HELP_HEAD))
        text:SetJustifyH("LEFT")
        text:SetSpacing(3)
        text:SetTextColor(unpack(Palette.textLabel))
        text:SetText(body)

        -- Height follows the wrapped body, and the measurement is the fiddly
        -- part: a FontString inside a frame that has NEVER been rendered does
        -- not report a real height. The first hover after a reload therefore
        -- measured short and clipped both the text and the box, and every
        -- hover after that was fine because the string had been laid out by
        -- then. Measuring again one frame later fixes the first hover too --
        -- by then the string exists, whatever it reported a moment ago.
        local function Fit()
            tip:SetHeight(HELP_PAD + HELP_HEAD + math.max(text:GetStringHeight(), 12) + HELP_PAD)
        end
        widget:HookScript("OnEnter", function()
            tip:Show()
            Fit()
            C_Timer.After(0, Fit)
        end)
        widget:HookScript("OnLeave", function() tip:Hide() end)

        widget.helpTip = tip
        return tip
    end

    -- Anchored to the PREVIEW, not the column. The column is much wider than
    -- the preview and the preview is left-aligned inside it, so centring on the
    -- column left the header visibly off to one side of the thing it sits over.
    -- Anchoring also means it re-centres by itself when the Bar Width slider
    -- moves, which a fixed x could never do.
    -- Sits on the same surface as the hover tips. It used to be bare text
    -- between two rules, which read as a caption on the preview rather than as
    -- the instruction for the most important thing on the page. The card gives
    -- it an edge to own.
    --
    -- The rules went with the change: the border already encapsulates it, and
    -- keeping both put four horizontal lines around two lines of text.
    local function CreateMoveNote(parent, anchorTo, gap)
        local a = AccentHex()
        local f = CreateFrame("Frame", nil, parent, "BackdropTemplate")
        f:SetPoint("BOTTOM", anchorTo, "TOP", 0, gap or 62)
        f:SetWidth(NOTE_WIDTH)
        -- "TOP" now there is a title for the candle glow to sit behind, same
        -- as the hover tips.
        StyleHelpSurface(f, NOTE_WIDTH, "TOP")

        -- Title, double rule, body: the identical stack AttachHelpTip builds,
        -- at the same offsets. Centred rather than left-aligned, because this
        -- card is centred under the preview and the tips are not.
        local head = f:CreateFontString(nil, "OVERLAY")
        head:SetFont(ns.LOCALE_FONT, 14, "")
        head:SetPoint("TOPLEFT",  f, "TOPLEFT",   HELP_PAD, -HELP_PAD)
        head:SetPoint("TOPRIGHT", f, "TOPRIGHT", -HELP_PAD, -HELP_PAD)
        head:SetJustifyH("CENTER")
        head:SetTextColor(unpack(Palette.textHover))
        head:SetText("Reposition Frames")

        DoubleRule(f, -(HELP_PAD + 19))

        local text = f:CreateFontString(nil, "OVERLAY")
        text:SetFont(ns.LOCALE_FONT, 14, "")
        text:SetJustifyH("CENTER")
        text:SetSpacing(3)
        text:SetPoint("TOPLEFT",  f, "TOPLEFT",   HELP_PAD, -(HELP_PAD + HELP_HEAD))
        text:SetPoint("TOPRIGHT", f, "TOPRIGHT", -HELP_PAD, -(HELP_PAD + HELP_HEAD))
        text:SetText(
            "Hold |cff" .. a .. "Ctrl|r and drag to move the bars.\n" ..
            "Only |cff" .. a .. "out of combat|r.")
        text:SetTextColor(unpack(Palette.textLabel))

        -- Same measurement problem as the hover tips, and this one is built
        -- long before the panel is ever shown. The floor covers the usual
        -- two-line English case; the deferred re-fit covers a localisation
        -- that wraps to three.
        local function Fit()
            local th = math.max(34, math.ceil(text:GetStringHeight()))
            f:SetHeight(HELP_PAD + HELP_HEAD + th + HELP_PAD)
        end
        Fit()
        f:HookScript("OnShow", function() C_Timer.After(0, Fit) end)
        return f
    end


    ns.UI = {
        AttachHelpTip = AttachHelpTip,
        -- The help popup's raw pieces, so surfaces built OUTSIDE this function
        -- (the minimap tooltip) are made of the same thing and cannot drift.
        StyleHelpSurface = StyleHelpSurface,
        DoubleRule = DoubleRule,
        HELP_PAD = HELP_PAD,
        HELP_HEAD = HELP_HEAD,
        CreateSlider = CreateSlider,
        CreateCheckbox = CreateCheckbox,
        StyleToggleSwitch = StyleToggleSwitch,
        CreateDropdown = CreateDropdown,
        CreateNumberBox = CreateNumberBox,
        CreateColorSwatch = CreateColorSwatch,
        CreateChromeButton = CreateChromeButton,
        ShowConfirm = ShowConfirm,
        AddHeaderPlatform = AddHeaderPlatform,
        AddHeaderHairline = AddHeaderHairline,
        AttachHeaderHover = AttachHeaderHover,
        DecorateTextHeader = DecorateTextHeader,
        AddBackdrop = AddBackdrop,
        AddSectionBackdrop = AddSectionBackdrop,
        SetGreyState = SetGreyState,
        SetHeaderGreyState = SetHeaderGreyState,
        SetSliderGreyState = SetSliderGreyState,
        SetNumberBoxGreyState = SetNumberBoxGreyState,
        SetDropdownGreyState = SetDropdownGreyState,
        ResetCCFrames = ResetCCFrames,
        GREY_ALPHA = GREY_ALPHA,
        tabFrames = tabFrames,
        tabButtons = tabButtons,
        CreateTabContent = CreateTabContent,
        CreateTabButton = CreateTabButton,
        GLOW_R = GLOW_R, GLOW_G = GLOW_G, GLOW_B = GLOW_B,
        GLOW_TEXTURE = GLOW_TEXTURE,
        content = content,
        PANEL_WIDTH = PANEL_WIDTH,
        suppressTabSound = function() return suppressTabSound end,
        setSuppressTabSound = function(v) suppressTabSound = v end,
        CreateSubTabSystem = CreateSubTabSystem,
        AttachStaggeredReveal = AttachStaggeredReveal,
        CreateKeybindWidget = CreateKeybindWidget,
        CreateMoveNote = CreateMoveNote,
        RefreshKeybindWidgets = RefreshKeybindWidgets,
    }

    -- Layout frames, kept so ns.DebugTabLayout can MEASURE rather than model.
    -- Assigned after the table above, not inside it, or it would be discarded.
    ns.UI.layoutFrames = { panel = panel, header = header, tabBar = tabBar, content = content }


    -- ============================================
    -- Create tabs from separate files
    -- ============================================
    local bgOnShow = ns.CreateBGTab and ns.CreateBGTab() or nil
    local epicBGOnShow = ns.CreateEpicBGTab and ns.CreateEpicBGTab() or nil
    local extrasOnShow = ns.CreateExtrasTab and ns.CreateExtrasTab() or nil
    local profilesOnShow = ns.CreateProfilesTab and ns.CreateProfilesTab() or nil

    -- ============================================
    -- Panel OnShow: Initialize all values
    -- ============================================
    panel:SetScript("OnShow", function()
        -- Initialize all tab widgets from settings
        if bgOnShow then bgOnShow() end
        if epicBGOnShow then epicBGOnShow() end
        if extrasOnShow then extrasOnShow() end
        if profilesOnShow then profilesOnShow() end
        RefreshKeybindWidgets()

        -- Reset to no-tab state (clean slate each open)
        for i, tabBtn in ipairs(tabButtons) do
            SetTabState(tabBtn, "inactive")
            if tabFrames[i].fadeIn then tabFrames[i].fadeIn:Stop() end
            tabFrames[i]:Hide()
        end
        activeTab = nil
        if ns._bgResetSubTabs then ns._bgResetSubTabs() end
        if ns._epicBGResetSubTabs then ns._epicBGResetSubTabs() end

        -- Hide title and author for fade-in after cascade
        title:SetText("BlitzFramesTargets v1.0.0")
        title:SetAlpha(0)
        versionHeader:SetAlpha(0)

        -- Card cascade: OnUpdate-driven, tabs deal right to left from left edge
        local DEAL_DURATION = 0.3
        local DEAL_STAGGER = 0.07
        -- Store each tab's start offset (distance from left edge to final position)
        for i, tabBtn in ipairs(tabButtons) do
            tabBtn:SetAlpha(0)
            local reverseIdx = NUM_TABS - i  -- last tab goes first (index 0), tab 1 last
            tabBtn._dealStart = (i - 1) * TAB_WIDTH  -- how far right of position 0
            tabBtn._dealDelay = reverseIdx * DEAL_STAGGER
            tabBtn._dealDone = false
            -- Position at the stacked left-side start
            tabBtn:ClearAllPoints()
            tabBtn:SetPoint("LEFT", tabBar, "LEFT", 0, 0)
        end
        panel._dealElapsed = 0
        if not panel._dealDriver then
            panel._dealDriver = CreateFrame("Frame", nil, panel)
        end
        panel._dealDriver:SetScript("OnUpdate", function(self, elapsed)
            panel._dealElapsed = panel._dealElapsed + elapsed
            local allDone = true
            for i, tabBtn in ipairs(tabButtons) do
                if tabBtn._dealDone then
                    -- already settled
                elseif panel._dealElapsed < tabBtn._dealDelay then
                    allDone = false
                else
                    local t = (panel._dealElapsed - tabBtn._dealDelay) / DEAL_DURATION
                    if t >= 1 then
                        t = 1
                        tabBtn._dealDone = true
                    else
                        allDone = false
                    end
                    -- Ease out: fast start, gentle finish
                    local eased = 1 - (1 - t) * (1 - t)
                    local offset = tabBtn._dealStart * eased
                    tabBtn:ClearAllPoints()
                    tabBtn:SetPoint("LEFT", tabBar, "LEFT", offset, 0)
                    tabBtn:SetAlpha(math.min(1, eased * 2))
                end
            end
            if allDone then
                self:SetScript("OnUpdate", nil)
                -- Fade in title, then author
                panel._titleFadeElapsed = 0
                if not panel._titleFadeDriver then
                    panel._titleFadeDriver = CreateFrame("Frame", nil, panel)
                end
                panel._titleFadeDriver:SetScript("OnUpdate", function(tfSelf, tfElapsed)
                    panel._titleFadeElapsed = panel._titleFadeElapsed + tfElapsed
                    local tt = panel._titleFadeElapsed / 0.5
                    if tt >= 1 then
                        title:SetAlpha(1)
                        tfSelf:SetScript("OnUpdate", nil)
                        -- Now fade in "by Miracle"
                        versionHeader._verElapsed = 0
                        if not panel._verFadeDriver then
                            panel._verFadeDriver = CreateFrame("Frame", nil, panel)
                        end
                        panel._verFadeDriver:SetScript("OnUpdate", function(vSelf, vElapsed)
                            versionHeader._verElapsed = (versionHeader._verElapsed or 0) + vElapsed
                            local vt = versionHeader._verElapsed / 0.4
                            if vt >= 1 then
                                versionHeader:SetAlpha(1)
                                vSelf:SetScript("OnUpdate", nil)
                            else
                                versionHeader:SetAlpha(vt)
                            end
                        end)
                    else
                        title:SetAlpha(tt)
                    end
                end)
            end
        end)

        -- Glow pulse across entire tab bar
        if not panel.glowPulse then
            panel.glowPulse = tabBar:CreateTexture(nil, "ARTWORK", nil, 2)
            panel.glowPulse:SetTexture(GLOW_TEXTURE)
            panel.glowPulse:SetBlendMode("ADD")
            panel.glowPulse:SetPoint("CENTER", tabBar, "CENTER", 0, 0)
            panel.glowPulse:SetSize(TAB_BAR_WIDTH * 0.6, TAB_HEIGHT * 1.5)
            panel.glowPulse:SetVertexColor(GLOW_R, GLOW_G, GLOW_B, 1)

            panel.glowPulseAG = panel.glowPulse:CreateAnimationGroup()

            panel.glowPulseAG.fadeIn = panel.glowPulseAG:CreateAnimation("Alpha")
            panel.glowPulseAG.fadeIn:SetFromAlpha(0)
            panel.glowPulseAG.fadeIn:SetToAlpha(0.6)
            panel.glowPulseAG.fadeIn:SetDuration(0.25)
            panel.glowPulseAG.fadeIn:SetSmoothing("IN")
            panel.glowPulseAG.fadeIn:SetOrder(1)

            panel.glowPulseAG.scaleUp = panel.glowPulseAG:CreateAnimation("Scale")
            panel.glowPulseAG.scaleUp:SetScaleFrom(0.4, 0.6)
            panel.glowPulseAG.scaleUp:SetScaleTo(1.2, 1.1)
            panel.glowPulseAG.scaleUp:SetDuration(0.25)
            panel.glowPulseAG.scaleUp:SetSmoothing("IN")
            panel.glowPulseAG.scaleUp:SetOrigin("CENTER", 0, 0)
            panel.glowPulseAG.scaleUp:SetOrder(1)

            panel.glowPulseAG.fadeOut = panel.glowPulseAG:CreateAnimation("Alpha")
            panel.glowPulseAG.fadeOut:SetFromAlpha(0.6)
            panel.glowPulseAG.fadeOut:SetToAlpha(0)
            panel.glowPulseAG.fadeOut:SetDuration(0.7)
            panel.glowPulseAG.fadeOut:SetSmoothing("OUT")
            panel.glowPulseAG.fadeOut:SetOrder(2)

            panel.glowPulseAG.scaleOut = panel.glowPulseAG:CreateAnimation("Scale")
            panel.glowPulseAG.scaleOut:SetScaleFrom(1.2, 1.1)
            panel.glowPulseAG.scaleOut:SetScaleTo(1.6, 1.3)
            panel.glowPulseAG.scaleOut:SetDuration(0.7)
            panel.glowPulseAG.scaleOut:SetSmoothing("OUT")
            panel.glowPulseAG.scaleOut:SetOrigin("CENTER", 0, 0)
            panel.glowPulseAG.scaleOut:SetOrder(2)
        end
        panel.glowPulse:SetAlpha(0)
        panel.glowPulseAG:Stop()
        panel.glowPulseAG:Play()

        -- Accent line that sweeps left to right along the bottom of the tab bar
        if not panel.accentLine then
            panel.lineOverlay = CreateFrame("Frame", nil, panel)
            panel.lineOverlay:SetAllPoints(tabBar)
            panel.lineOverlay:SetFrameStrata("TOOLTIP")

            panel.accentLine = CreateFrame("Frame", nil, panel.lineOverlay)
            panel.accentLine:SetHeight(14)
            panel.accentLine.tex = panel.accentLine:CreateTexture(nil, "ARTWORK")
            panel.accentLine.tex:SetAllPoints()
            panel.accentLine.tex:SetTexture(GLOW_TEXTURE)
            panel.accentLine.tex:SetBlendMode("ADD")
            panel.accentLine.tex:SetVertexColor(GLOW_R, GLOW_G, GLOW_B, 1)

            -- Soft glow spark riding the leading edge
            panel.sparkPoint = CreateFrame("Frame", nil, panel.lineOverlay)
            panel.sparkPoint:SetSize(50, 30)
            panel.sparkPoint.tex = panel.sparkPoint:CreateTexture(nil, "ARTWORK")
            panel.sparkPoint.tex:SetAllPoints()
            panel.sparkPoint.tex:SetTexture(GLOW_TEXTURE)
            panel.sparkPoint.tex:SetBlendMode("ADD")
            panel.sparkPoint.tex:SetVertexColor(GLOW_R, GLOW_G, GLOW_B, 1)
        end
        panel.accentLine:SetAlpha(0)
        panel.accentLine:ClearAllPoints()
        panel.accentLine:SetPoint("BOTTOMLEFT", tabBar, "BOTTOMLEFT", 0, 0)
        panel.accentLine:SetWidth(0.01)
        panel.accentLine:Show()
        panel.sparkPoint:SetAlpha(0)
        panel.sparkPoint:Show()
        panel._lineElapsed = 0
        panel._lineActive = true

        if not panel.lineDriver then
            panel.lineDriver = CreateFrame("Frame", nil, panel)
        end
        local LINE_DELAY = 0.6
        local LINE_DURATION = 1.2
        panel.lineDriver:SetScript("OnUpdate", function(self, elapsed)
            if not panel._lineActive then
                self:SetScript("OnUpdate", nil)
                return
            end
            panel._lineElapsed = panel._lineElapsed + elapsed
            if panel._lineElapsed < LINE_DELAY then
                return
            end
            local progress = (panel._lineElapsed - LINE_DELAY) / LINE_DURATION
            if progress >= 1 then
                panel.accentLine:SetWidth(TAB_BAR_WIDTH)
                panel.accentLine:SetAlpha(0.8)
                -- Hide spark and fade out line after reaching full width
                panel.sparkPoint:SetAlpha(0)
                if not panel.accentLine.fadeOut then
                    panel.accentLine.fadeOut = panel.accentLine:CreateAnimationGroup()
                    local fade = panel.accentLine.fadeOut:CreateAnimation("Alpha")
                    fade:SetFromAlpha(0.8)
                    fade:SetToAlpha(0)
                    fade:SetDuration(0.5)
                    fade:SetSmoothing("OUT")
                    fade:SetStartDelay(0.1)
                    panel.accentLine.fadeOut:SetToFinalAlpha(true)
                end
                panel.accentLine.fadeOut:Stop()
                panel.accentLine.fadeOut:Play()
                panel._lineActive = false
                self:SetScript("OnUpdate", nil)
                return
            end
            -- Ease out: fast start, gentle finish
            local eased = 1 - (1 - progress) * (1 - progress)
            local lineWidth = math.max(1, eased * TAB_BAR_WIDTH)
            panel.accentLine:SetWidth(lineWidth)
            panel.accentLine:SetAlpha(0.8)

            -- Spark rides the leading edge
            panel.sparkPoint:ClearAllPoints()
            panel.sparkPoint:SetPoint("CENTER", tabBar, "BOTTOMLEFT", lineWidth, 0)
            panel.sparkPoint:SetAlpha(math.sin(progress * math.pi) * 0.8)
        end)
    end)

    return panel
end

-- Minimap Button
function ns.CreateMinimapButton()
    local button = CreateFrame("Button", "BlitzFramesTargetsMinimapButton", Minimap)
    button:SetSize(32, 32)
    button:SetFrameStrata("MEDIUM")
    button:SetFrameLevel(8)
    button:SetHighlightTexture("Interface\\Minimap\\UI-Minimap-ZoomButton-Highlight")

    -- Button textures
    local overlay = button:CreateTexture(nil, "OVERLAY")
    overlay:SetSize(53, 53)
    overlay:SetTexture("Interface\\Minimap\\MiniMap-TrackingBorder")
    overlay:SetPoint("TOPLEFT")

    local background = button:CreateTexture(nil, "BACKGROUND")
    background:SetSize(20, 20)
    background:SetTexture("Interface\\Icons\\Achievement_BG_trueAVshutout")
    background:SetPoint("CENTER", 0, 0)
    background:SetTexCoord(0.08, 0.92, 0.08, 0.92)

    -- Position around minimap (default angle)
    local minimapAngle = ns.db and ns.db.minimapAngle or 220
    local minimapRadius = 103 -- radius to position on outside of minimap border
    local function UpdatePosition()
        local angle = math.rad(minimapAngle)
        local x = math.cos(angle) * minimapRadius
        local y = math.sin(angle) * minimapRadius
        button:ClearAllPoints()
        button:SetPoint("CENTER", Minimap, "CENTER", x, y)
    end
    UpdatePosition()

    -- Dragging to reposition around minimap
    button:RegisterForDrag("LeftButton")
    button:SetScript("OnDragStart", function(self)
        self.dragging = true
    end)

    button:SetScript("OnDragStop", function(self)
        self.dragging = false
        if ns.db then
            ns.db.minimapAngle = minimapAngle
        end
    end)

    button:SetScript("OnUpdate", function(self)
        if self.dragging then
            local mx, my = Minimap:GetCenter()
            local cx, cy = GetCursorPosition()
            local scale = Minimap:GetEffectiveScale()
            cx, cy = cx / scale, cy / scale
            minimapAngle = math.deg(math.atan2(cy - my, cx - mx))
            UpdatePosition()
        end
    end)

    -- Click handlers
    button:RegisterForClicks("LeftButtonUp", "RightButtonUp")
    button:SetScript("OnClick", function(self, btn)
        if InCombatLockdown() then return end
        if btn == "LeftButton" then
            ns.ToggleSettingsPanel()
        elseif btn == "RightButton" then
            if ns.ToggleTestMode then ns.ToggleTestMode() end
        end
    end)

    -- Tooltip, on the addon's own help surface rather than GameTooltip.
    --
    -- Built here rather than through UI.AttachHelpTip because this one differs
    -- in both ways that helper is fixed: its body is DYNAMIC (combat lockdown
    -- and test-mode availability change the lines) and it has to sit beside a
    -- button that can be dragged anywhere around the minimap, where the
    -- helper's fixed below-left anchor would put it off-screen.
    --
    -- The surface itself is shared, so it cannot drift from the in-panel tips.
    local tip
    local function BuildTip()
        local UI = ns.UI
        if tip or not (UI and UI.StyleHelpSurface) then return tip end

        local W = 250
        tip = CreateFrame("Frame", nil, UIParent, "BackdropTemplate")
        tip:SetFrameStrata("TOOLTIP")
        tip:SetWidth(W)
        tip:Hide()
        UI.StyleHelpSurface(tip, W, "TOP")

        tip.head = tip:CreateFontString(nil, "OVERLAY")
        tip.head:SetFont(ns.LOCALE_FONT, 14, "")
        tip.head:SetPoint("TOPLEFT",  UI.HELP_PAD, -UI.HELP_PAD)
        tip.head:SetPoint("TOPRIGHT", -UI.HELP_PAD, -UI.HELP_PAD)
        tip.head:SetJustifyH("LEFT")
        tip.head:SetTextColor(unpack(Palette.textHover))
        tip.head:SetText("BlitzFramesTargets")

        UI.DoubleRule(tip, -(UI.HELP_PAD + 19))

        tip.body = tip:CreateFontString(nil, "OVERLAY")
        tip.body:SetFont(ns.LOCALE_FONT, 12, "")
        tip.body:SetPoint("TOPLEFT",  UI.HELP_PAD, -(UI.HELP_PAD + UI.HELP_HEAD))
        tip.body:SetPoint("TOPRIGHT", -UI.HELP_PAD, -(UI.HELP_PAD + UI.HELP_HEAD))
        tip.body:SetJustifyH("LEFT")
        tip.body:SetSpacing(4)
        tip.body:SetTextColor(unpack(Palette.textLabel))
        return tip
    end

    button:SetScript("OnEnter", function(self)
        local t = BuildTip()
        if not t then return end

        -- Disabled lines stay VISIBLE and greyed rather than being dropped. A
        -- line that vanishes in combat reads as the addon losing a feature; a
        -- greyed one says the feature exists and is unavailable right now.
        local grey, white = "|cff888888", "|cffffffff"
        local lines = {}
        lines[#lines + 1] = InCombatLockdown()
            and (grey .. "Left-Click:|r|cff888888 Open Settings (unavailable in combat)|r")
            or  (white .. "Left-Click:|r Open Settings")
        if ns.testModeActive then
            lines[#lines + 1] = white .. "Right-Click:|r Disable Test Mode"
        elseif ns.CanAccessTestMode and ns.CanAccessTestMode() then
            lines[#lines + 1] = white .. "Right-Click:|r Test Mode"
        else
            lines[#lines + 1] = grey .. "Right-Click:|r|cff888888 Test Mode (disabled in active PvP)|r"
        end
        -- No "Drag: Move Button" line. Dragging a minimap button is universal
        -- Blizzard behaviour and telling people about it only costs a line.
        t.body:SetText(table.concat(lines, "\n"))

        -- Side flips with the button. It rides the minimap edge and can end up
        -- anywhere, so a fixed side would hang off-screen half the time.
        t:ClearAllPoints()
        local cx = self:GetCenter()
        if cx and cx > (UIParent:GetWidth() / 2) then
            t:SetPoint("TOPRIGHT", self, "TOPLEFT", -8, 0)
        else
            t:SetPoint("TOPLEFT", self, "TOPRIGHT", 8, 0)
        end

        -- Same one-frame re-measure the in-panel tips need: a FontString in a
        -- frame that has never rendered does not report a real height, so the
        -- first hover after a reload would clip without this.
        local function Fit()
            t:SetHeight(ns.UI.HELP_PAD + ns.UI.HELP_HEAD
                        + math.max(t.body:GetStringHeight(), 12) + ns.UI.HELP_PAD)
        end
        t:Show()
        Fit()
        C_Timer.After(0, Fit)
    end)

    button:SetScript("OnLeave", function()
        if tip then tip:Hide() end
    end)

    -- No UpdatePosition/SetAngle exported. They were on the button for an
    -- external caller that never existed, and with the reference no longer
    -- stored anywhere they were unreachable by definition. Dragging still works
    -- -- that path is internal and untouched.

    return button
end

-------------------------------------------------------------------------------
-- Layout measurement
-------------------------------------------------------------------------------
-- Prints where the tab rows ACTUALLY are, in screen pixels.
--
-- This exists because the tab rows have twice looked misaligned while the
-- arithmetic said they were exact -- once from a container deriving its width
-- from two different references, once from unequal mask feathers. Modelling the
-- geometry on paper found neither. Measure instead.
--
-- Values are divided by the panel's effective scale, so they are comparable
-- regardless of the Options Scale setting.
function ns.DebugTabLayout()
    local UI = ns.UI
    local F = UI and UI.layoutFrames
    if not F or not F.panel then
        print("|cffff5555BFT:|r open the options panel first")
        return
    end
    local s = F.panel:GetEffectiveScale()
    local function line(label, f)
        if not f then print(("  %-14s <missing>"):format(label)) return end
        local l, w = f:GetLeft(), f:GetWidth()
        if not l then print(("  %-14s <unplaced>"):format(label)) return end
        print(("  %-14s left %7.1f   right %7.1f   width %7.1f"):format(
            label, l * s, (l + w) * s, w * s))
    end

    print("|cffffd700BlitzFramesTargets layout|r  (scale " .. string.format("%.2f", s) .. ")")
    line("panel",   F.panel)
    line("header",  F.header)
    line("tabBar",  F.tabBar)
    line("content", F.content)

    for i, btn in ipairs(UI.tabButtons or {}) do
        line("  main tab " .. i, btn)
    end

    local active = UI.tabFrames and UI.tabFrames[1]
    for i, frame in ipairs(UI.tabFrames or {}) do
        if frame._subBar then
            line("subBar (tab " .. i .. ")", frame._subBar)
            for j, btn in ipairs(frame._subButtons or {}) do
                line("  sub tab " .. j, btn)
            end
            break
        end
    end
    print("Compare a sub tab's left/right against the main tabs it sits under.")
end
