-- zSettingsFrame/zSF_Panel.lua
-- Top-level settings panel: frame shell, sidebar navigation, tab strip,
-- scrollable content area, and WoW Settings integration.
-- Addon-agnostic: all addon-specific details come from the adapter.
local ZSF = _G.ZSF  -- namespace for style constants and easing functions

-- ── Layout constants ─────────────────────────────────────────────────────────

local PANEL_W       = 960
local PANEL_H       = 600
local TITLE_H       = 76
local STATUS_H      = 24
local SCROLLBAR_W   = 6
local SCROLL_STEP   = 22 -- pixels per mousewheel tick

-- Navigation.
local NAV_H         = 50
local CHILD_H       = NAV_H - 6
local NAV_GAP       = 1
local NAV_TOP_PAD   = 8
local NAV_INDENT    = 16

-- Tabs.
local TAB_H         = 26

-- Content panel fade-in duration when switching nav items or tabs.
local CONTENT_FADE_IN     = 0.60
-- How far (px) a panel starts to the left of its resting position when slide mode is active.
local CONTENT_SLIDE_OFFSET = 40

-- ── Shared memory profiling ticker ─────────────────────────────────────────
-- UpdateAddOnMemoryUsage() is expensive; one ticker services every open panel
-- so the call fires at most once per 15 seconds no matter how many are visible.
local _MEM_INTERVAL = 15
---@type table<function, boolean>
local _memListeners = {}
---@type any|nil
local _memTicker    = nil

local function _tickMemory()
    if not next(_memListeners) then
        if _memTicker then _memTicker:Cancel(); _memTicker = nil end
        return
    end
    UpdateAddOnMemoryUsage()
    for fn in pairs(_memListeners) do fn() end
end

local function _memRegister(fn)
    if _memListeners[fn] then return end
    _memListeners[fn] = true
    UpdateAddOnMemoryUsage(); fn()  -- immediate read on first show
    if not _memTicker then
        _memTicker = C_Timer.NewTicker(_MEM_INTERVAL, _tickMemory)
    end
end

local function _memUnregister(fn)
    _memListeners[fn] = nil
    if not next(_memListeners) and _memTicker then
        _memTicker:Cancel()
        _memTicker = nil
    end
end

-- ── Per-addon panel context (instantiated by Register) ───────────────────────
---@param ctx zSF_AddonContext  Receives panel-specific functions as fields.
function zSF_InitPanelInstance(ctx)

-- Mutable per-panel state — each addon gets its own fresh set of these.
local effectivePanelW = PANEL_W

-- ── Forward state (set by BuildPanel) ────────────────────────────────────────

---@type Frame|nil
local panel          = nil

---@type table|nil    adapter ref
local panelAdapter   = nil

---@type boolean  true when adapter.fadeInAnimation == "slide"
local _slideAnim     = false

---@type fun()|nil  Stored unsubscribe handle returned by adapter.subscribe
local _unsubscribe   = nil

---@type table        { r, g, b } colours set by the adapter bridge
local PRI, SEC       = {}, {}

---@type table  resolved per-panel color overrides; populated by _buildPanel, read by all closures.
local C              = {}

-- Nav runtime.
local navItems       = {} -- array mirror of schema nav entries
local navFrames      = {} -- navFrames[i] = { frame, activeBG, indicator, label, childFrames={}, expandDot? }
local activeNav      = 1
local activeChild    = nil  -- index into current nav's child list, or nil

-- Tab runtime (rebuilt per nav/child).
local tabFrames      = {}
---@type table<number,number>  activeTabPerNav[navIdx] = last active tab index (1-based)
local activeTabPerNav = {}
---@type fun()|nil  Re-applies tab label colors without rebuilding frames; set by RebuildTabs.
local activeTabRefresh = nil

local function GetActiveTab(navIdx)
    return activeTabPerNav[navIdx] or 1
end

-- Composite key for tab panels belonging to a navChild rather than a nav parent.
-- navIdx ≤ ~10, childIdx ≤ 6, so multiplying by 1000 guarantees no collision.
local function ChildTabKey(navIdx, childIdx)
    return navIdx * 1000 + childIdx
end

-- Content regions.
---@type Frame|nil       scrollable content parent
local scrollContent  = nil
---@type ScrollFrame|nil
local scrollFrame    = nil
---@type Frame|nil       right-side content area (parent of tabStrip and scrollFrame)
local contentArea    = nil
---@type Frame|nil       scrollbar thumb
local sbThumb        = nil
---@type Frame|nil       scrollbar track
local sbTrack        = nil
---@type Frame|nil       sidebar nav scrollbar thumb
local navSbThumb     = nil
---@type Frame|nil       sidebar nav scrollbar track
local navSbTrack     = nil
---@type ScrollFrame|nil
local navScrollFrame = nil
---@type Frame|nil        scrollable child that nav items are parented to
local navContent     = nil
---@type boolean  true when WoW's SettingsPanel opened us; false for standalone Open()
local _wowHosted     = false
---@type number|nil  panel:GetWidth() at the time content panels were last built; nil = never
local _builtForWidth = nil
---@type number  effective sidebar width for the current hosting context
local activeSidebarW = ZSF.SIDEBAR_WIDTH

-- Content panels:  contentPanels[navIdx][childIdx] = Frame
-- childIdx = 0 → nav-parent's own content; 1..N → navChild panels.
local contentPanels  = {}

-- Tab panels:  tabPanels[panelKey] = { [tabIdx] = Frame }
local tabPanels      = {}

-- ── Internal: scrollbar thumb update ─────────────────────────────────────────

local function UpdateThumb()
    if not sbTrack or not scrollFrame then return end
    local trackH = sbTrack:GetHeight() or 0
    if trackH <= 0 then return end
    local range = scrollFrame:GetVerticalScrollRange() or 0
    local viewH = scrollFrame:GetHeight() or 0
    local totalH = viewH + range
    if range < 1 then
        sbThumb:Hide()
        sbTrack:Hide()
        return
    end
    sbTrack:Show()
    sbThumb:Show()
    local ratio  = totalH > 0 and (viewH / totalH) or 1
    local thumbH = math.max(trackH * 0.12, trackH * ratio)
    sbThumb:SetHeight(thumbH)
    local travel = trackH - thumbH
    local offset = scrollFrame:GetVerticalScroll() or 0
    local thumbY = range > 0 and -(offset / range * travel) or 0
    sbThumb:ClearAllPoints()
    sbThumb:SetPoint("TOPLEFT",  sbTrack, "TOPLEFT",  0, thumbY)
    sbThumb:SetPoint("TOPRIGHT", sbTrack, "TOPRIGHT", 0, thumbY)
end

local function UpdateNavThumb()
    if not navSbTrack or not navScrollFrame then return end
    local trackH = navSbTrack:GetHeight() or 0
    if trackH <= 0 then return end
    local range = navScrollFrame:GetVerticalScrollRange() or 0
    local viewH = navScrollFrame:GetHeight() or 0
    local totalH = viewH + range
    if range < 1 then
        navSbThumb:Hide()
        navSbTrack:Hide()
        navScrollFrame:SetVerticalScroll(0)
        if navContent then navContent:SetWidth(activeSidebarW) end
        return
    end
    navSbTrack:Show()
    navSbThumb:Show()
    if navContent then navContent:SetWidth(activeSidebarW - (SCROLLBAR_W + 4)) end
    local ratio  = totalH > 0 and (viewH / totalH) or 1
    local thumbH = math.max(trackH * 0.12, trackH * ratio)
    navSbThumb:SetHeight(thumbH)
    local travel = trackH - thumbH
    local offset = navScrollFrame:GetVerticalScroll() or 0
    local thumbY = range > 0 and -(offset / range * travel) or 0
    navSbThumb:ClearAllPoints()
    navSbThumb:SetPoint("TOPLEFT",  navSbTrack, "TOPLEFT",  0, thumbY)
    navSbThumb:SetPoint("TOPRIGHT", navSbTrack, "TOPRIGHT", 0, thumbY)
end

local SMOOTH_SCROLL_SPEED    = 12   -- higher = snappier
local SMOOTH_SNAP_THRESHOLD  = 0.30 -- pixel threshold for final snap

local function SetScrollContentHeight(height, andReset)
    if scrollContent then
        if andReset and scrollFrame then
            if scrollFrame.CancelSmooth then scrollFrame:CancelSmooth() end
            scrollFrame:SetVerticalScroll(0)
            if sbThumb then sbThumb:Hide() end
            if sbTrack then sbTrack:Hide() end
        end
        scrollContent:SetHeight(height)
    end
end

local function ApplySmoothScroll(scrollFrame, thumbUpdater)
    if not scrollFrame then return end
    scrollFrame:EnableMouseWheel(true)
    scrollFrame._smoothTarget   = nil
    scrollFrame._isSmoothing    = false

    local smoothDriver = CreateFrame("Frame")
    smoothDriver:Hide()
    smoothDriver:SetScript("OnUpdate", function(_, elapsed)
        local cur = scrollFrame:GetVerticalScroll() or 0
        local maxScroll = scrollFrame:GetVerticalScrollRange() or 0
        local scale = scrollFrame:GetEffectiveScale() or 1
        maxScroll = math.floor(maxScroll * scale) / scale

        local target = scrollFrame._smoothTarget or cur
        target = math.max(0, math.min(maxScroll, target))

        local diff = target - cur
        if math.abs(diff) < SMOOTH_SNAP_THRESHOLD then
            scrollFrame:SetVerticalScroll(target)
            scrollFrame._smoothTarget = nil
            scrollFrame._isSmoothing = false
            smoothDriver:Hide()
            if thumbUpdater then thumbUpdater() end
            return
        end

        local factor = math.min(1, SMOOTH_SCROLL_SPEED * elapsed)
        local newScroll = cur + diff * factor
        newScroll = math.max(0, math.min(maxScroll, newScroll))

        if diff > 0 then
            newScroll = math.ceil(newScroll * scale) / scale
        else
            newScroll = math.floor(newScroll * scale) / scale
        end
        newScroll = math.max(0, math.min(maxScroll, newScroll))

        scrollFrame:SetVerticalScroll(newScroll)
        if thumbUpdater then thumbUpdater() end

        if math.abs(target - newScroll) < SMOOTH_SNAP_THRESHOLD then
            scrollFrame:SetVerticalScroll(target)
            scrollFrame._smoothTarget = nil
            scrollFrame._isSmoothing = false
            smoothDriver:Hide()
            if thumbUpdater then thumbUpdater() end
        end
    end)

    local function SmoothScrollTo(self, target)
        local maxScroll = self:GetVerticalScrollRange() or 0
        local scale = self:GetEffectiveScale() or 1
        maxScroll = math.floor(maxScroll * scale) / scale
        target = math.max(0, math.min(maxScroll, target))
        target = math.floor(target * scale + 0.5) / scale
        target = math.min(target, maxScroll)

        self._smoothTarget = target
        if not self._isSmoothing then
            self._isSmoothing = true
            smoothDriver:Show()
        end
    end

    local function InstantScrollTo(self, value)
        self._isSmoothing = false
        smoothDriver:Hide()
        local maxScroll = self:GetVerticalScrollRange() or 0
        local scale = self:GetEffectiveScale() or 1
        value = math.floor(value * scale + 0.5) / scale
        value = math.max(0, math.min(maxScroll, value))
        self:SetVerticalScroll(value)
        if thumbUpdater then thumbUpdater() end
    end

    scrollFrame.SmoothScrollTo  = SmoothScrollTo
    scrollFrame.InstantScrollTo = InstantScrollTo
    scrollFrame.CancelSmooth    = function(self)
        self._smoothTarget = nil
        self._isSmoothing  = false
        smoothDriver:Hide()
    end

    scrollFrame:SetScript("OnMouseWheel", function(self, delta)
        local maxScroll = self:GetVerticalScrollRange() or 0
        if maxScroll <= 0 then return end
        local base = (self._isSmoothing and self._smoothTarget) or self:GetVerticalScroll() or 0
        self:SmoothScrollTo(base - delta * SCROLL_STEP)
    end)

    scrollFrame:SetScript("OnScrollRangeChanged", function()
        if thumbUpdater then thumbUpdater() end
    end)
end

-- ── Content panel animation helpers ────────────────────────────────────────────
-- Both helpers are used by ShowContentPanel and the tab-click handler.

local function NeutraliseFrame(frame)
    if frame._zsfFadeInTw then frame._zsfFadeInTw:Cancel(); frame._zsfFadeInTw = nil end
    frame:SetAlpha(1)
    if _slideAnim then
        frame:ClearAllPoints()
        frame:SetPoint("TOPLEFT",  scrollContent, "TOPLEFT",  0, 0)
        frame:SetPoint("TOPRIGHT", scrollContent, "TOPRIGHT", 0, 0)
    end
end

local function AnimateIn(frame)
    frame:SetAlpha(0)
    if not _slideAnim then
        frame._zsfFadeInTw = zAF_Tween(0, 1, CONTENT_FADE_IN, ZAF.EASE_SINE_OUT,
            function(v) frame:SetAlpha(v) end,
            function()
                frame._zsfFadeInTw = nil
                -- Defer by one frame so WoW's layout pass commits the new scroll
                -- child geometry before we read GetVerticalScrollRange().
                C_Timer.After(0, UpdateThumb)
            end)
    else
        -- Slide+fade from the nav side: starts offset left and eases to rest position.
        frame:ClearAllPoints()
        frame:SetPoint("TOPLEFT",  scrollContent, "TOPLEFT",  -CONTENT_SLIDE_OFFSET, 0)
        frame:SetPoint("TOPRIGHT", scrollContent, "TOPRIGHT", -CONTENT_SLIDE_OFFSET, 0)
        frame._zsfFadeInTw = zAF_Tween(0, 1, CONTENT_FADE_IN, ZAF.EASE_CUBIC_OUT, function(t)
            local x = -CONTENT_SLIDE_OFFSET * (1 - t)
            frame:ClearAllPoints()
            frame:SetPoint("TOPLEFT",  scrollContent, "TOPLEFT",  x, 0)
            frame:SetPoint("TOPRIGHT", scrollContent, "TOPRIGHT", x, 0)
            frame:SetAlpha(t)
        end, function()
            frame:ClearAllPoints()
            frame:SetPoint("TOPLEFT",  scrollContent, "TOPLEFT",  0, 0)
            frame:SetPoint("TOPRIGHT", scrollContent, "TOPRIGHT", 0, 0)
            frame:SetAlpha(1)
            frame._zsfFadeInTw = nil
            -- Defer by one frame so WoW's layout pass commits the new scroll
            -- child geometry before we read GetVerticalScrollRange().
            C_Timer.After(0, UpdateThumb)
        end)
    end
end

-- ── Internal: show one content panel, hide all others ────────────────────────

local RebuildTabs  -- forward-declare (defined below, after tabStrip is created)

local function ShowContentPanel(navIdx, childIdx, animate)
    -- Determine up-front whether tabs replace the parent's own content panel.
    local hier    = ctx._navHierarchy
    local navInfo = hier and hier[navIdx]
    local parentHasTabs = (navInfo and navInfo.tabNames and tabPanels[navIdx]) and true or false

    -- Determine whether the selected navChild has its own tab strip.
    local childTabKey = nil
    if childIdx > 0 and navInfo and navInfo.childTabNames and navInfo.childTabNames[childIdx] then
        childTabKey = ChildTabKey(navIdx, childIdx)
    end
    local childHasTabs = childTabKey ~= nil and tabPanels[childTabKey] ~= nil

    -- Hide all regular content panels, fading in the active one when requested.
    -- Tabs replace the parent's own content (childIdx==0 + parentHasTabs)
    -- and replace a navChild's own content (childIdx>0 + childHasTabs).
    for ni, cmap in pairs(contentPanels) do
        for ci, frame in pairs(cmap) do
            local isActive = (ni == navIdx and ci == childIdx)
            if isActive and parentHasTabs and childIdx == 0 then
                isActive = false  -- nav-level tabs replace parent's own content
            end
            if isActive and childHasTabs and childIdx > 0 then
                isActive = false  -- child-level tabs replace navChild's own content
            end
            if isActive then
                frame:Show()
                if animate then
                    AnimateIn(frame)
                else
                    NeutraliseFrame(frame)
                end
            else
                if frame._zsfFadeInTw then frame._zsfFadeInTw:Cancel(); frame._zsfFadeInTw = nil end
                frame:SetAlpha(1)
                frame:Hide()
            end
        end
    end

    -- Determine which tabPanels key is driving the strip for the current selection.
    -- Nav-level: childIdx==0 and the nav itself has tabs.
    -- Child-level: the selected navChild has tabs.
    local activeTabSource = nil
    if childIdx == 0 and parentHasTabs then
        activeTabSource = navIdx
    elseif childHasTabs then
        activeTabSource = childTabKey
    end

    for ni, tabs in pairs(tabPanels) do
        local curTab = GetActiveTab(ni)
        for ti, tf in ipairs(tabs) do
            local isActive = (ni == activeTabSource and ti == curTab)
            if isActive then
                tf:Show()
                if animate then
                    AnimateIn(tf)
                else
                    NeutraliseFrame(tf)
                end
            else
                if tf._zsfFadeInTw then tf._zsfFadeInTw:Cancel(); tf._zsfFadeInTw = nil end
                tf:SetAlpha(1)
                tf:Hide()
            end
        end
    end

    -- Tab strip and scroll height depend on which tab source is active.
    if activeTabSource == navIdx then
        RebuildTabs(navInfo.tabNames, navIdx, navInfo.tabShowWhen, navInfo.tabDisableWhen)
        local activeTf = tabPanels[navIdx][GetActiveTab(navIdx)]
        if activeTf then
            SetScrollContentHeight(activeTf.zsfHeight or 1, true)
        end
    elseif childTabKey ~= nil and activeTabSource == childTabKey then
        RebuildTabs(navInfo.childTabNames[childIdx], childTabKey,
            navInfo.childTabShowWhen    and navInfo.childTabShowWhen[childIdx],
            navInfo.childTabDisableWhen and navInfo.childTabDisableWhen[childIdx])
        local activeTf = tabPanels[childTabKey][GetActiveTab(childTabKey)]
        if activeTf then
            SetScrollContentHeight(activeTf.zsfHeight or 1, true)
        end
    else
        RebuildTabs(nil, nil)
        local cmap = contentPanels[navIdx]
        if cmap then
            local frame = cmap[childIdx]
            if frame then
                SetScrollContentHeight(frame.zsfHeight or 1, true)
            end
        end
    end
    -- Drive the content header with the active nav name/description.
    if panel and panel._navNameText then
        local activeItem = navItems[navIdx]
        panel._navNameText:SetText(activeItem and activeItem.name or "")
        local desc = activeItem and activeItem.description
        if desc and desc ~= "" then
            panel._navDescText:SetText(desc)
            panel._navDescText:Show()
        else
            panel._navDescText:Hide()
        end
    end
end

-- ── Internal: tab strip ──────────────────────────────────────────────────────

local tabStrip     -- Frame
local tabSepLine   -- Texture

-- ── Click-streak animation ────────────────────────────────────────────────────
-- Creates a narrow gradient "light blob" frame that whooshes left→right across
-- the host row when clicked. The gradient edges produce the natural fade-in and
-- fade-out — no separate Alpha animation is needed.
-- Returns a Play() closure to call from OnMouseDown.
local STREAK_W = 80
---@param hostFrame Frame
---@return fun()
local function MakeStreakAnim(hostFrame)
    -- Clip container matches the host exactly; SetClipsChildren confines the
    -- moving streak so it can't bleed outside the row bounds.
    local clipFrame = CreateFrame("Frame", nil, hostFrame)
    clipFrame:SetAllPoints(hostFrame)
    clipFrame:SetClipsChildren(true)

    local streak = CreateFrame("Frame", nil, clipFrame)
    streak:SetWidth(STREAK_W)
    streak:SetPoint("TOPLEFT",    hostFrame, "TOPLEFT",    -STREAK_W, 0)
    streak:SetPoint("BOTTOMLEFT", hostFrame, "BOTTOMLEFT", -STREAK_W, 0)

    local half = STREAK_W / 2

    -- Left ramp: transparent → accent colour.
    local texL = streak:CreateTexture(nil, "OVERLAY")
    texL:SetPoint("TOPLEFT",    streak, "TOPLEFT")
    texL:SetPoint("BOTTOMLEFT", streak, "BOTTOMLEFT")
    texL:SetWidth(half)
    texL:SetColorTexture(1, 1, 1, 1)
    texL:SetGradient("HORIZONTAL",
        CreateColor(PRI[1], PRI[2], PRI[3], 0),
        CreateColor(PRI[1], PRI[2], PRI[3], 0.42))
    texL:SetBlendMode("ADD")

    -- Right ramp: accent colour → transparent.
    local texR = streak:CreateTexture(nil, "OVERLAY")
    texR:SetPoint("TOPRIGHT",    streak, "TOPRIGHT")
    texR:SetPoint("BOTTOMRIGHT", streak, "BOTTOMRIGHT")
    texR:SetWidth(half)
    texR:SetColorTexture(1, 1, 1, 1)
    texR:SetGradient("HORIZONTAL",
        CreateColor(PRI[1], PRI[2], PRI[3], 0.42),
        CreateColor(PRI[1], PRI[2], PRI[3], 0))
    texR:SetBlendMode("ADD")

    local ag   = streak:CreateAnimationGroup()
    local move = ag:CreateAnimation("Translation")
    move:SetOrder(1)
    move:SetSmoothing("OUT")

    -- Reset anchor when the sweep finishes so a re-trigger starts from the left.
    ag:SetScript("OnFinished", function()
        streak:ClearAllPoints()
        streak:SetPoint("TOPLEFT",    hostFrame, "TOPLEFT",    -STREAK_W, 0)
        streak:SetPoint("BOTTOMLEFT", hostFrame, "BOTTOMLEFT", -STREAK_W, 0)
    end)

    return function()
        local fw = hostFrame:GetWidth()
        if not fw or fw < 1 then fw = 200 end
        -- Travel from the left edge to fully off the right edge.
        move:SetOffset(fw + STREAK_W * 2, 0)
        move:SetDuration(0.50)
        ag:Stop()
        streak:ClearAllPoints()
        streak:SetPoint("TOPLEFT",    hostFrame, "TOPLEFT",    -STREAK_W, 0)
        streak:SetPoint("BOTTOMLEFT", hostFrame, "BOTTOMLEFT", -STREAK_W, 0)
        ag:Play()
    end
end

-- ── Glide animation ────────────────────────────────────────────────────────────
-- Phase 1: a solid fill expands from 0 → full row width (left-anchored).
-- Phase 2: the full-width fill slides off to the right.
-- Total runtime ~1.0s — twice the flash streak duration.
---@param hostFrame Frame
---@return fun()
local function MakeGlideAnim(hostFrame)
    local clipFrame = CreateFrame("Frame", nil, hostFrame)
    clipFrame:SetAllPoints(hostFrame)
    clipFrame:SetClipsChildren(true)

    local fill = CreateFrame("Frame", nil, clipFrame)
    fill:SetPoint("TOPLEFT",    hostFrame, "TOPLEFT",    0, 0)
    fill:SetPoint("BOTTOMLEFT", hostFrame, "BOTTOMLEFT", 0, 0)
    fill:SetWidth(1)

    local fillTex = fill:CreateTexture(nil, "OVERLAY")
    fillTex:SetAllPoints()
    fillTex:SetColorTexture(PRI[1], PRI[2], PRI[3], 0.30)
    fillTex:SetBlendMode("ADD")

    local tw1, tw2

    local function Reset()
        if tw1 then tw1:Cancel(); tw1 = nil end
        if tw2 then tw2:Cancel(); tw2 = nil end
        fill:ClearAllPoints()
        fill:SetPoint("TOPLEFT",    hostFrame, "TOPLEFT",    0, 0)
        fill:SetPoint("BOTTOMLEFT", hostFrame, "BOTTOMLEFT", 0, 0)
        fill:SetWidth(1)
    end

    return function()
        Reset()
        local fw = hostFrame:GetWidth()
        if not fw or fw < 1 then fw = 200 end

        -- Phase 1: expand from left edge across the row.
        tw1 = zAF_Tween(0, fw, 0.50, ZAF.EASE_CUBIC_OUT, function(v)
            fill:SetWidth(math.max(1, v))
        end, function()
            tw1 = nil
            fill:SetWidth(fw)
            -- Phase 2: translate full-width fill off to the right.
            tw2 = zAF_Tween(0, fw, 0.50, ZAF.EASE_CUBIC_IN, function(v)
                fill:ClearAllPoints()
                fill:SetPoint("TOPLEFT",    hostFrame, "TOPLEFT",    v, 0)
                fill:SetPoint("BOTTOMLEFT", hostFrame, "BOTTOMLEFT", v, 0)
            end, function()
                tw2 = nil
                Reset()
            end)
        end)
    end
end

-- Selects the nav click animation based on the adapter's navAnim field.
-- "flash" → streak sweep; anything else (or nil) → glide. Default: "glide".
---@param hostFrame Frame
---@return fun()
local function NavAnimFactory(hostFrame)
    if panelAdapter and panelAdapter.navAnim == "flash" then
        return MakeStreakAnim(hostFrame)
    end
    return MakeGlideAnim(hostFrame)
end

RebuildTabs = function(tabList, navKey, tabShowWhen, tabDisableWhen)
    -- Hide old tab buttons.
    for _, tf in ipairs(tabFrames) do
        tf:Hide()
    end
    tabFrames = {}

    if not tabList or #tabList == 0 then
        activeTabRefresh = nil
        tabStrip:SetHeight(0)
        tabStrip:Hide()
        if scrollFrame and contentArea then
            scrollFrame:ClearAllPoints()
            scrollFrame:SetPoint("TOPLEFT",     contentArea, "TOPLEFT",      (SCROLLBAR_W + 4),  0)
            scrollFrame:SetPoint("BOTTOMRIGHT", contentArea, "BOTTOMRIGHT", -(SCROLLBAR_W + 4), 0)
        end
        if sbTrack and contentArea then
            sbTrack:ClearAllPoints()
            sbTrack:SetPoint("TOPRIGHT",    contentArea, "TOPRIGHT",    -2, -2)
            sbTrack:SetPoint("BOTTOMRIGHT", contentArea, "BOTTOMRIGHT", -2,  2)
        end
        return
    end

    -- Build display list: only tabs whose showWhen passes (or have no showWhen).
    -- visibleIdxs[displayPos] = originalTabIdx maps display pos → content panel key.
    local visibleIdxs = {}
    for ti = 1, #tabList do
        local swFn = tabShowWhen and tabShowWhen[ti]
        if not swFn or swFn() then
            visibleIdxs[#visibleIdxs + 1] = ti
        end
    end

    -- Collapse the strip entirely if every tab is hidden.
    if #visibleIdxs == 0 then
        activeTabRefresh = nil
        tabStrip:SetHeight(0)
        tabStrip:Hide()
        return
    end

    -- Auto-correct the stored active index if it was hidden by showWhen.
    -- activeTabPerNav always stores original (pre-filter) indices.
    local curOrigIdx      = GetActiveTab(navKey)
    local activeIsVisible = false
    for _, oi in ipairs(visibleIdxs) do
        if oi == curOrigIdx then activeIsVisible = true; break end
    end
    if not activeIsVisible then
        curOrigIdx = visibleIdxs[1]
        activeTabPerNav[navKey] = curOrigIdx
    end

    -- Map an original tab index to its display position.
    local function GetDisplayPos(origIdx)
        for dp, oi in ipairs(visibleIdxs) do
            if oi == origIdx then return dp end
        end
        return 1
    end
    local curDisplayPos = GetDisplayPos(curOrigIdx)

    -- Resolve per-row limit from the adapter, using the Blizz-hosted value when
    -- WoW's SettingsPanel is open (narrower canvas).
    local rowMax = _wowHosted
        and ((panelAdapter and panelAdapter.tabRowMaxBlizz) or 6)
        or  ((panelAdapter and panelAdapter.tabRowMax)      or 6)

    local PANEL_AREA_W = effectivePanelW - activeSidebarW
    local nVisible = #visibleIdxs
    local nPerRow  = math.min(nVisible, rowMax)
    local numRows  = math.ceil(nVisible / rowMax)
    local tabW     = math.floor(PANEL_AREA_W / nPerRow)

    tabStrip:SetHeight(numRows * TAB_H)
    tabStrip:Show()
    if scrollFrame and contentArea then
        scrollFrame:ClearAllPoints()
        scrollFrame:SetPoint("TOPLEFT",     tabStrip,    "BOTTOMLEFT",  (SCROLLBAR_W + 4),  0)
        scrollFrame:SetPoint("BOTTOMRIGHT", contentArea, "BOTTOMRIGHT", -(SCROLLBAR_W + 4), 0)
    end
    if sbTrack and contentArea then
        sbTrack:ClearAllPoints()
        sbTrack:SetPoint("TOPRIGHT",    tabStrip,    "BOTTOMRIGHT", -2, -2)
        sbTrack:SetPoint("BOTTOMRIGHT", contentArea, "BOTTOMRIGHT", -2,  2)
    end

    for displayPos = 1, nVisible do
        local originalTI = visibleIdxs[displayPos]
        local isActive   = (displayPos == curDisplayPos)
        local dwFn       = tabDisableWhen and tabDisableWhen[originalTI]
        local isDisabled = dwFn and dwFn()

        local row = math.floor((displayPos - 1) / rowMax)
        local col = (displayPos - 1) % rowMax

        local tf = CreateFrame("Frame", nil, tabStrip)
        tf:SetSize(tabW, TAB_H)
        tf:SetPoint("TOPLEFT", tabStrip, "TOPLEFT", col * tabW, -row * TAB_H)

        local tabActiveBG = zAF_FillBG(tf, unpack(C.tabActive))
        tabActiveBG:SetShown(isActive)

        -- Single-anchor indicator so we can animate its width on click.
        local tabIndicator = tf:CreateTexture(nil, "OVERLAY")
        tabIndicator:SetColorTexture(PRI[1], PRI[2], PRI[3], 1)
        tabIndicator:SetPoint("BOTTOMLEFT", tf, "BOTTOMLEFT", 0, 0)
        PixelUtil.SetHeight(tabIndicator, 2, 1)
        tabIndicator:SetWidth(tabW)
        tabIndicator:SetShown(isActive)

        local tabHoverTex = tf:CreateTexture(nil, "ARTWORK")
        tabHoverTex:SetAllPoints()
        tabHoverTex:SetColorTexture(C.rowHover[1], C.rowHover[2],
                                     C.rowHover[3], C.rowHover[4])
        tabHoverTex:SetAlpha(0)
        tf:EnableMouse(true)
        tf:SetScript("OnEnter", function() tabHoverTex:SetAlpha(1) end)
        tf:SetScript("OnLeave", function() tabHoverTex:SetAlpha(0) end)

        local tabLbl = zAF_Text(tf, "GameFontNormal", "CENTER")
        tabLbl:SetAllPoints()
        tabLbl:SetText(tabList[originalTI])
        local lc = isDisabled and C.disabled or (isActive and PRI or C.label)
        tabLbl:SetTextColor(lc[1], lc[2], lc[3], 1)

        -- Click animation on tab rows.
        local tabPlayStreak = NavAnimFactory(tf)

        local capturedOrigTI = originalTI
        local capturedNav    = navKey
        tf:SetScript("OnMouseDown", function()
            -- Re-evaluate disable at click time so runtime changes are respected.
            local dw = tabDisableWhen and tabDisableWhen[capturedOrigTI]
            if dw and dw() then return end
            tabPlayStreak()
            activeTabPerNav[capturedNav] = capturedOrigTI
            local newOrigActive = capturedOrigTI
            -- Update visual state for all visible tab buttons.
            for dp2, ttf in ipairs(tabFrames) do
                local oi2   = visibleIdxs[dp2]
                local isAct = (oi2 == newOrigActive)
                ttf.activeBG:SetShown(isAct)
                if isAct then
                    -- Reveal underline indicator and flash.
                    ttf.indicator:SetWidth(0)
                    ttf.indicator:Show()
                    zAF_Tween(0, tabW, 0.22, ZAF.EASE_CUBIC_OUT,
                        function(v) ttf.indicator:SetWidth(v) end)
                    if ttf.flash then ttf.flash() end
                else
                    ttf.indicator:Hide()
                end
                local dwFn2  = tabDisableWhen and tabDisableWhen[oi2]
                local isDis2 = dwFn2 and dwFn2()
                local lc2    = isDis2 and C.disabled or (isAct and PRI or C.label)
                ttf.label:SetTextColor(lc2[1], lc2[2], lc2[3], 1)
            end
            -- Switch tab content frames (keyed by original index).
            if tabPanels[capturedNav] then
                local tp = tabPanels[capturedNav]
                for oi2, tf2 in ipairs(tp) do
                    if oi2 == newOrigActive then
                        tf2:Show()
                        AnimateIn(tf2)
                    else
                        tf2:Hide()
                    end
                end
                local activeTf = tp[newOrigActive]
                if activeTf then
                    SetScrollContentHeight(activeTf.zsfHeight or 1, true)
                end
            end
        end)

        tf.activeBG  = tabActiveBG
        tf.indicator = tabIndicator
        tf.flash     = tabPlayStreak
        tf.label     = tabLbl
        tabFrames[displayPos] = tf
    end

    -- Full rebuild on refresh so both showWhen (display list) and disableWhen
    -- (colors) are re-evaluated correctly by RefreshAll.
    activeTabRefresh = function()
        RebuildTabs(tabList, navKey, tabShowWhen, tabDisableWhen)
    end
end

-- ── Internal: sidebar navigation ─────────────────────────────────────────────

local RebuildNav  -- forward-declare

local function MakeNavItem(sidebar, navIdx, item, yStart)
    local f = CreateFrame("Frame", nil, sidebar)
    f:SetPoint("TOPLEFT",  sidebar, "TOPLEFT",  0, yStart)
    f:SetPoint("TOPRIGHT", sidebar, "TOPRIGHT", 0, yStart)
    f:SetHeight(NAV_H)

    -- Active background: gradient from PRI at 40% on the left down to 0% at
    -- the right, so it visually radiates out from the left indicator bar.
    local activeBG = f:CreateTexture(nil, "BACKGROUND")
    activeBG:SetAllPoints(f)
    activeBG:SetColorTexture(1, 1, 1, 1)
    activeBG:SetGradient("HORIZONTAL",
        CreateColor(PRI[1], PRI[2], PRI[3], 0.25),
        CreateColor(PRI[1], PRI[2], PRI[3], 0))
    activeBG:SetShown(navIdx == activeNav)

    local indicator = f:CreateTexture(nil, "OVERLAY")
    indicator:SetColorTexture(PRI[1], PRI[2], PRI[3], 1)
    PixelUtil.SetPoint(indicator, "TOPLEFT",    f, "TOPLEFT",    0, -1)
    PixelUtil.SetPoint(indicator, "BOTTOMLEFT", f, "BOTTOMLEFT", 0,  1)
    PixelUtil.SetWidth(indicator, 3, 1)
    indicator:SetShown(navIdx == activeNav)

    -- 1 px edge lines at the top and bottom of the active nav row, fading right.
    local glowTop = f:CreateTexture(nil, "OVERLAY")
    glowTop:SetColorTexture(1, 1, 1, 1)
    PixelUtil.SetHeight(glowTop, 1, 1)
    PixelUtil.SetPoint(glowTop, "TOPLEFT",  f, "TOPLEFT",  0, 0)
    PixelUtil.SetPoint(glowTop, "TOPRIGHT", f, "TOPRIGHT", 0, 0)
    glowTop:SetGradient("HORIZONTAL",
        CreateColor(1, 1, 1, 0.30),
        CreateColor(1, 1, 1, 0))
    glowTop:SetShown(navIdx == activeNav)

    local glowBot = f:CreateTexture(nil, "OVERLAY")
    glowBot:SetColorTexture(1, 1, 1, 1)
    PixelUtil.SetHeight(glowBot, 1, 1)
    PixelUtil.SetPoint(glowBot, "BOTTOMLEFT",  f, "BOTTOMLEFT",  0, 0)
    PixelUtil.SetPoint(glowBot, "BOTTOMRIGHT", f, "BOTTOMRIGHT", 0, 0)
    glowBot:SetGradient("HORIZONTAL",
        CreateColor(1, 1, 1, 0.30),
        CreateColor(1, 1, 1, 0))
    glowBot:SetShown(navIdx == activeNav)

    local hoverTex = f:CreateTexture(nil, "ARTWORK")
    hoverTex:SetAllPoints()
    hoverTex:SetColorTexture(C.rowHover[1], C.rowHover[2],
                              C.rowHover[3], C.rowHover[4])
    hoverTex:SetAlpha(0)
    f:EnableMouse(true)
    f:SetScript("OnEnter", function() hoverTex:SetAlpha(1) end)
    f:SetScript("OnLeave", function() hoverTex:SetAlpha(0) end)

    -- Click animation on nav parent rows.
    local navPlayStreak = NavAnimFactory(f)

    local lbl = zAF_Text(f, _wowHosted and "GameFontNormal" or "GameFontNormalLarge")
    lbl:SetPoint("LEFT", f, "LEFT", 24, 0)
    lbl:SetText(item.name)
    lbl:SetTextColor(
        navIdx == activeNav and PRI[1] or C.label[1],
        navIdx == activeNav and PRI[2] or C.label[2],
        navIdx == activeNav and PRI[3] or C.label[3],
        1
    )

    -- Small dot on the right edge when this nav has children; hidden when expanded.
    local expandDot
    if item.children then
        expandDot = f:CreateTexture(nil, "OVERLAY")
        expandDot:SetColorTexture(SEC[1], SEC[2], SEC[3], 0.8)
        expandDot:SetSize(3, 3)
        expandDot:SetPoint("RIGHT", f, "RIGHT", -8, 0)
        expandDot:SetShown(navIdx ~= activeNav)
    end

    -- Build child sidebar items.
    local childFrames = {}
    if item.children then
        for ci, cname in ipairs(item.children) do
            local cf = CreateFrame("Frame", nil, sidebar)
            cf:SetPoint("TOPLEFT",  sidebar, "TOPLEFT",  0, 0)
            cf:SetPoint("TOPRIGHT", sidebar, "TOPRIGHT", 0, 0)
            cf:SetHeight(CHILD_H)
            cf:EnableMouse(true)

            local choverTex = cf:CreateTexture(nil, "ARTWORK")
            choverTex:SetAllPoints()
            choverTex:SetColorTexture(C.rowHover[1], C.rowHover[2],
                                       C.rowHover[3], C.rowHover[4])
            choverTex:SetAlpha(0)
            cf:SetScript("OnEnter", function() choverTex:SetAlpha(1) end)
            cf:SetScript("OnLeave", function() choverTex:SetAlpha(0) end)

            -- Bullet dot (aligned with child text indent).
            local dot = cf:CreateTexture(nil, "OVERLAY")
            dot:SetColorTexture(SEC[1], SEC[2], SEC[3], 0.8)
            dot:SetSize(3, 3)
            dot:SetPoint("LEFT", cf, "LEFT", NAV_INDENT + 12, 0)

            local clbl = zAF_Text(cf, _wowHosted and "GameFontHighlightSmall" or "GameFontNormal")
            clbl:SetPoint("LEFT", cf, "LEFT", NAV_INDENT + 18, 0)
            clbl:SetText(cname)

            -- Active child highlight line.
            local cline = cf:CreateTexture(nil, "OVERLAY")
            cline:SetColorTexture(SEC[1], SEC[2], SEC[3], 0.85)
            PixelUtil.SetPoint(cline, "TOPRIGHT",    cf, "TOPRIGHT",    0, -1)
            PixelUtil.SetPoint(cline, "BOTTOMRIGHT", cf, "BOTTOMRIGHT", 0,  1)
            PixelUtil.SetWidth(cline, 3, 1)

            -- 1 px edge lines at top and bottom of the active child row, fading left.
            local cglowTop = cf:CreateTexture(nil, "OVERLAY")
            cglowTop:SetColorTexture(1, 1, 1, 1)
            PixelUtil.SetHeight(cglowTop, 1, 1)
            PixelUtil.SetPoint(cglowTop, "TOPLEFT",  cf, "TOPLEFT",  0, 0)
            PixelUtil.SetPoint(cglowTop, "TOPRIGHT", cf, "TOPRIGHT", 0, 0)
            cglowTop:SetGradient("HORIZONTAL",
                CreateColor(1, 1, 1, 0),
                CreateColor(1, 1, 1, 0.20))

            local cglowBot = cf:CreateTexture(nil, "OVERLAY")
            cglowBot:SetColorTexture(1, 1, 1, 1)
            PixelUtil.SetHeight(cglowBot, 1, 1)
            PixelUtil.SetPoint(cglowBot, "BOTTOMLEFT",  cf, "BOTTOMLEFT",  0, 0)
            PixelUtil.SetPoint(cglowBot, "BOTTOMRIGHT", cf, "BOTTOMRIGHT", 0, 0)
            cglowBot:SetGradient("HORIZONTAL",
                CreateColor(1, 1, 1, 0),
                CreateColor(1, 1, 1, 0.20))

            cf.label      = clbl
            cf.activeLine = cline
            cf.glowTop    = cglowTop
            cf.glowBot    = cglowBot

            -- Active gradient: SEC color from 40% on the right (indicator side)
            -- fading to 0% at the left, matching the cline indicator direction.
            local cfActiveBG = cf:CreateTexture(nil, "BACKGROUND")
            cfActiveBG:SetPoint("TOPLEFT",     cf, "TOPLEFT",     0,  0)
            cfActiveBG:SetPoint("BOTTOMRIGHT", cf, "BOTTOMRIGHT", 0, 0)
            cfActiveBG:SetColorTexture(1, 1, 1, 1)
            cfActiveBG:SetGradient("HORIZONTAL",
                CreateColor(SEC[1], SEC[2], SEC[3], 0),
                CreateColor(SEC[1], SEC[2], SEC[3], 0.25))
            cfActiveBG:SetShown(false)
            cf.activeBG    = cfActiveBG
            cf.showWhen    = item.childShowWhen    and item.childShowWhen[ci]
            cf.disableWhen = item.childDisableWhen and item.childDisableWhen[ci]

            -- Click animation on nav child rows.
            local cfPlayStreak = NavAnimFactory(cf)

            local capturedNavIdx = navIdx
            local capturedCI     = ci
            cf:SetScript("OnMouseDown", function()
                local cdw = item.childDisableWhen and item.childDisableWhen[capturedCI]
                if cdw and cdw() then return end
                cfPlayStreak()
                activeNav   = capturedNavIdx
                activeChild = capturedCI
                RebuildNav()
                ShowContentPanel(capturedNavIdx, capturedCI, true)
            end)

            childFrames[ci] = cf
            cf:Hide()
        end
    end

    f:SetScript("OnMouseDown", function()
        if item.disableWhen and item.disableWhen() then return end
        navPlayStreak()
        activeNav = navIdx
        -- If nav has children and own/tabbed content, show parent view (childIdx=0).
        -- If nav has children but no own content or tabs, select first child.
        -- If nav has no children, show its own content (childIdx=0).
        if item.children and #item.children > 0 then
            if item.hasOwnContent or (item.tabNames and #item.tabNames > 0) then
                activeChild = nil
            else
                activeChild = 1
            end
        else
            activeChild = nil
        end
        RebuildNav()
        ShowContentPanel(navIdx, activeChild or 0, true)
    end)

    navFrames[navIdx] = {
        frame       = f,
        activeBG    = activeBG,
        indicator   = indicator,
        glowTop     = glowTop,
        glowBot     = glowBot,
        label       = lbl,
        childFrames = childFrames,
        expandDot   = expandDot,
        showWhen    = item.showWhen,
        disableWhen = item.disableWhen,
    }
end

--- Reposition all nav + child frames and refresh active state.
--- Also evaluates showWhen/disableWhen on each item and auto-corrects
--- activeNav/activeChild if they become hidden, then syncs the content panel.
RebuildNav = function()
    local prevNav   = activeNav
    local prevChild = activeChild

    -- Auto-correct activeNav if its showWhen currently hides it.
    local nfAct = navFrames[activeNav]
    if nfAct and nfAct.showWhen and not nfAct.showWhen() then
        for i = 1, #navItems do
            local nfi = navFrames[i]
            if not nfi.showWhen or nfi.showWhen() then
                activeNav   = i
                activeChild = nil
                break
            end
        end
    end

    -- Auto-correct activeChild if its showWhen currently hides it.
    if activeChild then
        local nfActive = navFrames[activeNav]
        if nfActive then
            local cfw = nfActive.childFrames[activeChild]
            if cfw and cfw.showWhen and not cfw.showWhen() then
                local found = nil
                for ci2 = 1, #nfActive.childFrames do
                    local cf2 = nfActive.childFrames[ci2]
                    if not cf2.showWhen or cf2.showWhen() then
                        found = ci2; break
                    end
                end
                activeChild = found  -- nil → fall back to parent view
            end
        end
    end

    local y = -NAV_TOP_PAD
    for i, item in ipairs(navItems) do
        local nf         = navFrames[i]
        local isVisible  = not nf.showWhen  or nf.showWhen()
        local isDisabled = nf.disableWhen and nf.disableWhen()
        local isActive   = (i == activeNav)

        if not isVisible then
            nf.frame:Hide()
            -- Hidden item occupies no space; children hidden implicitly.
        else
            nf.frame:Show()
            nf.frame:SetPoint("TOPLEFT",  nf.frame:GetParent(), "TOPLEFT",  0, y)
            nf.frame:SetPoint("TOPRIGHT", nf.frame:GetParent(), "TOPRIGHT", 0, y)
            nf.activeBG:SetShown(isActive and not isDisabled)
            nf.indicator:SetShown(isActive and not isDisabled)
            nf.glowTop:SetShown(isActive and not isDisabled)
            nf.glowBot:SetShown(isActive and not isDisabled)
            if nf.expandDot then nf.expandDot:SetShown(not isActive) end
            local lc = isDisabled and C.disabled or (isActive and PRI or C.label)
            nf.label:SetTextColor(lc[1], lc[2], lc[3], 1)
            y = y - NAV_H - NAV_GAP

            -- Show children only when this nav is active and has children.
            if item.children then
                for ci, cf in ipairs(nf.childFrames) do
                    if isActive then
                        local cVisible  = not cf.showWhen  or cf.showWhen()
                        local cDisabled = cf.disableWhen and cf.disableWhen()
                        if cVisible then
                            cf:Show()
                            cf:SetPoint("TOPLEFT",  cf:GetParent(), "TOPLEFT",  0, y)
                            cf:SetPoint("TOPRIGHT", cf:GetParent(), "TOPRIGHT", 0, y)
                            local isActiveChild = (activeChild == ci)
                            local clc = cDisabled and C.disabled or (isActiveChild and PRI or C.label)
                            cf.label:SetTextColor(clc[1], clc[2], clc[3], 1)
                            cf.activeLine:SetShown(isActiveChild and not cDisabled)
                            if cf.activeBG  then cf.activeBG:SetShown(isActiveChild and not cDisabled)  end
                            if cf.glowTop   then cf.glowTop:SetShown(isActiveChild and not cDisabled)   end
                            if cf.glowBot   then cf.glowBot:SetShown(isActiveChild and not cDisabled)   end
                            y = y - CHILD_H - NAV_GAP
                        else
                            cf:Hide()
                        end
                    else
                        cf:Hide()
                    end
                end
            end
        end
    end

    -- If the active selection changed due to visibility, sync the content panel.
    if activeNav ~= prevNav or activeChild ~= prevChild then
        ShowContentPanel(activeNav, activeChild or 0, false)
    end

    -- Keep the scroll child tall enough so items never clip at the bottom.
    if navContent then
        navContent:SetHeight(math.max(1, math.abs(y) + NAV_TOP_PAD))
        C_Timer.After(0, UpdateNavThumb)
    end
end

-- ── Public: build the panel ──────────────────────────────────────────────────

--- Build the full settings panel frame.  Called once by the framework on first
-- Forward-declared so OnShow inside _buildPanel can close over it as an upvalue.
-- The definition lives later in the factory after all helpers it depends on are ready.
local RebuildContent

--- open (lazy init). The adapter provides the addon name, version, and theme colours.
---@param adapter   zSF_Adapter
---@param primary   table  { r, g, b } primary accent colour.
---@param secondary table  { r, g, b } secondary accent colour.
---@return Frame panel  The root canvas frame to register with WoW Settings.
function ctx._buildPanel(adapter, primary, secondary)
    panelAdapter = adapter
    _slideAnim   = adapter.fadeInAnimation == "slide"
    PRI = primary
    SEC = secondary

    -- Resolve per-panel effective colors: adapter override → PRI-driven default → global constant.
    -- Stored on ctx so Constructor.lua / Widgets.lua can read them via _buildCtx._colors
    -- during schema walks. Globals are never mutated.
    local nc = ZSF.NormalizeColor
    local ac = adapter.colors
    C.sidebarBg     = nc(ac and ac.sidebarBg)     or ZAF.COLOR_SIDEBAR_BG
    C.rowHover      = nc(ac and ac.rowHover)      or ZAF.COLOR_ROW_HOVER
    C.border        = nc(ac and ac.border)        or { PRI[1], PRI[2], PRI[3], 1.0 }
    C.label         = nc(ac and ac.label)         or ZAF.COLOR_LABEL
    C.desc          = nc(ac and ac.desc)          or ZAF.COLOR_DESC
    C.headerText    = nc(ac and ac.headerText)    or { PRI[1], PRI[2], PRI[3], 1.0 }
    C.disabled      = nc(ac and ac.disabled)      or ZAF.COLOR_DISABLED
    C.disabledAlpha = (ac and ac.disabledAlpha)   or ZAF.DISABLED_ALPHA
    C.tabActive     = nc(ac and ac.tabActive)     or ZAF.COLOR_TAB_ACTIVE
    C.separator     = nc(ac and ac.separator)     or ZAF.COLOR_SEPARATOR
    C.rowStripeOdd           = nc(ac and ac.rowStripeOdd)           or ZAF.COLOR_ROW_STRIPE_ODD
    C.rowStripeEven          = nc(ac and ac.rowStripeEven)          or ZAF.COLOR_ROW_STRIPE_EVEN
    C.colDivider             = nc(ac and ac.colDivider)             or ZAF.COLOR_COL_DIVIDER
    C.toggleIndicatorColor   = nc(ac and ac.toggleIndicatorColor)   or nil
    ctx._colors     = C

    local pw = adapter.panelWidth  or PANEL_W
    local ph = adapter.panelHeight or PANEL_H
    effectivePanelW = pw

    -- Profiling upvalues — FontStrings assigned after the status bar is built below.
    -- Closures capture the variable slot so they resolve to the final values at call time.
    local addonFolderName = adapter.addonFolder or adapter.addonName
    ---@type FontString|nil
    local cpuText
    ---@type FontString|nil
    local memText
    ---@type any|nil
    local cpuTicker
    ---@type any|nil
    local memHoverTicker

    local function doRefreshCPU()
        if not cpuText then return end
        if not (C_AddOnProfiler and C_AddOnProfiler.IsEnabled()) then cpuText:Hide(); return end
        local appVal     = C_AddOnProfiler.GetApplicationMetric(Enum.AddOnProfilerMetric.RecentAverageTime)
        local overallVal = C_AddOnProfiler.GetOverallMetric(Enum.AddOnProfilerMetric.RecentAverageTime)
        local addonVal   = C_AddOnProfiler.GetAddOnMetric(addonFolderName, Enum.AddOnProfilerMetric.RecentAverageTime)
        local relTotal   = appVal - overallVal + addonVal
        local pct        = (relTotal > 0) and (addonVal / relTotal * 100) or 0
        cpuText:SetText(string.format("CPU %.2f%%", pct))
        cpuText:Show()
    end

    local function doRefreshMem()
        if not memText then return end
        local kb = GetAddOnMemoryUsage(addonFolderName)
        if kb >= 1024 then
            memText:SetText(string.format("Mem %.1f MB", kb / 1024))
        else
            memText:SetText(string.format("Mem %d KB", math.floor(kb + 0.5)))
        end
        memText:Show()
    end

    -- ── Root frame ──────────────────────────────────────────────────────
    panel = CreateFrame("Frame", "zSFPanelFrame_" .. adapter.addonName, UIParent)
    panel:Hide()  -- keep hidden until Open() calls Show() after content panels are built
    panel:SetSize(pw, ph)
    panel:SetPoint("CENTER")
    panel:SetFrameStrata("FULLSCREEN_DIALOG")
    panel:SetFrameLevel(100)
    panel:SetClampedToScreen(false)
    panel:SetMovable(true)
    panel:EnableMouse(true)
    -- ESC closes the panel like any standard WoW window.
    table.insert(UISpecialFrames, "zSFPanelFrame_" .. adapter.addonName)

    -- Native animation groups for the panel open/close fade.
    -- Using AnimationGroup applies alpha at the compositor level, so the entire
    -- frame tree fades as one unit regardless of individual child alpha values.
    local openAG  = panel:CreateAnimationGroup()
    local openFade = openAG:CreateAnimation("Alpha")
    openFade:SetFromAlpha(0)
    openFade:SetToAlpha(1)
    openFade:SetDuration(0.22)
    openFade:SetSmoothing("OUT")

    local closeAG   = panel:CreateAnimationGroup()
    local closeFade = closeAG:CreateAnimation("Alpha")
    closeFade:SetFromAlpha(1)
    closeFade:SetToAlpha(0)
    closeFade:SetDuration(0.16)
    closeFade:SetSmoothing("OUT")
    closeAG:SetScript("OnFinished", function()
        panel:SetAlpha(1)
        panel:Hide()
    end)

    -- ── Background images ───────────────────────────────────────────────
    -- Opaque near-black base is always present.
    -- Two optional image layers sit above it; each can be replaced via the
    -- adapter's bgBottomLayer / bgTopLayer (path or "off") and
    -- bgBottomLayerColor / bgTopLayerColor ({ r,g,b } or "off").
    local bgBase = panel:CreateTexture(nil, "BACKGROUND", nil, -8)
    bgBase:SetAllPoints()
    bgBase:SetColorTexture(0.04, 0.04, 0.04, 1)

    local function ResolveColor(color, fallback)
        if type(color) == "table" and type(color[1]) == "number" and type(color[2]) == "number" and type(color[3]) == "number" then
            return color[1], color[2], color[3]
        end
        return fallback[1], fallback[2], fallback[3]
    end

    local bottomPath  = adapter.bgBottomLayer
    local bottomColor = ZSF.NormalizeColor(adapter.bgBottomLayerColor)
    if bottomPath == nil then
        bottomPath  = zSF_AssetPath("bg-bottom.png")
        bottomColor = bottomColor  -- keep adapter override or nil
    end
    if bottomPath ~= "off" then
        local bgBottom = panel:CreateTexture(nil, "BACKGROUND", nil, -4)
        bgBottom:SetAllPoints()
        bgBottom:SetTexture(bottomPath)
        if bottomColor == "off" then
            -- Show as-is: no desaturation, no tint.
        else
            local r, g, b = ResolveColor(bottomColor, SEC)
            bgBottom:SetDesaturated(true)
            bgBottom:SetVertexColor(r, g, b, 1)
        end
    end

    local topPath  = adapter.bgTopLayer
    local topColor = ZSF.NormalizeColor(adapter.bgTopLayerColor)
    if topPath == nil then
        topPath = zSF_AssetPath("bg-top.png")
    end
    if topPath ~= "off" then
        local bgTop = panel:CreateTexture(nil, "BACKGROUND", nil, 0)
        bgTop:SetAllPoints()
        bgTop:SetTexture(topPath)
        if topColor == "off" then
            -- Show as-is: no desaturation, no tint.
        else
            local r, g, b = ResolveColor(topColor, PRI)
            bgTop:SetDesaturated(true)
            bgTop:SetVertexColor(r, g, b, 1)
        end
    end

    -- Gradient border sits above all child content.
    local border = CreateFrame("Frame", nil, panel)
    border:SetAllPoints()
    border:SetFrameLevel(300)
    zAF_DrawGradientBorder(border, PRI, SEC, SEC, PRI)
    panel._border = border  -- stored so Open() can keep it above panel level when raising

    -- ── Title bar (branding watermark + active nav headline) ────────────
    local titleBar = CreateFrame("Frame", nil, panel)
    titleBar:SetPoint("TOPLEFT",  panel, "TOPLEFT",  0, 0)
    titleBar:SetPoint("TOPRIGHT", panel, "TOPRIGHT", 0, 0)
    titleBar:SetHeight(TITLE_H)
    zAF_FillBG(titleBar, unpack(C.sidebarBg))

    local titleSep = titleBar:CreateTexture(nil, "OVERLAY")
    titleSep:SetColorTexture(PRI[1], PRI[2], PRI[3], 0.65)
    titleSep:SetPoint("BOTTOMLEFT",  titleBar, "BOTTOMLEFT",  0, 0)
    titleSep:SetPoint("BOTTOMRIGHT", titleBar, "BOTTOMRIGHT", 0, 0)
    PixelUtil.SetHeight(titleSep, 1, 1)

    -- Small addon name watermark stays within the sidebar column.
    local titleText = zAF_Text(titleBar, "GameFontHighlightSmall")
    titleText:SetText(adapter.addonName)
    titleText:SetTextColor(PRI[1], PRI[2], PRI[3])
    titleText:SetAlpha(0.50)
    titleText:SetPoint("TOPLEFT", titleBar, "TOPLEFT", 14, -8)

    -- Active nav section name — aligned with the content frame left edge.
    -- Uses the heading font so it reads as the dominant headline.
    local navNameText = zAF_Text(titleBar, ctx.FONTOBJ_TITLEBAR or "GameFontNormalLarge")
    navNameText:SetTextColor(PRI[1], PRI[2], PRI[3])
    navNameText:SetPoint("TOPLEFT", titleBar, "TOPLEFT", activeSidebarW + ZSF.CONTENT_PAD_X, -18)

    -- Active nav description shown below the section name, also content-left aligned.
    local navDescText = zAF_Text(titleBar, "GameFontHighlightSmall")
    navDescText:SetTextColor(unpack(C.desc))
    navDescText:SetPoint("TOPLEFT", navNameText, "BOTTOMLEFT", 0, -4)
    navDescText:Hide()

    local closeBtn = zAF_BuildCloseButton(titleBar, SEC)
    closeBtn:SetPoint("TOPRIGHT", titleBar, "TOPRIGHT", -4, -4)
    closeBtn:SetScript("OnClick", function()
        openAG:Stop()
        closeAG:Stop()
        panel:SetAlpha(1)
        closeAG:Play()
    end)

    -- Panel scale dropdown (optional, bottom-right of titlebar).
    if adapter.showScaleDropdown then
        -- Scale options in order.
        local scaleOrder = { "75%", "80%", "85%", "90%", "95%", "100%", "105%", "110%", "120%", "130%", "140%", "150%" }
        local scaleValuesMap = {  -- key → display text map
            ["75%"]  = "75%",   ["80%"]  = "80%",   ["85%"]  = "85%",   ["90%"]  = "90%",
            ["95%"]  = "95%",   ["100%"] = "100%",  ["105%"] = "105%",  ["110%"] = "110%",
            ["120%"] = "120%",  ["130%"] = "130%",  ["140%"] = "140%",  ["150%"] = "150%",
        }
        local valueMap = {  -- key → numeric scale value
            ["75%"]  = 0.75,   ["80%"]  = 0.80,   ["85%"]  = 0.85,   ["90%"]  = 0.90,
            ["95%"]  = 0.95,   ["100%"] = 1.00,   ["105%"] = 1.05,   ["110%"] = 1.10,
            ["120%"] = 1.20,   ["130%"] = 1.30,   ["140%"] = 1.40,   ["150%"] = 1.50,
        }

        -- Load and validate saved scale.
        local scaleDBKey = adapter.scaleDBKey or "panelScale"
        local savedScale = adapter.get(scaleDBKey) or 1.0
        if type(savedScale) ~= "number" or savedScale < 0.75 or savedScale > 1.50 then
            savedScale = 1.0
        end

        -- Find label for a numeric scale value (within tolerance).
        local currentScale = "100%"
        local tolerance = 0.001
        for _, label in ipairs(scaleOrder) do
            if math.abs(valueMap[label] - savedScale) < tolerance then
                currentScale = label
                break
            end
        end

        -- Build the dropdown with the current scale as initial text.
        local scaleDDHost = CreateFrame("Frame", nil, titleBar)
        scaleDDHost:SetSize(68, 18)
        scaleDDHost:SetPoint("BOTTOMRIGHT", titleBar, "BOTTOMRIGHT", -4, 4)
        local scaleDD = zAF_BuildDropdown(scaleDDHost, currentScale, PRI)

        -- Add "Scale" label above the dropdown.
        local scaleLabel = scaleDDHost:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
        scaleLabel:SetText("Scale")
        scaleLabel:SetPoint("BOTTOMRIGHT", scaleDDHost, "TOPRIGHT", 0, 2)
        scaleLabel:SetTextColor(0.6, 0.6, 0.6, 1)

        -- Apply saved scale immediately.
        panel:SetScale(savedScale)

        -- Dropdown callbacks.
        local function getValue()
            return currentScale
        end
        local function getValues()
            return scaleValuesMap, scaleOrder  -- map, then ordered key list
        end
        local function onPick(selectedLabel, displayText)
            if not selectedLabel or not valueMap[selectedLabel] then return end
            local targetScale = valueMap[selectedLabel]
            local startScale = panel:GetScale() or 1.0
            currentScale = selectedLabel
            
            -- Update the dropdown button text immediately.
            scaleDD.text:SetText(selectedLabel)

            -- Save to DB first.
            if adapter.set then
                adapter.set(scaleDBKey, targetScale)
            end

            -- Animate scale change (faster: 0.15 seconds).
            local elapsed = 0
            local duration = 0.15
            local tweener = CreateFrame("Frame")
            tweener:SetScript("OnUpdate", function(_, dt)
                elapsed = elapsed + dt
                local t = math.min(1, elapsed / duration)
                local newScale = startScale + (targetScale - startScale) * t
                panel:SetScale(newScale)
                if t >= 1 then
                    panel:SetScale(targetScale)
                    tweener:SetScript("OnUpdate", nil)
                end
            end)
        end

        scaleDD.SetMenuAccessors(getValue, getValues, onPick)
        panel._scaleDD = scaleDD
    end

    -- Drag is restricted to the titlebar so the rest of the panel doesn't act as a handle.
    local dragRegion = CreateFrame("Frame", nil, titleBar)
    dragRegion:SetAllPoints(titleBar)
    dragRegion:EnableMouse(true)
    dragRegion:RegisterForDrag("LeftButton")
    dragRegion:SetScript("OnDragStart", function() panel:StartMoving() end)
    dragRegion:SetScript("OnDragStop",  function() panel:StopMovingOrSizing() end)

    -- ── Sidebar ─────────────────────────────────────────────────────────
    local sidebar = CreateFrame("Frame", nil, panel)
    sidebar:SetPoint("TOPLEFT",    panel, "TOPLEFT",    0, -TITLE_H)
    sidebar:SetPoint("BOTTOMLEFT", panel, "BOTTOMLEFT", 0,  STATUS_H)
    sidebar:SetWidth(activeSidebarW)
    zAF_FillBG(sidebar, unpack(C.sidebarBg))

    local sideRule = sidebar:CreateTexture(nil, "OVERLAY")
    sideRule:SetColorTexture(PRI[1], PRI[2], PRI[3], 0.55)
    PixelUtil.SetPoint(sideRule, "TOPRIGHT",    sidebar, "TOPRIGHT",    0, 0)
    PixelUtil.SetPoint(sideRule, "BOTTOMRIGHT", sidebar, "BOTTOMRIGHT", 0, 0)
    PixelUtil.SetWidth(sideRule, 1, 1)

    -- ── Sidebar nav scroll area ───────────────────────────────────────────
    -- Wraps all nav items so the sidebar gracefully handles overflow when a
    -- parent nav is expanded or many items are present.
    navScrollFrame = CreateFrame("ScrollFrame", nil, sidebar)
    navScrollFrame:SetAllPoints(sidebar)
    navScrollFrame:EnableMouseWheel(true)

    navContent = CreateFrame("Frame", nil, navScrollFrame)
    navContent:SetWidth(activeSidebarW)
    navContent:SetHeight(1)  -- RebuildNav updates this after every layout pass
    navScrollFrame:SetScrollChild(navContent)

    ApplySmoothScroll(navScrollFrame, UpdateNavThumb)

    -- ── Sidebar scrollbar ────────────────────────────────────────────────
    navSbTrack = CreateFrame("Frame", nil, sidebar)
    navSbTrack:SetWidth(SCROLLBAR_W)
    navSbTrack:SetPoint("TOPRIGHT",    sidebar, "TOPRIGHT",    -2, -2)
    navSbTrack:SetPoint("BOTTOMRIGHT", sidebar, "BOTTOMRIGHT", -2,  2)
    local navSbTrackBG = navSbTrack:CreateTexture(nil, "BACKGROUND")
    navSbTrackBG:SetAllPoints()
    navSbTrackBG:SetColorTexture(0, 0, 0, 0.25)

    navSbThumb = CreateFrame("Frame", nil, navSbTrack)
    navSbThumb:SetWidth(SCROLLBAR_W)
    local navSbThumbTex = navSbThumb:CreateTexture(nil, "OVERLAY")
    navSbThumbTex:SetAllPoints()
    navSbThumbTex:SetColorTexture(PRI[1], PRI[2], PRI[3], 0.85)
    navSbThumb:Hide()
    navSbTrack:Hide()

    local navIsDragging, navDragStartY, navDragStartScr = false, 0, 0
    navSbThumb:EnableMouse(true)
    navSbThumb:SetScript("OnMouseDown", function(self, btn)
        if btn ~= "LeftButton" then return end
        navIsDragging   = true
        navDragStartY   = select(2, GetCursorPosition()) / UIParent:GetScale()
        navDragStartScr = navScrollFrame:GetVerticalScroll() or 0
        self:SetScript("OnUpdate", function()
            if not navIsDragging then self:SetScript("OnUpdate", nil) return end
            local curY   = select(2, GetCursorPosition()) / UIParent:GetScale()
            local deltaY = navDragStartY - curY
            local trackH = navSbTrack:GetHeight() or 1
            local thumbH = navSbThumb:GetHeight() or 20
            local travel  = trackH - thumbH
            if travel <= 0 then return end
            local range = navScrollFrame:GetVerticalScrollRange() or 0
            navScrollFrame:SetVerticalScroll(math.max(0, math.min(
                navDragStartScr + (deltaY / travel * range), range)))
        end)
    end)
    navSbThumb:SetScript("OnMouseUp", function() navIsDragging = false end)

    navScrollFrame:HookScript("OnVerticalScroll", UpdateNavThumb)
    navScrollFrame:HookScript("OnSizeChanged",    UpdateNavThumb)
    navSbTrack:HookScript("OnSizeChanged",        UpdateNavThumb)

    -- ── Content area ────────────────────────────────────────────────────
    local content = CreateFrame("Frame", nil, panel)
    content:SetPoint("TOPLEFT",     sidebar, "TOPRIGHT",   0,        0)
    content:SetPoint("BOTTOMRIGHT", panel,   "BOTTOMRIGHT", 0, STATUS_H)
    zAF_FillBG(content, unpack(ZAF.COLOR_PANEL_BG))
    contentArea = content

    -- ── Tab strip ───────────────────────────────────────────────────────
    tabStrip = CreateFrame("Frame", nil, content)
    tabStrip:SetPoint("TOPLEFT",  content, "TOPLEFT",  0, 0)
    tabStrip:SetPoint("TOPRIGHT", content, "TOPRIGHT", 0, 0)
    tabStrip:SetHeight(0)  -- RebuildTabs sets the real height; 0 means scrollFrame starts flush
    zAF_FillBG(tabStrip, unpack(C.sidebarBg))

    tabSepLine = tabStrip:CreateTexture(nil, "OVERLAY")
    tabSepLine:SetColorTexture(PRI[1], PRI[2], PRI[3], 0.55)
    tabSepLine:SetPoint("BOTTOMLEFT",  tabStrip, "BOTTOMLEFT",  0, 0)
    tabSepLine:SetPoint("BOTTOMRIGHT", tabStrip, "BOTTOMRIGHT", 0, 0)
    PixelUtil.SetHeight(tabSepLine, 1, 1)
    tabStrip:Hide() -- shown when a panel has tabs

    -- ── Scrollbar ───────────────────────────────────────────────────────
    sbTrack = CreateFrame("Frame", nil, content)
    sbTrack:SetWidth(SCROLLBAR_W)
    sbTrack:SetPoint("TOPRIGHT",    content, "TOPRIGHT", -2, -2)
    sbTrack:SetPoint("BOTTOMRIGHT", content, "BOTTOMRIGHT", -2,  2)
    local sbTrackBG = sbTrack:CreateTexture(nil, "BACKGROUND")
    sbTrackBG:SetAllPoints()
    sbTrackBG:SetColorTexture(0, 0, 0, 0.25)

    sbThumb = CreateFrame("Frame", nil, sbTrack)
    sbThumb:SetWidth(SCROLLBAR_W)
    local sbThumbTex = sbThumb:CreateTexture(nil, "OVERLAY")
    sbThumbTex:SetAllPoints()
    sbThumbTex:SetColorTexture(PRI[1], PRI[2], PRI[3], 0.85)
    sbThumb:Hide()
    sbTrack:Hide()

    -- ── Scroll frame + content ──────────────────────────────────────────
    local CONTENT_W = pw - activeSidebarW - (SCROLLBAR_W + 4) * 2
    scrollFrame = CreateFrame("ScrollFrame", nil, content)
    scrollFrame:SetPoint("TOPLEFT",     content, "TOPLEFT",      (SCROLLBAR_W + 4),   0)
    scrollFrame:SetPoint("BOTTOMRIGHT", content, "BOTTOMRIGHT", -(SCROLLBAR_W + 4),  0)
    scrollFrame:EnableMouseWheel(true)

    scrollContent = CreateFrame("Frame", nil, scrollFrame)
    scrollContent:SetWidth(CONTENT_W)
    scrollContent:SetHeight(1)
    scrollContent:SetPoint("TOPLEFT")
    scrollContent:SetPoint("TOPRIGHT")
    scrollFrame:SetScrollChild(scrollContent)

    -- Smooth scroll.
    ApplySmoothScroll(scrollFrame, UpdateThumb)

    -- Thumb drag.
    local isDragging, dragStartY, dragStartScr = false, 0, 0
    sbThumb:EnableMouse(true)
    sbThumb:SetScript("OnMouseDown", function(self, btn)
        if btn ~= "LeftButton" then return end
        isDragging   = true
        dragStartY   = select(2, GetCursorPosition()) / UIParent:GetScale()
        dragStartScr = scrollFrame:GetVerticalScroll() or 0
        self:SetScript("OnUpdate", function()
            if not isDragging then self:SetScript("OnUpdate", nil) return end
            local curY   = select(2, GetCursorPosition()) / UIParent:GetScale()
            local deltaY = dragStartY - curY
            local trackH = sbTrack:GetHeight() or 1
            local thumbH = sbThumb:GetHeight() or 20
            local travel  = trackH - thumbH
            if travel <= 0 then return end
            local range = scrollFrame:GetVerticalScrollRange() or 0
            scrollFrame:SetVerticalScroll(math.max(0, math.min(
                dragStartScr + (deltaY / travel * range), range)))
        end)
    end)
    sbThumb:SetScript("OnMouseUp", function() isDragging = false end)

    -- Sync thumb position on scroll events.
    scrollFrame:HookScript("OnVerticalScroll", UpdateThumb)
    scrollFrame:HookScript("OnSizeChanged",    UpdateThumb)
    sbTrack:HookScript("OnSizeChanged",        UpdateThumb)
    panel:HookScript("OnShow", function()
        C_Timer.After(0, UpdateThumb)
        C_Timer.After(0, UpdateNavThumb)
    end)

    panel:SetScript("OnShow", function()
        -- Detect whether WoW's SettingsPanel opened us or our own standalone Open() did.
        _wowHosted = not not (SettingsPanel and SettingsPanel:IsShown())
        if _wowHosted then
            -- WoW controls size and position; suppress chrome that conflicts with it.
            panel._closeBtn:Hide()
            panel._dragRegion:EnableMouse(false)
        else
            panel._closeBtn:Show()
            panel._dragRegion:EnableMouse(true)
            openAG:Stop()
            closeAG:Stop()
            panel:SetAlpha(0)  -- keep invisible during the layout pass
        end
        -- Sync sidebar width for the current hosting context.
        local newSidebarW
        if _wowHosted then
            newSidebarW = (panelAdapter and panelAdapter.navWidthBlizzard) or math.floor(ZSF.SIDEBAR_WIDTH / 2)
        else
            newSidebarW = ZSF.SIDEBAR_WIDTH
        end
        if newSidebarW ~= activeSidebarW then
            activeSidebarW = newSidebarW
            sidebar:SetWidth(activeSidebarW)
            navNameText:ClearAllPoints()
            navNameText:SetPoint("TOPLEFT", titleBar, "TOPLEFT", activeSidebarW + ZSF.CONTENT_PAD_X, -18)
            _builtForWidth = 0  -- force RebuildContent even if panel pixel width didn't change
            RebuildNav()
        end
        -- Rebuild content whenever the panel width changed (entering or
        -- leaving WoW Settings, UI scale change, etc.).
        if panel:GetWidth() ~= _builtForWidth then
            RebuildContent()
        end
        if panelAdapter and panelAdapter.onPanelShow then
            panelAdapter.onPanelShow()
        end
        -- Subscribe to external state changes while the panel is visible.
        if panelAdapter and panelAdapter.subscribe and not _unsubscribe then
            local pending = false
            _unsubscribe = panelAdapter.subscribe(function()
                if ctx._selfWriting then return end
                if pending then return end
                pending = true
                C_Timer.After(0, function()
                    pending = false
                    if panel and panel:IsShown() then
                        ctx:RefreshAll()
                    end
                end)
            end)
        end
        ShowContentPanel(activeNav, activeChild or 0)
        ctx:RefreshAll(true)
        if not _wowHosted then
            C_Timer.After(0, function()
                ShowContentPanel(activeNav, activeChild or 0)
                ctx:RefreshAll(true)
                -- Restore stored alpha to 1 so the animation can take over cleanly.
                panel:SetAlpha(1)
                openAG:Play()
            end)
        end
        -- Start profiling if the adapter has opted in.
        if adapter.cpuProfile and C_AddOnProfiler and C_AddOnProfiler.IsEnabled() then
            if not cpuTicker then
                cpuTicker = C_Timer.NewTicker(0.5, doRefreshCPU)
            end
            doRefreshCPU()
        end
        if adapter.memoryProfile then
            _memRegister(doRefreshMem)
        end
    end)
    panel:SetScript("OnHide", function()
        if not _wowHosted then
            openAG:Stop()
            closeAG:Stop()
            panel:SetAlpha(1)
        end
        -- Reset all child content/tab frame alphas so no orphaned nav tween
        -- can leave a frame at a non-1 alpha before the next open.
        for _, cmap in pairs(contentPanels) do
            for _, frame in pairs(cmap) do frame:SetAlpha(1) end
        end
        for _, tabs in pairs(tabPanels) do
            for _, tf in ipairs(tabs) do tf:SetAlpha(1) end
        end
        -- Unsubscribe from external state changes.
        if _unsubscribe then
            _unsubscribe()
            _unsubscribe = nil
        end
        if panelAdapter and panelAdapter.onPanelHide then
            panelAdapter.onPanelHide()
        end
        -- Stop profiling.
        if cpuTicker then cpuTicker:Cancel(); cpuTicker = nil end
        if memHoverTicker then memHoverTicker:Cancel(); memHoverTicker = nil end
        if adapter.memoryProfile then _memUnregister(doRefreshMem) end
    end)

    -- ── Status bar ──────────────────────────────────────────────────────
    local statusBar = CreateFrame("Frame", nil, panel)
    statusBar:SetPoint("BOTTOMLEFT",  panel, "BOTTOMLEFT",  0, 0)
    statusBar:SetPoint("BOTTOMRIGHT", panel, "BOTTOMRIGHT", 0, 0)
    statusBar:SetHeight(STATUS_H)
    zAF_FillBG(statusBar, unpack(C.sidebarBg))

    local statusSep = statusBar:CreateTexture(nil, "OVERLAY")
    statusSep:SetColorTexture(PRI[1], PRI[2], PRI[3], 0.45)
    statusSep:SetPoint("TOPLEFT",  statusBar, "TOPLEFT",  0, 0)
    statusSep:SetPoint("TOPRIGHT", statusBar, "TOPRIGHT", 0, 0)
    PixelUtil.SetHeight(statusSep, 1, 1)

    local version = C_AddOns.GetAddOnMetadata(adapter.addonFolder or adapter.addonName, "Version") or ""
    local statusText = zAF_Text(statusBar, "GameFontHighlightSmall")
    statusText:SetTextColor(unpack(C.desc))
    statusText:SetText("v" .. version)
    statusText:SetPoint("LEFT", statusBar, "LEFT", 8, 0)

    local author = C_AddOns.GetAddOnMetadata(adapter.addonFolder or adapter.addonName, "Author") or ""
    local authorText = zAF_Text(statusBar, "GameFontHighlightSmall")
    authorText:SetTextColor(unpack(C.desc))
    authorText:SetText(author)
    authorText:SetPoint("RIGHT", statusBar, "RIGHT", -8, 0)

    -- Profile display: cpu% and/or memory, anchored right of the version text.
    -- FontString upvalues (cpuText, memText) are assigned here, after which the
    -- doRefreshCPU / doRefreshMem closures defined above will resolve them correctly.
    local profileAnchor = statusText
    if adapter.cpuProfile then
        cpuText = zAF_Text(statusBar, "GameFontHighlightSmall")
        cpuText:SetTextColor(unpack(C.desc))
        cpuText:SetPoint("LEFT", profileAnchor, "RIGHT", 10, 0)
        cpuText:Hide()
        profileAnchor = cpuText
    end
    if adapter.memoryProfile then
        memText = zAF_Text(statusBar, "GameFontHighlightSmall")
        memText:SetTextColor(unpack(C.desc))
        memText:SetPoint("LEFT", profileAnchor, "RIGHT", 10, 0)
        memText:Hide()

        -- Transparent frame over memText to capture hover events.
        -- FontStrings can't receive mouse events, so we overlay a Frame.
        local memHoverFrame = CreateFrame("Frame", nil, statusBar)
        memHoverFrame:SetAllPoints(memText)
        memHoverFrame:EnableMouse(true)
        memHoverFrame:SetScript("OnEnter", function()
            -- Switch from the shared 15s ticker to a faster local one.
            _memUnregister(doRefreshMem)
            UpdateAddOnMemoryUsage(); doRefreshMem()
            if not memHoverTicker then
                memHoverTicker = C_Timer.NewTicker(5, function()
                    UpdateAddOnMemoryUsage(); doRefreshMem()
                end)
            end
        end)
        memHoverFrame:SetScript("OnLeave", function()
            -- Return to the shared slow ticker.
            if memHoverTicker then memHoverTicker:Cancel(); memHoverTicker = nil end
            if panel:IsShown() then _memRegister(doRefreshMem) end
        end)
    end

    -- Store accessible references on the panel.
    panel.sidebar       = sidebar
    panel.navContent    = navContent
    panel.content       = content
    panel.scrollFrame   = scrollFrame
    panel.scrollContent = scrollContent
    panel.tabStrip      = tabStrip
    panel.titleText     = titleText
    panel.statusText    = statusText
    panel.CONTENT_W     = CONTENT_W
    panel._navNameText  = navNameText
    panel._navDescText  = navDescText
    panel._closeBtn     = closeBtn
    panel._dragRegion   = dragRegion

    return panel
end

-- ── Public: populate the sidebar from schema nav entries ─────────────────────

--- Build sidebar nav items from the schema's top-level nav entries.
--- Called by the constructor after resolving nav/navChild hierarchy.
---@param sidebar Frame
---@param items   table[]  Array of { name=string, description=string|nil, children=string[]|nil, hasOwnContent=bool }.
function ctx._buildSidebarNav(sidebar, items)
    navItems = items
    navFrames = {}
    for i, item in ipairs(items) do
        MakeNavItem(sidebar, i, item, 0)
    end
    RebuildNav()
end

-- ── Public: rebuild content at current panel size ───────────────────────────

--- Tears down all existing content / tab frames and rebuilds them at the
--- panel's current actual width.  Called on every first Show (initial lazy build)
--- and whenever the panel width changes (resize, WoW Settings ↔ standalone switch).
RebuildContent = function()
    if not panel or not scrollContent or not panelAdapter then return end

    -- Hide and detach every old content / tab frame.  Hiding first ensures
    -- orphaned frames don't render after SetParent(nil) re-homes them under
    -- UIParent while still anchored to scrollContent.
    for _, cmap in pairs(contentPanels) do
        for _, f in pairs(cmap) do f:Hide() ; f:SetParent(nil) end
    end
    for _, tabs in pairs(tabPanels) do
        for _, f in ipairs(tabs) do f:Hide() ; f:SetParent(nil) end
    end
    contentPanels  = {}
    tabPanels      = {}
    ctx._widgets   = {}

    -- Re-derive content width from the real frame size WoW gave us.
    local actualW   = panel:GetWidth()
    local newCW     = actualW - activeSidebarW - (SCROLLBAR_W + 4) * 2
    effectivePanelW = actualW
    scrollContent:SetWidth(newCW)
    panel.CONTENT_W = newCW
    _builtForWidth  = actualW

    local prevCtx = zSettingsFrame._buildCtx
    zSettingsFrame._buildCtx = ctx
    ctx._populateContentPanels(
        ctx._navHierarchy or {}, panelAdapter, PRI, SEC)
    zSettingsFrame._buildCtx = prevCtx
end
ctx._rebuildContent = RebuildContent

-- ── Public: build content panels from nav hierarchy ───────────────────────────

--- Create and populate one scrollable content frame per nav / navChild / tab.
--- Calls zSF_BuildFromSchema for each, stores results in contentPanels / tabPanels.
--- Must be called after zSF_BuildPanel so scrollContent exists.
---@param navHierarchy zSF_NavInfo[]
---@param adapter      zSF_Adapter
---@param primary      table  { r, g, b }
---@param secondary    table  { r, g, b }
function ctx._populateContentPanels(navHierarchy, adapter, primary, secondary)
    local sc = scrollContent
    if not sc then return end

    local function makeFrame()
        local f = CreateFrame("Frame", nil, sc)
        f:SetPoint("TOPLEFT",  sc, "TOPLEFT",  0, 0)
        f:SetPoint("TOPRIGHT", sc, "TOPRIGHT", 0, 0)
        f:SetHeight(1)
        f:Hide()
        f._zsfCtx = ctx
        return f
    end

    local function buildInto(f, schema, trackerID)
        local h = zSF_BuildFromSchema(schema, f, adapter, trackerID, primary, secondary)
        h = math.max(h, 1)
        f:SetHeight(h)
        f.zsfHeight = h
    end

    for navIdx, navInfo in ipairs(navHierarchy) do
        contentPanels[navIdx] = {}

        local hasTabs = navInfo.tabNames and #navInfo.tabNames > 0

        -- Build navChild content panels (if any).
        if navInfo.children and #navInfo.children > 0 then
            -- Own content is only useful when tabs don't replace it.
            if not hasTabs and navInfo.hasOwnContent and navInfo.ownSchema then
                local f = makeFrame()
                buildInto(f, navInfo.ownSchema, nil)
                contentPanels[navIdx][0] = f
            end
            for ci, childSchema in ipairs(navInfo.childSchemas or {}) do
                local trackerID = navInfo.trackerIDs and navInfo.trackerIDs[ci]
                local cTabNames = navInfo.childTabNames and navInfo.childTabNames[ci]
                if cTabNames and #cTabNames > 0 then
                    -- Build tab panels for this navChild; tabs replace its own content.
                    local key = ChildTabKey(navIdx, ci)
                    tabPanels[key] = {}
                    local cTabSchemas = navInfo.childTabSchemas and navInfo.childTabSchemas[ci] or {}
                    for ti, tabSchema in ipairs(cTabSchemas) do
                        local f = makeFrame()
                        buildInto(f, tabSchema, trackerID)
                        tabPanels[key][ti] = f
                    end
                    -- Only build a content panel if the navChild also has plain (non-tab) widgets.
                    if #childSchema > 0 then
                        local f = makeFrame()
                        buildInto(f, childSchema, trackerID)
                        contentPanels[navIdx][ci] = f
                    end
                else
                    local f = makeFrame()
                    buildInto(f, childSchema, trackerID)
                    contentPanels[navIdx][ci] = f
                end
            end
        end

        -- Build tab panels (allowed alongside navChildren).
        if navInfo.tabNames and #navInfo.tabNames > 0 then
            tabPanels[navIdx] = {}
            for ti, tabSchema in ipairs(navInfo.tabSchemas or {}) do
                local f = makeFrame()
                buildInto(f, tabSchema, nil)
                tabPanels[navIdx][ti] = f
            end
        end

        -- Plain nav with no children and no tabs — just own widgets.
        if (not navInfo.children or #navInfo.children == 0)
           and (not navInfo.tabNames or #navInfo.tabNames == 0) then
            local f = makeFrame()
            buildInto(f, navInfo.ownSchema or {}, nil)
            contentPanels[navIdx][0] = f
        end
    end
end

-- ── Public: register Blizzard Settings stub frame ─────────────────────────────

--- Register a minimal stub frame in WoW's Settings → Addons so the addon
--- appears in the list.  The stub shows a button that opens our standalone panel.
---@param addonName string
function ctx._registerStubFrame(addonName)
    local stub = CreateFrame("Frame", nil, UIParent)

    local title = stub:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    title:SetPoint("TOP", stub, "TOP", 0, -50)
    title:SetText(addonName)

    local desc = stub:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    desc:SetPoint("TOP", title, "BOTTOM", 0, -12)
    desc:SetText("Click below to open the full settings panel.")

    local btn = CreateFrame("Button", nil, stub, "UIPanelButtonTemplate")
    btn:SetSize(180, 30)
    btn:SetPoint("TOP", desc, "BOTTOM", 0, -18)
    btn:SetText("Open Options")
    btn:SetScript("OnClick", function()
        if SettingsPanel and SettingsPanel:IsShown() then
            -- HideUIPanel properly fires WoW's UIPanelWindows bookkeeping.
            -- Direct :Hide() skips that, leaving SettingsPanel's internal state
            -- dirty and permanently breaking the Escape → game menu chain.
            HideUIPanel(SettingsPanel)
        end
        -- Defer one frame to avoid taint from executing inside the Blizzard
        -- Settings frame's own click handler stack.
        C_Timer.After(0, function() ctx:Open() end)
    end)

    local category = Settings.RegisterCanvasLayoutCategory(stub, addonName)
    Settings.RegisterAddOnCategory(category)
    ctx._categoryID = category:GetID()
end

-- ── Context accessors (called via _buildCtx from Constructor / cross-file) ────

    ctx._isHosted               = function() return _wowHosted end
    ctx._getContentWidth        = function() return panel and panel.CONTENT_W or 0 end
    ctx._getScrollContent       = function() return scrollContent end
    ctx._setScrollContentHeight = SetScrollContentHeight
    -- Returns true only when `frame` is the panel currently driving the scroll
    -- frame height.  Used by reflow logic to ignore fading-out panels that are
    -- still IsShown() during their AnimateOut transition.
    ctx._isActiveContentFrame   = function(frame)
        if not frame or not contentPanels then return false end
        local ci = activeChild or 0
        local cmap = contentPanels[activeNav]
        if cmap and cmap[ci] == frame then return true end
        -- Also accept the active tab panel as the driver.
        local tabKey = (ci == 0) and activeNav or ChildTabKey(activeNav, ci)
        local tabs   = tabPanels[tabKey]
        if tabs then
            local activeTf = tabs[GetActiveTab(tabKey)]
            if activeTf == frame then return true end
        end
        return false
    end
    ctx._refreshNav             = RebuildNav
    ctx._refreshTabs            = function() if activeTabRefresh then activeTabRefresh() end end

end  -- end zSF_InitPanelInstance

-- ── Global dispatch shims ─────────────────────────────────────────────────────
-- These delegate through _buildCtx, which is set during build passes and while
-- zSF_RelayoutContentFrame / widget builders are executing inside Constructor.

--- Return whether the active context's panel is inside WoW's SettingsPanel.
---@return boolean
function zSF_IsHosted()
    local c = zSettingsFrame and zSettingsFrame._buildCtx
    return (c and c._isHosted()) or false
end

--- Return the content area width for the active build context.
---@return number
function zSF_GetContentWidth()
    local c = zSettingsFrame and zSettingsFrame._buildCtx
    return (c and c._getContentWidth()) or 0
end

--- Return the scrollable content frame for the active build context.
---@return Frame|nil
function zSF_GetScrollContent()
    local c = zSettingsFrame and zSettingsFrame._buildCtx
    return c and c._getScrollContent()
end
