-- zSettingsFrame/zSF_Widgets.lua
-- Widget constructors for the settings framework.
-- Each builder creates the WoW frames for a single control type and returns
-- a table with the interactive elements the Constructor needs to wire up.
-- All colours are parameterised via primary/secondary tables passed at build time.
-- Chrome utilities (tooltip, gradient border, section header) live in zSF_Chrome.lua.
local ZSF = _G.ZSF  -- namespace for style constants and easing functions
-- Addon-agnostic: no references to any specific addon symbol.

-- ── Shared constants ─────────────────────────────────────────────────────────

-- CovenantSanctum-Renown-Arrow texture (used for slider steppers and dropdown arrow).
local RENOWN_SHEET = "Interface\\CovenantRenown\\CovenantRenownUI"
local RENOWN_L, RENOWN_R = 0.7568359375, 0.8037109375
local RENOWN_T, RENOWN_B = 0.1328125,    0.263671875

-- Widget cell heights (kept in sync with prototype).
local SWATCH_H  = 20
local DD_H      = 20
local ARROW_W   = 14  -- stepper arrow width (still used by zSF_MakeStepperArrow)
local ARROW_H   = 20  -- stepper arrow height
local CB_INSET  = 3   -- checkbox border-to-fill inset

-- Popup fade constants (shorter than panel for snappier UX).
local POPUP_FADE_IN  = 0.2
local POPUP_FADE_OUT = 0.2

local function Popup_ClearTweens(frame)
    if frame._zsfFadeInTw then frame._zsfFadeInTw:Cancel(); frame._zsfFadeInTw = nil end
    if frame._zsfFadeOutTw then frame._zsfFadeOutTw:Cancel(); frame._zsfFadeOutTw = nil end
end

local function Popup_Show(frame)
    Popup_ClearTweens(frame)
    frame:SetAlpha(0)
    frame:Show()
    frame:Raise()
    frame._zsfFadeInTw = zSF_Tween(0, 1, POPUP_FADE_IN, ZSF.EASE_SINE_OUT,
        function(v) frame:SetAlpha(v) end,
        function() frame._zsfFadeInTw = nil end)
end

local function Popup_Hide(frame)
    if not frame:IsShown() then return end
    Popup_ClearTweens(frame)
    local fromAlpha = frame:GetAlpha()
    frame._zsfFadeOutTw = zSF_Tween(fromAlpha, 0, POPUP_FADE_OUT, ZSF.EASE_SINE_IN,
        function(v) frame:SetAlpha(v) end,
        function()
            frame._zsfFadeOutTw = nil
            frame:Hide()
            frame:SetAlpha(1)
        end)
end

-- ── Shared math helper ───────────────────────────────────────────────────────
local function lerp(a, b, t) return a + (b - a) * t end

-- ── Pill toggle ──────────────────────────────────────────────────────────────

-- Toggle pill dimensions.
local PILL_W    = 36
local PILL_H    = 16
local PILL_KNOB = 12   -- knob diameter (circle)
local PILL_PAD  = 2    -- knob inset from pill edge
local PILL_ANIM = 0.10

--- Pill-shaped toggle switch with true rounded ends and a circular knob.
--- Returns a Button frame with :SetChecked(bool), :GetChecked().
--- Animates the knob left→right and cross-fades the track colour.
---@param parent         Frame
---@param secondary      table       { r, g, b } ON track colour.
---@param indicatorColor table|nil   { r, g, b[, a] } knob ON colour; defaults to white.
---@return Button pill
function zSF_MakePillToggle(parent, secondary, indicatorColor)
    local pill = CreateFrame("Button", nil, parent)
    pill:SetSize(PILL_W, PILL_H)
    pill:EnableMouse(true)

    local circlePath = zSF_AssetPath("zSF_Circle.tga")
    local pillPath   = zSF_AssetPath("zSF_Pill.tga")

    -- Track: single rect masked to the pill shape.
    local track = pill:CreateTexture(nil, "BACKGROUND")
    track:SetAllPoints()
    track:SetColorTexture(0.15, 0.15, 0.15, 0.85)
    local trackMask = pill:CreateMaskTexture()
    trackMask:SetAllPoints()
    trackMask:SetTexture(pillPath, "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE")
    track:AddMaskTexture(trackMask)

    -- OFF-state tint: secondary colour at 20% overlaid on the grey, fading out as toggle slides ON.
    local trackTint = pill:CreateTexture(nil, "BORDER")
    trackTint:SetAllPoints()
    trackTint:AddMaskTexture(trackMask)

    -- Knob: circle texture tinted via SetVertexColor.
    local knob = pill:CreateTexture(nil, "ARTWORK")
    knob:SetSize(PILL_KNOB, PILL_KNOB)
    knob:SetPoint("LEFT", pill, "LEFT", PILL_PAD, 0)
    knob:SetTexture(circlePath)
    knob:SetVertexColor(0.78, 0.78, 0.78, 1)

    -- Animation state (0 = fully OFF, 1 = fully ON).
    local animT      = 0
    local animTarget = 0
    local POS_OFF    = PILL_PAD
    local POS_ON     = PILL_W - PILL_KNOB - PILL_PAD
    -- Track by knob center so squash-stretch stays centered during travel.
    local CENTER_OFF     = POS_OFF + PILL_KNOB / 2
    local CENTER_ON      = POS_ON  + PILL_KNOB / 2
    local KNOB_STRETCH   = 6   -- extra px width at peak of travel arc
    local SR, SG, SB = secondary[1], secondary[2], secondary[3]
    local IC = indicatorColor or { 1, 1, 1 }
    local PR, PG, PB, PA = IC[1], IC[2], IC[3], IC[4] or 1

    local function ApplyVisual(t)
        local stretch = math.sin(math.pi * t) * KNOB_STRETCH
        local knobW   = math.floor(PILL_KNOB + stretch + 0.5)
        local cx      = lerp(CENTER_OFF, CENTER_ON, t)
        local xOff    = math.floor(cx - knobW / 2 + 0.5)
        knob:SetWidth(knobW)
        knob:ClearAllPoints()
        knob:SetPoint("LEFT", pill, "LEFT", xOff, 0)
        track:SetColorTexture(
            lerp(0.15, SR, t), lerp(0.15, SG, t), lerp(0.15, SB, t),
            lerp(0.85, 1, t))
        trackTint:SetColorTexture(SR, SG, SB, 0.20 * (1 - t))
        -- Knob transitions from neutral grey (OFF) to the indicator colour (ON); white by default.
        knob:SetVertexColor(
            lerp(0.78, PR, t), lerp(0.78, PG, t), lerp(0.78, PB, t), lerp(1, PA, t))
    end

    ApplyVisual(0)

    local function AnimOnUpdate(self, elapsed)
        local dir = (animTarget == 1) and 1 or -1
        animT = math.max(0, math.min(1, animT + dir * (elapsed / PILL_ANIM)))
        ApplyVisual(animT)
        if animT == animTarget then
            self:SetScript("OnUpdate", nil)
        end
    end

    pill.checked = false

    ---@param v boolean
    function pill:SetChecked(v)
        self.checked = v
        animT        = v and 1 or 0
        animTarget   = animT
        self:SetScript("OnUpdate", nil)
        ApplyVisual(animT)
    end

    ---@return boolean
    function pill:GetChecked()
        return self.checked
    end

    -- Clicking animates; actual value persistence is wired externally by the Constructor.
    local function OnClick(self)
        local newVal = not self.checked
        self.checked = newVal
        animTarget   = newVal and 1 or 0
        self:SetScript("OnUpdate", AnimOnUpdate)
    end
    pill:SetScript("OnClick", OnClick)
    -- Expose the click-animate handler so Constructor can extend without breaking animation.
    pill.DoClick = OnClick

    return pill
end

-- ── Stepper arrow (Renown-style) ─────────────────────────────────────────────

--- Create a left/right stepper arrow button.
---@param parent    Frame
---@param flipH     boolean      true = right-facing.
---@param primary   table        { r, g, b } normal colour.
---@param secondary table        { r, g, b } hover colour.
---@return Frame btn
function zSF_MakeStepperArrow(parent, flipH, primary, secondary)
    local btn = CreateFrame("Frame", nil, parent)
    btn:SetSize(ARROW_W, ARROW_H)

    local norm = btn:CreateTexture(nil, "ARTWORK")
    norm:SetTexture(RENOWN_SHEET)
    if flipH then
        norm:SetTexCoord(RENOWN_R, RENOWN_L, RENOWN_T, RENOWN_B)
    else
        norm:SetTexCoord(RENOWN_L, RENOWN_R, RENOWN_T, RENOWN_B)
    end
    norm:SetAllPoints()
    norm:SetDesaturated(true)
    norm:SetVertexColor(primary[1], primary[2], primary[3], 1)

    btn:EnableMouse(true)
    btn:SetScript("OnEnter", function()
        norm:SetVertexColor(secondary[1], secondary[2], secondary[3], 1)
    end)
    btn:SetScript("OnLeave", function()
        norm:SetVertexColor(primary[1], primary[2], primary[3], 1)
    end)
    btn:SetScript("OnMouseDown", function()
        norm:SetVertexColor(0.72, 0.72, 0.72, 1)
    end)
    btn:SetScript("OnMouseUp", function()
        if btn:IsMouseOver() then
            norm:SetVertexColor(secondary[1], secondary[2], secondary[3], 1)
        else
            norm:SetVertexColor(primary[1], primary[2], primary[3], 1)
        end
    end)
    btn.normalTex = norm
    return btn
end

--- Create an up/down arrow button using the same Renown atlas, rotated 90°.
---@param parent    Frame
---@param pointDown boolean  true = down-facing.
---@param primary   table    { r, g, b } normal colour.
---@param secondary table    { r, g, b } hover colour.
---@return Frame btn
function zSF_MakeVerticalArrow(parent, pointDown, primary, secondary)
    local btn = CreateFrame("Frame", nil, parent)
    btn:SetSize(ARROW_W, ARROW_W)  -- square: same width as horizontal arrows

    local norm = btn:CreateTexture(nil, "ARTWORK")
    norm:SetTexture(RENOWN_SHEET)
    norm:SetTexCoord(RENOWN_L, RENOWN_R, RENOWN_T, RENOWN_B)
    -- Rotate the left-pointing arrow: CW 90° → tip points down; CCW 90° → tip points up.
    norm:SetRotation(pointDown and (math.pi / 2) or (-math.pi / 2))
    norm:SetAllPoints()
    norm:SetDesaturated(true)
    norm:SetVertexColor(primary[1], primary[2], primary[3], 1)

    btn:EnableMouse(true)
    btn:SetScript("OnEnter", function()
        norm:SetVertexColor(secondary[1], secondary[2], secondary[3], 1)
    end)
    btn:SetScript("OnLeave", function()
        norm:SetVertexColor(primary[1], primary[2], primary[3], 1)
    end)
    btn:SetScript("OnMouseDown", function()
        norm:SetVertexColor(0.72, 0.72, 0.72, 1)
    end)
    btn:SetScript("OnMouseUp", function()
        if btn:IsMouseOver() then
            norm:SetVertexColor(secondary[1], secondary[2], secondary[3], 1)
        else
            norm:SetVertexColor(primary[1], primary[2], primary[3], 1)
        end
    end)
    btn.normalTex = norm
    return btn
end

-- ── Slider ───────────────────────────────────────────────────────────────────

-- Dimensions for the fill-bar drag slider.
local TRACK_H   = 4    -- groove height (px)
local THUMB_SZ  = 12   -- thumb circle diameter (px)
local INPUT_H   = 18   -- value box height

--- Format a slider value according to step precision.
---@param v    number
---@param step number
---@return string
local function FormatSliderVal(v, step)
    if step >= 1 then
        return string.format("%d", math.floor(v + 0.5))
    elseif step >= 0.1 then
        return string.format("%.1f", v)
    else
        return string.format("%.2f", v)
    end
end

--- Build a fill-bar drag slider (no WoW Slider frame).
--- Returned table: { trackFrame, inputText, inputFrame, UpdateVisual }
---@param cell      Frame    Parent frame (width must be set before calling).
---@param minVal    number
---@param maxVal    number
---@param curVal    number
---@param step      number
---@param primary   table    { r, g, b }
---@param secondary table    { r, g, b }
---@param inline    boolean|nil  True → input/arrows sit right of the track on one line.
---@return table result
function zSF_BuildSlider(cell, minVal, maxVal, curVal, step, primary, secondary, inline)
    step   = step or 1
    curVal = math.max(minVal, math.min(maxVal, curVal))

    local value = curVal  -- current committed value

    -- ── Track ────────────────────────────────────────────────────────────────
    local trackFrame = CreateFrame("Frame", nil, cell)
    trackFrame:SetHeight(THUMB_SZ)  -- container tall enough for thumb; anchors set after arrows

    -- Dark groove.
    local trackDark = trackFrame:CreateTexture(nil, "BACKGROUND")
    trackDark:SetHeight(TRACK_H)
    trackDark:SetPoint("LEFT",  trackFrame, "LEFT",  THUMB_SZ / 2,  0)
    trackDark:SetPoint("RIGHT", trackFrame, "RIGHT", -THUMB_SZ / 2, 0)
    trackDark:SetColorTexture(0.15, 0.15, 0.15, 0.85)

    -- Secondary tint over the full groove (same alpha as pill OFF-state),
    -- so the unfilled "expand" region reads as grey + secondary hint.
    local trackTint = trackFrame:CreateTexture(nil, "BORDER")
    trackTint:SetHeight(TRACK_H)
    trackTint:SetPoint("LEFT",  trackFrame, "LEFT",  THUMB_SZ / 2,  0)
    trackTint:SetPoint("RIGHT", trackFrame, "RIGHT", -THUMB_SZ / 2, 0)
    trackTint:SetColorTexture(secondary[1], secondary[2], secondary[3], 0.20)

    -- Filled portion (grows left-to-right, secondary colour).
    local trackFill = trackFrame:CreateTexture(nil, "BORDER")
    trackFill:SetHeight(TRACK_H)
    trackFill:SetPoint("LEFT", trackDark, "LEFT", 0, 0)
    trackFill:SetColorTexture(secondary[1], secondary[2], secondary[3], 0.85)

    -- ── Thumb ────────────────────────────────────────────────────────────────
    local thumb = CreateFrame("Frame", nil, trackFrame)
    thumb:SetSize(THUMB_SZ, THUMB_SZ)

    local thumbTex = thumb:CreateTexture(nil, "OVERLAY")
    thumbTex:SetAllPoints()
    thumbTex:SetTexture(zSF_AssetPath("zSF_Circle.tga"))
    thumbTex:SetVertexColor(primary[1], primary[2], primary[3], 1)

    -- ── Layout helpers ───────────────────────────────────────────────────────
    local function GetTrackPixelWidth()
        return trackDark:GetWidth()
    end

    local function ValueToRatio(v)
        if maxVal == minVal then return 0 end
        return (v - minVal) / (maxVal - minVal)
    end

    local function RatioToValue(r)
        local raw = minVal + r * (maxVal - minVal)
        local snapped = math.floor(raw / step + 0.5) * step
        return math.max(minVal, math.min(maxVal, snapped))
    end

    --- Reposition fill and thumb for value v (or current value if nil).
    ---@param v number|nil
    local function UpdateVisual(v)
        v = v or value
        local ratio = ValueToRatio(v)
        local trackW = GetTrackPixelWidth()
        local fillW  = math.max(0, math.min(trackW, ratio * trackW))
        trackFill:SetWidth(fillW)
        -- Thumb anchored to the fill right edge clamped to track bounds.
        thumb:ClearAllPoints()
        thumb:SetPoint("CENTER", trackDark, "LEFT", fillW, 0)
    end

    -- Re-run UpdateVisual when the slider becomes visible. trackDark:GetWidth()
    -- returns 0 on hidden frames (texture sizes are not computed until shown),
    -- so the initial visual pass during RefreshAll is a no-op for panels that
    -- aren't yet shown. This corrects the thumb position on first show.
    trackFrame:SetScript("OnShow", function()
        UpdateVisual(value)
    end)

    -- External callback (wired by Constructor).
    ---@type fun(v: number)|nil
    local onChanged = nil

    -- ── Value input box ──────────────────────────────────────────────────────
    local inputFrame = CreateFrame("Frame", nil, cell)
    local inputW = inline and 32 or math.min(52, math.max(36, cell:GetWidth() * 0.4))
    inputFrame:SetSize(inputW, INPUT_H)
    -- Anchor set below with the rest of the layout.
    zSF_FillBG(inputFrame, 0.06, 0.06, 0.06, 1)
    zSF_DrawBorder(inputFrame, primary[1], primary[2], primary[3], 0.65)

    local inputText = CreateFrame("EditBox", nil, inputFrame)
    inputText:SetPoint("TOPLEFT",     inputFrame, "TOPLEFT",      3, -2)
    inputText:SetPoint("BOTTOMRIGHT", inputFrame, "BOTTOMRIGHT", -3,  2)
    inputText:SetAutoFocus(false)
    inputText:SetFontObject("GameFontHighlightSmall")
    inputText:SetJustifyH("CENTER")
    inputText:SetTextColor(unpack(ZSF.COLOR_LABEL))
    inputText:SetMaxLetters(7)
    inputText:SetText(FormatSliderVal(curVal, step))

    local function CommitInput()
        local parsed = tonumber(inputText:GetText())
        if parsed then
            value = RatioToValue(ValueToRatio(math.max(minVal, math.min(maxVal, parsed))))
        end
        inputText:SetText(FormatSliderVal(value, step))
        UpdateVisual(value)
        if onChanged then onChanged(value) end
    end

    inputText:SetScript("OnEnterPressed", function(self)
        CommitInput()
        self:ClearFocus()
    end)
    inputText:SetScript("OnEscapePressed", function(self)
        inputText:SetText(FormatSliderVal(value, step))
        self:ClearFocus()
    end)
    inputText:SetScript("OnEditFocusGained", function(self)
        self:HighlightText()
        self:SetTextColor(1, 1, 1, 1)
    end)
    inputText:SetScript("OnEditFocusLost", function(self)
        CommitInput()
        self:SetTextColor(unpack(ZSF.COLOR_LABEL))
    end)
    -- WoW bug: after tab navigation the text can render as 0; re-render on show.
    inputText:SetScript("OnShow", function(self)
        self:SetText(FormatSliderVal(value, step))
        self:SetCursorPosition(0)
    end)

    -- ── Drag logic ───────────────────────────────────────────────────────────
    local isDragging   = false
    local dragStartX   = 0
    local dragStartVal = 0
    local SR, SG, SB   = primary[1], primary[2], primary[3]

    -- ── Stepper arrows ───────────────────────────────────────────────────────
    local function StepBy(delta)
        local newVal = math.max(minVal, math.min(maxVal, value + delta))
        if newVal ~= value then
            value = newVal
            UpdateVisual(value)
            if not inputText:HasFocus() then
                inputText:SetText(FormatSliderVal(value, step))
            end
            if onChanged then onChanged(value) end
        end
    end

    -- Hold-to-repeat timing constants.
    local HOLD_DELAY    = 0.5   -- initial delay before repeat starts (seconds)
    local INTERVAL_SLOW = 0.25  -- repeat interval at the start of the ramp
    local INTERVAL_FAST = 0.06  -- repeat interval at full speed (~16/sec)
    local RAMP_DURATION = 2.0   -- seconds to accelerate from slow to fast

    -- Wire a stepper arrow: fires once on click, then after HOLD_DELAY starts
    -- repeating and gradually accelerates over RAMP_DURATION before settling
    -- at INTERVAL_FAST. Cancels cleanly on release or mouse-leave.
    local function WireStepperHold(btn, delta)
        local timer     = nil
        local rampStart = 0

        local function CancelHold()
            if timer then timer:Cancel(); timer = nil end
        end

        -- Self-rescheduling tick; interval shrinks as ramp time elapses.
        local function ScheduleNext()
            local t        = math.min((GetTime() - rampStart) / RAMP_DURATION, 1)
            local interval = INTERVAL_SLOW + t * (INTERVAL_FAST - INTERVAL_SLOW)
            timer = C_Timer.NewTimer(interval, function()
                timer = nil
                StepBy(delta)
                ScheduleNext()
            end)
        end

        local origDown = btn:GetScript("OnMouseDown")
        btn:SetScript("OnMouseDown", function(self, b)
            if origDown then origDown(self, b) end
            if b == "LeftButton" then
                StepBy(delta)
                timer = C_Timer.NewTimer(HOLD_DELAY, function()
                    timer     = nil
                    rampStart = GetTime()
                    StepBy(delta)
                    ScheduleNext()
                end)
            end
        end)

        local origUp = btn:GetScript("OnMouseUp")
        btn:SetScript("OnMouseUp", function(self, b)
            if origUp then origUp(self, b) end
            if b == "LeftButton" then CancelHold() end
        end)

        local origLeave = btn:GetScript("OnLeave")
        btn:SetScript("OnLeave", function(self)
            CancelHold()
            if origLeave then origLeave(self) end
        end)
    end

    local stepLeft  = zSF_MakeStepperArrow(cell, false, primary, secondary)
    local stepRight = zSF_MakeStepperArrow(cell, true,  primary, secondary)
    if inline then
        -- One-line layout: [track] [←][input][→], each piece centered vertically.
        local insetY    = math.floor((ARROW_H - THUMB_SZ) / 2)  -- centers 12px track in ARROW_H cell
        local inputOffY = math.floor((ARROW_H - INPUT_H)  / 2)  -- centers 18px input in ARROW_H cell
        stepRight:SetPoint("TOPRIGHT",  cell,       "TOPRIGHT",  0,  0)
        inputFrame:SetPoint("TOPRIGHT", stepRight,  "TOPLEFT",  -4, -inputOffY)
        stepLeft:SetPoint("TOPRIGHT",   inputFrame, "TOPLEFT",  -4,  inputOffY)
        trackFrame:SetPoint("TOPLEFT",  cell,       "TOPLEFT",   0, -insetY)
        trackFrame:SetPoint("TOPRIGHT", stepLeft,   "TOPLEFT",  -4, -insetY)
    else
        trackFrame:SetPoint("TOPLEFT",  cell, "TOPLEFT",  0, 0)
        trackFrame:SetPoint("TOPRIGHT", cell, "TOPRIGHT", 0, 0)
        inputFrame:SetPoint("TOP", trackFrame, "BOTTOM", 0, -4)
        stepLeft:SetPoint("RIGHT",  inputFrame, "LEFT",  -4, 0)
        stepRight:SetPoint("LEFT",  inputFrame, "RIGHT",  4, 0)
    end

    WireStepperHold(stepLeft,  -step)
    WireStepperHold(stepRight,  step)

    local function BeginDrag()
        isDragging   = true
        dragStartX   = GetCursorPosition()
        dragStartVal = value
        thumbTex:SetVertexColor(1, 1, 1, 1)
        trackFrame:SetScript("OnUpdate", function()
            if not isDragging then
                trackFrame:SetScript("OnUpdate", nil)
                return
            end
            local curX = GetCursorPosition()
            local scale = trackFrame:GetEffectiveScale()
            local trackW = GetTrackPixelWidth() * scale
            if trackW <= 0 then return end
            local deltaPx = curX - dragStartX
            local deltaRatio = deltaPx / trackW
            local startRatio = ValueToRatio(dragStartVal)
            local newVal = RatioToValue(math.max(0, math.min(1, startRatio + deltaRatio)))
            if newVal ~= value then
                value = newVal
                UpdateVisual(value)
                if not inputText:HasFocus() then
                    inputText:SetText(FormatSliderVal(value, step))
                end
                if onChanged then onChanged(value) end
            end
        end)
    end

    local function EndDrag()
        if not isDragging then return end
        isDragging = false
        trackFrame:SetScript("OnUpdate", nil)
        thumbTex:SetVertexColor(SR, SG, SB, 1)
        if onChanged then onChanged(value) end
    end

    -- Click anywhere on the groove → jump to that position.
    trackFrame:EnableMouse(true)
    trackFrame:SetScript("OnMouseDown", function(self, btn)
        if btn ~= "LeftButton" then return end
        local scale  = self:GetEffectiveScale()
        local trackX = trackDark:GetLeft() * scale
        local trackW = GetTrackPixelWidth() * scale
        if trackW <= 0 then return end
        local cursorX  = GetCursorPosition()
        local clickRatio = math.max(0, math.min(1, (cursorX - trackX) / trackW))
        value = RatioToValue(clickRatio)
        dragStartX   = cursorX
        dragStartVal = value
        UpdateVisual(value)
        if not inputText:HasFocus() then
            inputText:SetText(FormatSliderVal(value, step))
        end
        if onChanged then onChanged(value) end
        BeginDrag()
    end)
    trackFrame:SetScript("OnMouseUp", function(_, btn)
        if btn == "LeftButton" then EndDrag() end
    end)

    -- Also drag from the thumb itself.
    thumb:EnableMouse(true)
    thumb:SetScript("OnMouseDown", function(_, btn)
        if btn == "LeftButton" then BeginDrag() end
    end)
    thumb:SetScript("OnMouseUp", function(_, btn)
        if btn == "LeftButton" then EndDrag() end
    end)

    -- Hover tint on thumb.
    thumb:SetScript("OnEnter", function()
        if not isDragging then thumbTex:SetVertexColor(1, 1, 1, 1) end
    end)
    thumb:SetScript("OnLeave", function()
        if not isDragging then thumbTex:SetVertexColor(SR, SG, SB, 1) end
    end)

    -- Initial layout.
    UpdateVisual(value)
    -- The cell's width (and therefore trackDark's width) resolves at the first
    -- render pass, not at construction.  Re-run when the track gets a real width.
    trackFrame:SetScript("OnSizeChanged", function(self, w)
        if w > 0 then UpdateVisual(value) end
    end)

    cell:SetHeight(inline and ARROW_H or (THUMB_SZ + 4 + INPUT_H))

    -- Return shape the Constructor uses.
    local result = {
        trackFrame  = trackFrame,
        inputText   = inputText,
        inputFrame  = inputFrame,
        --- Get the current (snapped) value.
        ---@return number
        GetValue    = function() return value end,
        --- Set the value without firing onChanged.
        ---@param v number
        SetValue    = function(v)
            value = math.max(minVal, math.min(maxVal, v))
            inputText:SetText(FormatSliderVal(value, step))
            UpdateVisual(value)
        end,
        --- Register a callback fired on every drag step and input commit.
        ---@param fn fun(v: number)
        SetOnChanged = function(fn) onChanged = fn end,
        --- Force a visual refresh (useful after layout pass changes width).
        UpdateVisual = function() UpdateVisual(value) end,
    }
    return result
end

-- ── Dropdown ─────────────────────────────────────────────────────────────────

-- Maximum pixel height before the popup scrolls.
local DD_MENU_MAX_H = 200
-- Item height and padding inside the popup.
local DD_ITEM_H     = 22
local DD_MENU_PAD   = 4

-- Shared full-screen mouse-catcher used to dismiss open menus on outside click.
---@type Frame|nil
local _ddCatcher = nil

local function EnsureDDCatcher()
    if _ddCatcher then return end
    local f = CreateFrame("Frame", nil, UIParent)
    f:SetAllPoints(UIParent)
    f:SetFrameStrata("FULLSCREEN_DIALOG")
    f:SetFrameLevel(395)
    f:EnableMouse(true)
    f:Hide()
    _ddCatcher = f
end

-- Currently open menu (nil when closed; always points to the shared popup).
---@type Frame|nil
local _openMenu = nil

-- Which dropdown button currently owns the shared popup.
---@type Button|nil
local _ddPopupOwner = nil

-- ── Shared popup (one instance, claimed per open) ─────────────────────────────
---@type Frame|nil
local _ddPopup      = nil
---@type Frame|nil
local _ddPopupInner = nil
---@type ScrollFrame|nil
local _ddPopupSF    = nil
---@type Frame|nil
local _ddThumbTrack = nil
---@type Texture|nil
local _ddThumb      = nil
---@type Button[]
local _ddItems      = {}

local function UpdateDDThumb()
    local maxScroll = _ddPopupSF:GetVerticalScrollRange()
    if not maxScroll or maxScroll <= 0 then _ddThumbTrack:Hide(); return end
    _ddThumbTrack:Show()
    local trackH   = _ddThumbTrack:GetHeight()
    local viewH    = _ddPopupSF:GetHeight()
    local totalH   = viewH + maxScroll
    local ratio    = viewH / totalH
    local tH       = math.max(16, math.floor(ratio * trackH))
    local scrolled = _ddPopupSF:GetVerticalScroll() / maxScroll
    local tY       = -math.floor((trackH - tH) * scrolled)
    _ddThumb:ClearAllPoints()
    _ddThumb:SetHeight(tH)
    _ddThumb:SetPoint("TOP", _ddThumbTrack, "TOP", 0, tY)
end

local function EnsureDDPopup()
    if _ddPopup then return end
    local menu = CreateFrame("Frame", nil, UIParent)
    menu:SetFrameStrata("FULLSCREEN_DIALOG")
    menu:SetFrameLevel(400)
    menu:SetClampedToScreen(true)
    menu:EnableMouse(true)
    menu:Hide()
    zSF_FillBG(menu, 0.06, 0.06, 0.06, 0.97)
    -- Border colour is applied per-open via zSF_DrawBorder; this seeds the
    -- _zsfBorder cache so subsequent per-open tint updates reuse the textures.
    zSF_DrawBorder(menu, ZSF.COLOR_BORDER[1], ZSF.COLOR_BORDER[2], ZSF.COLOR_BORDER[3], ZSF.COLOR_BORDER[4])

    local inner = CreateFrame("Frame", nil, menu)

    local sf = CreateFrame("ScrollFrame", nil, menu)
    sf:SetPoint("TOPLEFT",     menu, "TOPLEFT",     0, -DD_MENU_PAD)
    sf:SetPoint("BOTTOMRIGHT", menu, "BOTTOMRIGHT", 0,  DD_MENU_PAD)
    sf:SetScrollChild(inner)
    sf:SetFrameLevel(menu:GetFrameLevel() + 1)
    sf:EnableMouseWheel(true)

    local thumbTrack = CreateFrame("Frame", nil, sf)
    thumbTrack:SetWidth(3)
    thumbTrack:SetPoint("TOPRIGHT",    sf, "TOPRIGHT",    -2, -2)
    thumbTrack:SetPoint("BOTTOMRIGHT", sf, "BOTTOMRIGHT", -2,  2)
    thumbTrack:SetFrameLevel(sf:GetFrameLevel() + 2)
    zSF_FillBG(thumbTrack, 1, 1, 1, 0.04)
    thumbTrack:Hide()

    local thumb = thumbTrack:CreateTexture(nil, "OVERLAY")
    thumb:SetWidth(3)
    thumb:SetColorTexture(1, 1, 1, 0.30)

    sf:SetScript("OnScrollRangeChanged", function() UpdateDDThumb() end)
    sf:SetScript("OnVerticalScroll",     function() UpdateDDThumb() end)
    sf:SetScript("OnMouseWheel", function(_, delta)
        local cur = sf:GetVerticalScroll()
        sf:SetVerticalScroll(math.max(0, math.min(sf:GetVerticalScrollRange(), cur - delta * 20)))
    end)

    _ddPopup      = menu
    _ddPopupInner = inner
    _ddPopupSF    = sf
    _ddThumbTrack = thumbTrack
    _ddThumb      = thumb
end

local function CloseOpenMenu()
    if _openMenu then
        _openMenu:Hide()
        _openMenu = nil
    end
    _ddPopupOwner = nil
    if _ddCatcher then _ddCatcher:Hide() end
end

--- Build item buttons inside the shared popup for the current owner's values.
---@param PR        number
---@param PG        number
---@param PB        number
---@param getValue  fun(): string|nil
---@param getValues fun(): table, table|nil
---@param decorator fun(item: Button, key: string)|nil
---@param onPick    fun(key: string, display: string)|nil
---@return number contentH  Total pixel height of all built items.
local function BuildDDItems(PR, PG, PB, getValue, getValues, decorator, onPick)
    for _, it in ipairs(_ddItems) do it:Hide(); it:SetParent(nil) end
    _ddItems = {}

    local values, order = getValues()
    if not values then values = {} end

    -- Build key order: use explicit order if provided, else table keys.
    local keyList = order
    if not keyList then
        keyList = {}
        for k in pairs(values) do keyList[#keyList + 1] = k end
        table.sort(keyList, function(a, b) return tostring(a) < tostring(b) end)
    end

    local SEL_ALPHA = 0.12
    local HL_ALPHA  = 0.22
    local yOff = 0
    for _, key in ipairs(keyList) do
        local displayText = values[key]
        if displayText then
            local item = CreateFrame("Button", nil, _ddPopupInner)
            item:SetHeight(DD_ITEM_H)
            item:SetPoint("TOPLEFT",  _ddPopupInner, "TOPLEFT",  1, -yOff)
            item:SetPoint("TOPRIGHT", _ddPopupInner, "TOPRIGHT", -1, -yOff)
            item:SetFrameLevel(_ddPopup:GetFrameLevel() + 3)

            local hl = item:CreateTexture(nil, "BACKGROUND")
            hl:SetAllPoints()
            hl:SetColorTexture(PR, PG, PB, 0)

            local lbl = item:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
            lbl:SetText(tostring(displayText))
            lbl:SetPoint("LEFT", item, "LEFT", 8, 0)
            lbl:SetPoint("RIGHT", item, "RIGHT", -8, 0)
            lbl:SetJustifyH("LEFT")
            lbl:SetTextColor(unpack(ZSF.COLOR_LABEL))

            item._key = key
            item._hl  = hl
            item._lbl = lbl

            -- Highlight current selection.
            if key == getValue() then
                hl:SetColorTexture(PR, PG, PB, SEL_ALPHA)
                lbl:SetTextColor(PR, PG, PB, 1)
            end

            item:SetScript("OnEnter", function()
                hl:SetColorTexture(PR, PG, PB, HL_ALPHA)
                lbl:SetTextColor(1, 1, 1, 1)
            end)
            item:SetScript("OnLeave", function()
                local cur = getValue()
                hl:SetColorTexture(PR, PG, PB, (item._key == cur) and SEL_ALPHA or 0)
                lbl:SetTextColor(
                    (item._key == cur) and PR  or ZSF.COLOR_LABEL[1],
                    (item._key == cur) and PG  or ZSF.COLOR_LABEL[2],
                    (item._key == cur) and PB  or ZSF.COLOR_LABEL[3], 1)
            end)
            item:SetScript("OnClick", function()
                if onPick then onPick(item._key, tostring(displayText)) end
                CloseOpenMenu()
            end)

            if decorator then decorator(item, key) end

            _ddItems[#_ddItems + 1] = item
            yOff = yOff + DD_ITEM_H
        end
    end

    _ddPopupInner:SetHeight(yOff)
    return yOff
end

--- Create a dropdown-style button shell: dark bg, border, text label, down-arrow.
--- Caller is responsible for positioning the returned button.
---@param parent  Frame
---@param w       number  Pixel width.
---@param primary table   { r, g, b }
---@return Button     btn   The button frame.
---@return FontString text  Left-aligned label inside the button.
---@return Texture   arrow  The down-arrow graphic.
function zSF_MakeDropButton(parent, w, primary)
    local btn = CreateFrame("Button", nil, parent)
    btn:SetSize(w, DD_H)
    btn:EnableMouse(true)
    zSF_FillBG(btn, 0.10, 0.10, 0.10, 1)
    zSF_DrawBorder(btn)

    local text = zSF_Text(btn, "GameFontHighlightSmall", "LEFT")
    text:SetTextColor(unpack(ZSF.COLOR_LABEL))
    text:SetPoint("LEFT",  btn, "LEFT",  6, 0)
    text:SetPoint("RIGHT", btn, "RIGHT", -22, 0)

    local arrow = btn:CreateTexture(nil, "ARTWORK")
    arrow:SetTexture(RENOWN_SHEET)
    arrow:SetTexCoord(RENOWN_L, RENOWN_R, RENOWN_T, RENOWN_B)
    arrow:SetSize(10, 14)
    arrow:SetRotation(math.pi / 2)
    arrow:SetDesaturated(true)
    arrow:SetVertexColor(primary[1], primary[2], primary[3], 1)
    arrow:SetPoint("RIGHT", btn, "RIGHT", -5, 0)

    return btn, text, arrow
end

--- Build a dropdown selector with a functional popup menu.
--- entry.values may be a table { key = displayString } or a function returning that table.
--- entry.order may be an array of keys; without it the menu uses default table order.
---@param cell      Frame        Parent frame (width must be set by caller).
---@param selText   string|nil   Currently selected display text.
---@param primary   table        { r, g, b }
---@return table result  { frame, text, arrow, OpenMenu, CloseMenu }
function zSF_BuildDropdown(cell, selText, primary)
    EnsureDDCatcher()

    local cellW = cell:GetWidth()

    -- ── Button shell ──────────────────────────────────────────────────────────
    local dd, ddText, arrow = zSF_MakeDropButton(cell, cellW, primary)
    dd:SetPoint("TOPLEFT", cell, "TOPLEFT", 0, 0)
    ddText:SetText(selText or "---")

    -- Per-instance state; filled via SetMenuAccessors / SetItemDecorator.
    ---@type fun(): string|nil
    local getValue  = function() return nil end
    ---@type fun(): table, table|nil
    local getValues = function() return {}, nil end
    ---@type fun(key: string, display: string)|nil
    local onPick    = nil
    ---@type fun(item: Button, key: string)|nil
    local itemDecorator = nil

    local PR, PG, PB = primary[1], primary[2], primary[3]

    local function OpenMenu()
        EnsureDDPopup()
        if _ddPopupOwner == dd then CloseOpenMenu(); return end
        CloseOpenMenu()

        local menuW = math.max(cellW, 120)
        _ddPopup:SetWidth(menuW)
        _ddPopupInner:SetWidth(menuW)

        -- Update popup border to this dropdown's primary colour (reuses cached textures).
        zSF_DrawBorder(_ddPopup, PR, PG, PB, 0.65)

        local contentH = BuildDDItems(PR, PG, PB, getValue, getValues, itemDecorator, onPick)
        local menuH    = math.min(contentH + DD_MENU_PAD * 2, DD_MENU_MAX_H)
        _ddPopup:SetHeight(menuH)

        -- Scroll so the selected item is visible, centred if possible.
        local curKey = getValue()
        local selIdx = nil
        for i, it in ipairs(_ddItems) do
            if it._key == curKey then selIdx = i; break end
        end
        if selIdx then
            local itemTop   = (selIdx - 1) * DD_ITEM_H
            local viewH     = menuH - DD_MENU_PAD * 2
            local centred   = itemTop - math.floor((viewH - DD_ITEM_H) / 2)
            local maxScroll = math.max(0, contentH - viewH)
            _ddPopupSF:SetVerticalScroll(math.max(0, math.min(maxScroll, centred)))
        else
            _ddPopupSF:SetVerticalScroll(0)
        end

        -- Position below the button; flip up if near screen bottom.
        _ddPopup:ClearAllPoints()
        local _, btnY = dd:GetCenter()
        if btnY and (btnY - (dd:GetHeight() / 2) - menuH) < 10 then
            _ddPopup:SetPoint("BOTTOMLEFT", dd, "TOPLEFT", 0, 2)
        else
            _ddPopup:SetPoint("TOPLEFT", dd, "BOTTOMLEFT", 0, -2)
        end

        _ddPopup:Show()
        _ddPopupOwner = dd
        _openMenu     = _ddPopup
        UpdateDDThumb()

        _ddCatcher:SetFrameLevel(396)
        _ddCatcher:SetScript("OnMouseDown", function() CloseOpenMenu() end)
        _ddCatcher:Show()
        _ddPopup:SetFrameLevel(400)
    end

    dd:SetScript("OnClick", function() OpenMenu() end)

    -- Hover tint on the button itself.
    dd:SetScript("OnEnter", function()
        zSF_DrawBorder(dd, ZSF.COLOR_BORDER[1], ZSF.COLOR_BORDER[2], ZSF.COLOR_BORDER[3], 0.80)
    end)
    dd:SetScript("OnLeave", function()
        zSF_DrawBorder(dd)
    end)

    cell:SetHeight(DD_H)

    return {
        frame    = dd,
        text     = ddText,
        arrow    = arrow,
        SetMenuAccessors = function(gv, gvs, cb)
            getValue  = gv
            getValues = gvs
            onPick    = cb
        end,
        --- Set a decorator called after each item is built when the menu opens.
        ---@param fn fun(item: Button, key: string)
        SetItemDecorator = function(fn) itemDecorator = fn end,
        OpenMenu  = OpenMenu,
        CloseMenu = CloseOpenMenu,
    }
end

-- ── Custom color picker ───────────────────────────────────────────────────────
--
-- A self-contained RGBA picker that does NOT use ColorPickerFrame.
-- Features: gradient hue/sat canvas, lightness bar, alpha bar (optional),
--           R/G/B/A numeric inputs, hex input, preview swatches, optional reset.
--
-- zSF_ShowColorPicker(config)
--   config.r, .g, .b          initial colour (0-1)
--   config.a                  initial alpha (0-1); provide to show alpha bar
--   config.hasAlpha   bool    show alpha controls even if .a is nil
--   config.onChange   fn(r,g,b,a)  live callback while dragging / typing
--   config.onAccept   fn(r,g,b,a)  called when OK is clicked
--   config.onCancel   fn(r,g,b,a)  called when Cancel is clicked (original values)
--   config.resetFunction fn()      optional; shows a Reset button that calls it

local _cpInstances = {}

-- Convert HSV (0-1 each) to RGB (0-1 each).
local function HSVtoRGB(h, s, v)
    if s == 0 then return v, v, v end
    local i  = math.floor(h * 6)
    local f  = h * 6 - i
    local p  = v * (1 - s)
    local q  = v * (1 - f * s)
    local t  = v * (1 - (1 - f) * s)
    local m  = i % 6
    if m == 0 then return v, t, p
    elseif m == 1 then return q, v, p
    elseif m == 2 then return p, v, t
    elseif m == 3 then return p, q, v
    elseif m == 4 then return t, p, v
    else               return v, p, q end
end

-- Convert RGB to HSV; returns h,s,v all 0-1.
local function RGBtoHSV(r, g, b)
    local mx = math.max(r, g, b)
    local mn = math.min(r, g, b)
    local d  = mx - mn
    local v  = mx
    local s  = (mx == 0) and 0 or (d / mx)
    local h  = 0
    if d ~= 0 then
        if mx == r then
            h = ((g - b) / d) % 6
        elseif mx == g then
            h = (b - r) / d + 2
        else
            h = (r - g) / d + 4
        end
        h = h / 6
    end
    return h, s, v
end

-- Parse a 6-char hex string to r,g,b 0-1.  Returns nil on bad input.
local function HexToRGB(hex)
    if not hex or #hex < 6 then return nil end
    local rs = hex:sub(1,2); local gs = hex:sub(3,4); local bs = hex:sub(5,6)
    local r = tonumber(rs, 16); local g = tonumber(gs, 16); local b = tonumber(bs, 16)
    if not (r and g and b) then return nil end
    return r / 255, g / 255, b / 255
end

local function RGBtoHex(r, g, b)
    return string.format("%02x%02x%02x",
        math.floor(r * 255 + 0.5),
        math.floor(g * 255 + 0.5),
        math.floor(b * 255 + 0.5))
end

-- Clamp helper.
local function Clamp01(v) return math.max(0, math.min(1, v)) end

local CP_W         = 300   -- total frame width
local CP_CANVAS_W  = 200   -- SV gradient canvas width
local CP_CANVAS_H  = 150   -- SV gradient canvas height
local CP_BAR_H     = 14    -- hue / alpha bar height
local CP_BAR_W     = CP_CANVAS_W
local CP_PAD       = 10    -- internal padding
local CP_INPUT_W   = 40    -- numeric input width
local CP_INPUT_H   = 18
local CP_HEX_W     = 50
local CP_SWATCH_H  = 20
local CP_BTN_W     = 65
local CP_BTN_H     = 20

---@param addonName string|nil  Addon name for per-addon frame isolation; nil uses the shared default.
local function MakeCPInstance(addonName)
    local key = addonName or "_default"
    if _cpInstances[key] then return _cpInstances[key] end

    -- Per-instance picker state.
    local _cpH, _cpS, _cpV, _cpA
    local _cpOrigR, _cpOrigG, _cpOrigB, _cpOrigA
    local _cpOnChange, _cpOnAccept, _cpOnCancel, _cpResetFn
    local _cpHasAlpha = false

    -- ── Outer frame ──────────────────────────────────────────────────────────
    local frameName = addonName and ("zSFColorPickerFrame_" .. addonName) or "zSFColorPickerFrame"
    local f = CreateFrame("Frame", frameName, UIParent)
    f:SetSize(CP_W, 10)   -- height set dynamically
    f:SetPoint("CENTER")
    f:SetFrameStrata("FULLSCREEN_DIALOG")
    f:SetFrameLevel(500)
    f:SetClampedToScreen(true)
    f:SetMovable(true)
    f:EnableMouse(true)
    f:Hide()

    zSF_FillBG(f, 0.05, 0.05, 0.05, 0.97)

    -- Drag region across the top.
    local grabBar = CreateFrame("Frame", nil, f)
    grabBar:SetHeight(20)
    grabBar:SetPoint("TOPLEFT",  f, "TOPLEFT",  0, 0)
    grabBar:SetPoint("TOPRIGHT", f, "TOPRIGHT", 0, 0)
    grabBar:EnableMouse(true)
    grabBar:SetScript("OnMouseDown", function() f:StartMoving() end)
    grabBar:SetScript("OnMouseUp",   function() f:StopMovingOrSizing() end)

    local titleFS = f:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    titleFS:SetPoint("TOP", f, "TOP", 0, -5)
    titleFS:SetTextColor(unpack(ZSF.COLOR_HEADER_TEXT))

    -- ── SV canvas ─────────────────────────────────────────────────────────────
    local canvas = CreateFrame("Frame", nil, f)
    canvas:SetSize(CP_CANVAS_W, CP_CANVAS_H)
    canvas:SetPoint("TOPLEFT", f, "TOPLEFT", CP_PAD, -(CP_PAD + 20))
    canvas:EnableMouse(true)

    -- Four-corner gradient: white→hue (top), black (bottom), white (left).
    -- We use a layered approach: full-saturation hue base, then white overlay
    -- (HORIZONTAL), then black overlay (VERTICAL from bottom).
    local canvasHue = canvas:CreateTexture(nil, "BACKGROUND")
    canvasHue:SetAllPoints()
    canvasHue:SetColorTexture(1, 0, 0, 1)   -- updated via SetGradient

    local canvasWhite = canvas:CreateTexture(nil, "BORDER")
    canvasWhite:SetAllPoints()
    canvasWhite:SetColorTexture(1, 1, 1, 1)
    zSF_DisablePixelSnap(canvasWhite)
    canvasWhite:SetGradient("HORIZONTAL", CreateColor(1,1,1,1), CreateColor(1,1,1,0))

    local canvasBlack = canvas:CreateTexture(nil, "ARTWORK")
    canvasBlack:SetAllPoints()
    canvasBlack:SetColorTexture(0, 0, 0, 1)
    zSF_DisablePixelSnap(canvasBlack)
    -- WoW VERTICAL gradient: first arg = bottom, second = top.
    -- Black at bottom (V=0 / dark), transparent at top (V=1 / bright).
    canvasBlack:SetGradient("VERTICAL", CreateColor(0,0,0,1), CreateColor(0,0,0,0))

    -- Canvas cursor (crosshair dot).
    local cursor = canvas:CreateTexture(nil, "OVERLAY")
    cursor:SetSize(8, 8)
    cursor:SetColorTexture(1, 1, 1, 1)

    local function UpdateCanvasCursor()
        local x = math.floor(_cpS * CP_CANVAS_W + 0.5)
        local y = math.floor(_cpV * CP_CANVAS_H + 0.5)
        cursor:ClearAllPoints()
        cursor:SetPoint("CENTER", canvas, "BOTTOMLEFT", x, y)
    end

    local function UpdateCanvasHue()
        local hr, hg, hb = HSVtoRGB(_cpH, 1, 1)
        canvasHue:SetColorTexture(hr, hg, hb, 1)
    end

    -- ── Hue bar ───────────────────────────────────────────────────────────────
    local hueBar = CreateFrame("Frame", nil, f)
    hueBar:SetSize(CP_BAR_W, CP_BAR_H)
    hueBar:SetPoint("TOPLEFT", canvas, "BOTTOMLEFT", 0, -6)
    hueBar:EnableMouse(true)

    -- Seven-stop hue gradient via textures stacked horizontally.
    -- Use relative anchoring so fractional widths don't leave pixel gaps.
    local HUE_STOPS = { {1,0,0},{1,1,0},{0,1,0},{0,1,1},{0,0,1},{1,0,1},{1,0,0} }
    local prevSeg = nil
    for i = 1, 6 do
        local seg = hueBar:CreateTexture(nil, "ARTWORK")
        seg:SetHeight(CP_BAR_H)
        if i == 1 then
            seg:SetPoint("TOPLEFT", hueBar, "TOPLEFT", 0, 0)
        else
            seg:SetPoint("TOPLEFT", prevSeg, "TOPRIGHT", 0, 0)
        end
        if i < 6 then
            seg:SetWidth(math.floor(CP_BAR_W / 6 + 0.5))
        else
            -- Last segment fills any remaining width from rounding.
            seg:SetPoint("TOPRIGHT", hueBar, "TOPRIGHT", 0, 0)
        end
        local cs, ce = HUE_STOPS[i], HUE_STOPS[i + 1]
        seg:SetColorTexture(1, 1, 1, 1)
        zSF_DisablePixelSnap(seg)
        seg:SetGradient("HORIZONTAL", CreateColor(cs[1], cs[2], cs[3], 1), CreateColor(ce[1], ce[2], ce[3], 1))
        prevSeg = seg
    end

    local hueCursor = hueBar:CreateTexture(nil, "OVERLAY")
    hueCursor:SetSize(3, CP_BAR_H + 2)
    hueCursor:SetColorTexture(1, 1, 1, 1)

    local function UpdateHueCursor()
        hueCursor:ClearAllPoints()
        hueCursor:SetPoint("CENTER", hueBar, "LEFT", math.floor(_cpH * CP_BAR_W + 0.5), 0)
    end

    -- ── Alpha bar (shown only when hasAlpha) ──────────────────────────────────
    local alphaBar = CreateFrame("Frame", nil, f)
    alphaBar:SetSize(CP_BAR_W, CP_BAR_H)
    alphaBar:SetPoint("TOPLEFT", hueBar, "BOTTOMLEFT", 0, -4)
    alphaBar:EnableMouse(true)
    alphaBar:Hide()

    -- Background for transparency indication; fully transparent end now shows black.
    local abChecker = alphaBar:CreateTexture(nil, "BACKGROUND")
    abChecker:SetAllPoints()
    abChecker:SetColorTexture(0, 0, 0, 1)

    local abFill = alphaBar:CreateTexture(nil, "ARTWORK")
    abFill:SetAllPoints()
    abFill:SetColorTexture(1, 1, 1, 1)   -- resized via SetGradient

    local alphaCursor = alphaBar:CreateTexture(nil, "OVERLAY")
    alphaCursor:SetSize(3, CP_BAR_H + 2)
    alphaCursor:SetColorTexture(1, 1, 1, 1)

    local function UpdateAlphaBarFill()
        local r, g, b = HSVtoRGB(_cpH, _cpS, _cpV)
        local left  = CreateColor(r, g, b, 0)
        local right = CreateColor(r, g, b, 1)
        abFill:SetGradient("HORIZONTAL", left, right)
        zSF_DisablePixelSnap(abFill)
    end

    local function UpdateAlphaCursor()
        alphaCursor:ClearAllPoints()
        alphaCursor:SetPoint("CENTER", alphaBar, "LEFT", math.floor(_cpA * CP_BAR_W + 0.5), 0)
    end

    -- ── Preview swatches ──────────────────────────────────────────────────────
    local swatchRow = CreateFrame("Frame", nil, f)
    local SWATCH_LABEL_H = 14
    local SWATCH_PADDING = 8
    swatchRow:SetSize(CP_CANVAS_W, CP_SWATCH_H + SWATCH_LABEL_H)

    local swatchOrig = swatchRow:CreateTexture(nil, "ARTWORK")
    swatchOrig:SetSize((CP_CANVAS_W - SWATCH_PADDING) / 2, CP_SWATCH_H)
    swatchOrig:SetPoint("TOPLEFT", swatchRow, "TOPLEFT", 0, 0)

    local swatchCur = swatchRow:CreateTexture(nil, "ARTWORK")
    swatchCur:SetSize((CP_CANVAS_W - SWATCH_PADDING) / 2, CP_SWATCH_H)
    swatchCur:SetPoint("TOPRIGHT", swatchRow, "TOPRIGHT", 0, 0)

    local newLabel = swatchRow:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    newLabel:SetText("New")
    newLabel:SetTextColor(0.75, 0.75, 0.75, 1)
    newLabel:SetPoint("TOP", swatchCur, "BOTTOM", 0, -2)

    local currentLabel = swatchRow:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    currentLabel:SetText("Current")
    currentLabel:SetTextColor(0.75, 0.75, 0.75, 1)
    currentLabel:SetPoint("TOP", swatchOrig, "BOTTOM", 0, -2)

    local function UpdateSwatchCur()
        local r, g, b = HSVtoRGB(_cpH, _cpS, _cpV)
        swatchCur:SetColorTexture(r, g, b, 1)
        zSF_DisablePixelSnap(swatchCur)
    end

    -- ── Input row ─────────────────────────────────────────────────────────────
    -- R / G / B / A inputs and a hex box.
    -- Placed to the right of the canvas area.
    local inputX = CP_PAD + CP_CANVAS_W + CP_PAD
    local inputAreaW = CP_W - inputX - CP_PAD

    local function MakeInput(labelStr, yOff)
        local lbl = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        lbl:SetText(labelStr)
        lbl:SetTextColor(unpack(ZSF.COLOR_DESC))
        lbl:SetPoint("TOPLEFT", f, "TOPLEFT", inputX, -(CP_PAD + 20 + yOff))

        local box = CreateFrame("EditBox", nil, f)
        box:SetSize(CP_INPUT_W, CP_INPUT_H)
        box:SetPoint("TOPLEFT", f, "TOPLEFT", inputX + 16, -(CP_PAD + 20 + yOff - 1))
        box:SetAutoFocus(false)
        box:SetFontObject("GameFontHighlightSmall")
        box:SetJustifyH("RIGHT")
        box:SetMaxLetters(3)
        box:SetNumeric(true)
        box:SetTextColor(unpack(ZSF.COLOR_LABEL))
        box:SetTextInsets(2, 2, 1, 1)
        zSF_FillBG(box, 0.08, 0.08, 0.08, 1)
        zSF_DrawBorder(box, 0.30, 0.30, 0.30, 0.65)
        box:SetScript("OnEditFocusGained", function(self) self:HighlightText() end)
        box:SetScript("OnEditFocusLost",   function(self) self:HighlightText(0, 0) end)
        return lbl, box
    end

    local _, boxR = MakeInput("R", 0)
    local _, boxG = MakeInput("G", CP_INPUT_H + 4)
    local _, boxB = MakeInput("B", (CP_INPUT_H + 4) * 2)

    -- Alpha input (shown conditionally); store the label so it can be hidden in RGB mode.
    local lblA, boxA = MakeInput("A", (CP_INPUT_H + 4) * 3)
    boxA:SetMaxLetters(3)

    -- Hex input (position set inline with swatchRow by RelayoutFrame).
    local hexLbl = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    hexLbl:SetText("#")
    hexLbl:SetTextColor(unpack(ZSF.COLOR_DESC))

    local boxHex = CreateFrame("EditBox", nil, f)
    boxHex:SetSize(CP_HEX_W, CP_INPUT_H)
    boxHex:SetAutoFocus(false)
    boxHex:SetFontObject("GameFontHighlightSmall")
    boxHex:SetJustifyH("LEFT")
    boxHex:SetMaxLetters(6)
    boxHex:SetTextColor(unpack(ZSF.COLOR_LABEL))
    boxHex:SetTextInsets(2, 2, 1, 1)
    zSF_FillBG(boxHex, 0.08, 0.08, 0.08, 1)
    zSF_DrawBorder(boxHex, 0.30, 0.30, 0.30, 0.65)
    boxHex:SetScript("OnEditFocusGained", function(self) self:HighlightText() end)
    boxHex:SetScript("OnEditFocusLost",   function(self) self:HighlightText(0, 0) end)

    -- ── Buttons ───────────────────────────────────────────────────────────────
    local btnRow = CreateFrame("Frame", nil, f)
    btnRow:SetHeight(CP_BTN_H)
    btnRow:SetPoint("BOTTOMLEFT",  f, "BOTTOMLEFT",  CP_PAD,  CP_PAD)
    btnRow:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -CP_PAD, CP_PAD)

    local btnOK = zSF_BuildActionButton(btnRow, OKAY or "OK", nil, CP_BTN_W, { 0.5, 0.8, 0.5 })
    btnOK:SetPoint("RIGHT", btnRow, "RIGHT", 0, 0)

    local btnCancel = zSF_BuildActionButton(btnRow, CANCEL or "Cancel", nil, CP_BTN_W, { 0.8, 0.4, 0.4 })
    btnCancel:SetPoint("LEFT", btnRow, "LEFT", 0, 0)

    -- Reset and Class are parented to f and stacked below the alpha input in
    -- the right column; RelayoutFrame sets their positions each open.
    local btnReset = zSF_BuildActionButton(f, RESET or "Reset", nil, CP_BTN_W, { 0.6, 0.6, 0.6 })
    btnReset:Hide()

    local btnClass = zSF_BuildActionButton(f, "Class", nil, CP_BTN_W, { 0.4, 0.6, 0.9 })

    -- ── Sync helpers ─────────────────────────────────────────────────────────

    local _suppressInputSync = false

    local function SyncInputs()
        _suppressInputSync = true
        local r, g, b = HSVtoRGB(_cpH, _cpS, _cpV)
        boxR:SetText(tostring(math.floor(r * 255 + 0.5)))
        boxG:SetText(tostring(math.floor(g * 255 + 0.5)))
        boxB:SetText(tostring(math.floor(b * 255 + 0.5)))
        boxA:SetText(tostring(math.floor(_cpA * 255 + 0.5)))
        boxHex:SetText(RGBtoHex(r, g, b))
        _suppressInputSync = false
    end

    local function FireChange()
        local r, g, b = HSVtoRGB(_cpH, _cpS, _cpV)
        if _cpOnChange then _cpOnChange(r, g, b, _cpA) end
    end

    local function FullRefresh()
        UpdateCanvasHue()
        UpdateCanvasCursor()
        UpdateHueCursor()
        UpdateAlphaBarFill()
        UpdateAlphaCursor()
        UpdateSwatchCur()
        SyncInputs()
        FireChange()
    end

    -- ── Canvas drag ───────────────────────────────────────────────────────────
    local canvasDragging = false

    local function ApplyCanvasPos(mx, my)
        local left   = canvas:GetLeft()
        local bottom = canvas:GetBottom()
        local scale  = canvas:GetEffectiveScale()
        if not left then return end
        local sx = Clamp01((mx / scale - left)  / CP_CANVAS_W)
        local sy = Clamp01((my / scale - bottom) / CP_CANVAS_H)
        _cpS = sx
        _cpV = sy
        FullRefresh()
    end

    canvas:SetScript("OnMouseDown", function(self, btn)
        if btn ~= "LeftButton" then return end
        canvasDragging = true
        ApplyCanvasPos(GetCursorPosition())
        self:SetScript("OnUpdate", function()
            if canvasDragging then ApplyCanvasPos(GetCursorPosition()) end
        end)
    end)
    canvas:SetScript("OnMouseUp", function(self, btn)
        if btn ~= "LeftButton" then return end
        canvasDragging = false
        self:SetScript("OnUpdate", nil)
    end)

    -- ── Hue bar drag ──────────────────────────────────────────────────────────
    local hueDragging = false

    local function ApplyHuePos(mx)
        local left  = hueBar:GetLeft()
        local scale = hueBar:GetEffectiveScale()
        if not left then return end
        _cpH = Clamp01((mx / scale - left) / CP_BAR_W)
        FullRefresh()
    end

    hueBar:SetScript("OnMouseDown", function(self, btn)
        if btn ~= "LeftButton" then return end
        hueDragging = true
        ApplyHuePos(GetCursorPosition())
        self:SetScript("OnUpdate", function()
            if hueDragging then ApplyHuePos(GetCursorPosition()) end
        end)
    end)
    hueBar:SetScript("OnMouseUp", function(self, btn)
        if btn ~= "LeftButton" then return end
        hueDragging = false
        self:SetScript("OnUpdate", nil)
    end)

    -- ── Alpha bar drag ────────────────────────────────────────────────────────
    local alphaDragging = false

    local function ApplyAlphaPos(mx)
        local left  = alphaBar:GetLeft()
        local scale = alphaBar:GetEffectiveScale()
        if not left then return end
        _cpA = Clamp01((mx / scale - left) / CP_BAR_W)
        UpdateAlphaCursor()
        SyncInputs()
        FireChange()
    end

    alphaBar:SetScript("OnMouseDown", function(self, btn)
        if btn ~= "LeftButton" then return end
        alphaDragging = true
        ApplyAlphaPos(GetCursorPosition())
        self:SetScript("OnUpdate", function()
            if alphaDragging then ApplyAlphaPos(GetCursorPosition()) end
        end)
    end)
    alphaBar:SetScript("OnMouseUp", function(self, btn)
        if btn ~= "LeftButton" then return end
        alphaDragging = false
        self:SetScript("OnUpdate", nil)
    end)

    -- ── Input commit handlers ─────────────────────────────────────────────────
    local function CommitRGB()
        if _suppressInputSync then return end
        local r = Clamp01((tonumber(boxR:GetText()) or 0) / 255)
        local g = Clamp01((tonumber(boxG:GetText()) or 0) / 255)
        local b = Clamp01((tonumber(boxB:GetText()) or 0) / 255)
        _cpH, _cpS, _cpV = RGBtoHSV(r, g, b)
        FullRefresh()
    end

    local function CommitAlpha()
        if _suppressInputSync then return end
        _cpA = Clamp01((tonumber(boxA:GetText()) or 255) / 255)
        UpdateAlphaCursor()
        FireChange()
    end

    local function CommitHex()
        if _suppressInputSync then return end
        local r, g, b = HexToRGB(boxHex:GetText())
        if r then
            _cpH, _cpS, _cpV = RGBtoHSV(r, g, b)
            FullRefresh()
        end
    end

    for _, box in ipairs({ boxR, boxG, boxB }) do
        box:SetScript("OnEnterPressed", function(self) self:ClearFocus(); CommitRGB() end)
        box:SetScript("OnEscapePressed", function(self) self:ClearFocus(); SyncInputs() end)
        box:SetScript("OnEditFocusLost", function(self) self:HighlightText(0,0); CommitRGB() end)
    end
    boxA:SetScript("OnEnterPressed", function(self) self:ClearFocus(); CommitAlpha() end)
    boxA:SetScript("OnEscapePressed", function(self) self:ClearFocus(); SyncInputs() end)
    boxA:SetScript("OnEditFocusLost", function(self) self:HighlightText(0,0); CommitAlpha() end)
    boxHex:SetScript("OnEnterPressed", function(self) self:ClearFocus(); CommitHex() end)
    boxHex:SetScript("OnEscapePressed", function(self) self:ClearFocus(); SyncInputs() end)
    boxHex:SetScript("OnEditFocusLost", function(self) self:HighlightText(0,0); CommitHex() end)

    -- Tab order: R → G → B → Hex → (A if visible) → back to R.
    boxR:SetScript("OnTabPressed", function() boxG:SetFocus() end)
    boxG:SetScript("OnTabPressed", function() boxB:SetFocus() end)
    boxB:SetScript("OnTabPressed", function() boxHex:SetFocus() end)
    boxHex:SetScript("OnTabPressed", function()
        if _cpHasAlpha then boxA:SetFocus() else boxR:SetFocus() end
    end)
    boxA:SetScript("OnTabPressed", function() boxR:SetFocus() end)

    -- ── Dynamic layout (recompute heights based on hasAlpha) ──────────────────
    local function RelayoutFrame()
        -- swatchRow sits below the alpha bar (if shown) or hue bar.
        local anchorBelow = _cpHasAlpha and alphaBar or hueBar
        swatchRow:ClearAllPoints()
        swatchRow:SetPoint("TOPLEFT", anchorBelow, "BOTTOMLEFT", 0, -6)
        swatchRow:SetWidth(CP_CANVAS_W)

        -- Hex sits inline with the swatch row, in the right column.
        hexLbl:ClearAllPoints()
        hexLbl:SetPoint("TOPLEFT", swatchRow, "TOPLEFT", CP_CANVAS_W + CP_PAD, 2)
        boxHex:ClearAllPoints()
        boxHex:SetPoint("TOPLEFT", swatchRow, "TOPLEFT", CP_CANVAS_W + CP_PAD + 16, 1)

        -- Class and Reset stack below the last visible numeric input.
        local classAnchor = _cpHasAlpha and boxA or boxB
        btnClass:ClearAllPoints()
        btnClass:SetPoint("TOPLEFT", classAnchor, "BOTTOMLEFT", -16, -6)
        if _cpResetFn then
            btnReset:Show()
            btnReset:ClearAllPoints()
            btnReset:SetPoint("TOPLEFT", btnClass, "BOTTOMLEFT", 0, -4)
        else
            btnReset:Hide()
        end

        -- Button row at the bottom.
        btnRow:ClearAllPoints()
        btnRow:SetPoint("BOTTOMLEFT",  f, "BOTTOMLEFT",  CP_PAD,  CP_PAD)
        btnRow:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -CP_PAD, CP_PAD)

        -- Total frame height: from top pad + title(20) + canvas + hue + (alpha) + swatch + labels + gap + buttons.
        local extraAlpha = _cpHasAlpha and (CP_BAR_H + 4) or 0
        local totalH = CP_PAD + 20 + CP_PAD + CP_CANVAS_H + 6 + CP_BAR_H + extraAlpha + 6 + CP_SWATCH_H + SWATCH_LABEL_H + 8 + CP_BTN_H + CP_PAD
        f:SetHeight(totalH)
    end

    -- ── OK / Cancel ───────────────────────────────────────────────────────────
    btnOK:SetScript("OnClick", function()
        local r, g, b = HSVtoRGB(_cpH, _cpS, _cpV)
        Popup_Hide(f)
        if _cpOnAccept then _cpOnAccept(r, g, b, _cpA) end
    end)

    btnCancel:SetScript("OnClick", function()
        Popup_Hide(f)
        if _cpOnCancel then _cpOnCancel(_cpOrigR, _cpOrigG, _cpOrigB, _cpOrigA) end
    end)
    f:SetScript("OnHide", function()
        -- If closed without OK/Cancel (e.g. Escape key via UISpecialFrames),
        -- treat as cancel.
    end)

    -- Reset: apply the default, update the picker in-place (do not close).
    btnReset:SetScript("OnClick", function()
        if not _cpResetFn then return end
        local r, g, b, a = _cpResetFn()
        if r then
            _cpH, _cpS, _cpV = RGBtoHSV(r, g, b)
            if _cpHasAlpha and a then _cpA = a end
            FullRefresh()
        end
    end)

    btnClass:SetScript("OnClick", function()
        local classColor = RAID_CLASS_COLORS[select(2, UnitClass("player"))]
        if classColor then
            _cpH, _cpS, _cpV = RGBtoHSV(classColor.r, classColor.g, classColor.b)
            FullRefresh()
        end
    end)

    f._relayout      = RelayoutFrame
    f._alphaBar      = alphaBar
    f._boxA          = boxA
    f._lblA          = lblA
    f._swatchOrig    = swatchOrig
    f._swatchCur     = swatchCur
    f._fullRefresh   = FullRefresh
    f._titleFS       = titleFS

    table.insert(UISpecialFrames, frameName)

    ---@type { show: fun(config:table, themeColors:table|nil) }
    local inst = {}
    function inst.show(config, themeColors)
        _cpOnChange  = config.onChange
        _cpOnAccept  = config.onAccept
        _cpOnCancel  = config.onCancel
        _cpResetFn   = config.resetFunction
        _cpHasAlpha  = config.hasAlpha or (config.a ~= nil)

        local r = config.r or 1
        local g = config.g or 1
        local b = config.b or 1
        local a = config.a or 1
        _cpH, _cpS, _cpV = RGBtoHSV(r, g, b)
        _cpA = a

        _cpOrigR, _cpOrigG, _cpOrigB, _cpOrigA = r, g, b, a

        f._swatchOrig:SetColorTexture(r, g, b, 1)
        zSF_DisablePixelSnap(f._swatchOrig)

        if _cpHasAlpha then
            f._alphaBar:Show()
            f._boxA:Show()
            f._lblA:Show()
        else
            f._alphaBar:Hide()
            f._boxA:Hide()
            f._lblA:Hide()
        end

        f._titleFS:SetText(config.title or "")

        f._relayout()
        local colors = themeColors or {}
        local pri    = colors.primary   or ZSF.DEFAULT_PRIMARY
        local sec    = colors.secondary or ZSF.DEFAULT_SECONDARY
        zSF_DrawGradientBorder(f, pri, sec, sec, pri)
        f._fullRefresh()

        Popup_Show(f)
    end

    _cpInstances[key] = inst
    return inst
end

--- Open the custom color picker.
---@param config table
---  .r .g .b .a   initial values (0-1)
---  .hasAlpha  bool
---  .onChange  fn(r,g,b,a)
---  .onAccept  fn(r,g,b,a)
---  .onCancel  fn(r,g,b,a)
---  .resetFunction fn()  optional
---  .title  string  optional frame title
function zSF_ShowColorPicker(config)
    MakeCPInstance(nil).show(config, nil)
end

--- Build a colour swatch with optional alpha percentage display.
---@param cell    Frame   Parent frame (width must be set by caller).
---@param r       number  Red   (0–1).
---@param g       number  Green (0–1).
---@param b       number  Blue  (0–1).
---@param a       number|nil  Alpha (0–1). Shown as "xx%" if provided.
---@param primary table   { r, g, b } for the swatch border.
---@return table result  { frame=Frame, bg=Texture, alphaText=FontString|nil }
function zSF_BuildColorSwatch(cell, r, g, b, a, primary)
    local cellW = cell:GetWidth()

    local swatch = CreateFrame("Frame", nil, cell)
    swatch:SetSize(cellW, SWATCH_H)
    swatch:SetPoint("TOPLEFT", cell, "TOPLEFT", 0, 0)
    local swatchBg = zSF_FillBG(swatch, r or 1, g or 1, b or 1, 1)
    zSF_DrawBorder(swatch, primary[1], primary[2], primary[3], 0.65)

    local alphaText
    if a then
        alphaText = zSF_Text(swatch, "GameFontHighlightSmall", "RIGHT")
        alphaText:SetText(string.format("%.0f%%", (a or 1) * 100))
        alphaText:SetTextColor(unpack(ZSF.COLOR_DESC))
        alphaText:SetPoint("RIGHT", swatch, "RIGHT", -4, 0)
    end

    cell:SetHeight(SWATCH_H)
    return { frame = swatch, bg = swatchBg, alphaText = alphaText }
end

-- ── Reset / action button ────────────────────────────────────────────────────

--- Build a flat action button with optional icon.
---@param parent      Frame
---@param labelText   string
---@param iconAtlas   string|nil         Atlas name for a left-aligned icon (e.g. "UI-RefreshButton").
---@param width       number|nil         Button width (default 140).
---@param secondary   table              { r, g, b } for hover/press overlay.
---@param iconColor   table|false|nil    { r, g, b } to desaturate+tint the icon; false/nil = show raw.
---@param borderColor table|nil          { r, g, b } border colour; nil uses ZSF.COLOR_BORDER.
---@param rotateIcon  number|nil         Clockwise rotation in degrees applied to the icon (any value).
---@param flipIcon    "horizontal"|"vertical"|nil  Mirror the icon on the given axis.
---@return Button btn
function zSF_BuildActionButton(parent, labelText, iconAtlas, width, secondary, iconColor, borderColor, rotateIcon, flipIcon)
    width = width or 140

    local btn = CreateFrame("Button", nil, parent)
    btn:SetSize(width, 22)

    zSF_FillBG(btn, ZSF.COLOR_TAB_ACTIVE[1], ZSF.COLOR_TAB_ACTIVE[2],
               ZSF.COLOR_TAB_ACTIVE[3], ZSF.COLOR_TAB_ACTIVE[4])

    -- Hover/press overlay.
    local overlay = btn:CreateTexture(nil, "ARTWORK")
    overlay:SetAllPoints()
    overlay:SetColorTexture(secondary[1], secondary[2], secondary[3], 1)
    overlay:SetAlpha(0)

    if iconAtlas then
        local icon = btn:CreateTexture(nil, "ARTWORK")
        icon:SetSize(14, 14)
        icon:SetPoint("LEFT", btn, "LEFT", 5, 0)

        -- When flipping is requested we need raw UV control, so resolve the atlas
        -- manually instead of using SetAtlas, which doesn't expose its UVs.
        if flipIcon then
            local info = C_Texture.GetAtlasInfo(iconAtlas)
            if info then
                icon:SetTexture(info.file)
                local l, r = info.leftTexCoord,  info.rightTexCoord
                local t, b = info.topTexCoord,   info.bottomTexCoord
                if flipIcon == "horizontal" then
                    icon:SetTexCoord(r, l, t, b)
                else  -- "vertical"
                    icon:SetTexCoord(l, r, b, t)
                end
            else
                icon:SetAtlas(iconAtlas)  -- fallback if atlas lookup fails
            end
        else
            icon:SetAtlas(iconAtlas)
        end

        if iconColor then
            icon:SetDesaturated(true)
            icon:SetVertexColor(iconColor[1], iconColor[2], iconColor[3], 1)
        end
        if rotateIcon then
            icon:SetRotation(math.rad(rotateIcon))
        end
    end

    -- Border on top of the overlay.
    if borderColor then
        zSF_DrawBorder(btn, borderColor[1], borderColor[2], borderColor[3])
    else
        zSF_DrawBorder(btn)
    end

    btn:EnableMouse(true)
    btn:SetScript("OnEnter",     function() overlay:SetAlpha(0.25) end)
    btn:SetScript("OnLeave",     function() overlay:SetAlpha(0) end)
    btn:SetScript("OnMouseDown", function() overlay:SetAlpha(0.42) end)
    btn:SetScript("OnMouseUp",   function()
        overlay:SetAlpha(btn:IsMouseOver() and 0.25 or 0)
    end)

    local lbl = btn:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    lbl:SetText(labelText)
    lbl:SetPoint("CENTER")
    lbl:SetTextColor(unpack(ZSF.COLOR_LABEL))
    btn.label = lbl

    return btn
end

-- ── Close button ─────────────────────────────────────────────────────────────

--- Build the title-bar close button (desaturated LFG decline mark).
---@param parent  Frame
---@param secondary table  { r, g, b } normal colour.
---@return Button
function zSF_BuildCloseButton(parent, secondary)
    local btn = CreateFrame("Button", nil, parent)
    btn:SetSize(20, 20)
    btn:EnableMouse(true)

    local icon = btn:CreateTexture(nil, "ARTWORK")
    icon:SetTexture(zSF_AssetPath("close-button.png"))
    icon:SetAllPoints()
    icon:SetVertexColor(secondary[1], secondary[2], secondary[3], 1)

    btn:SetScript("OnEnter", function() icon:SetVertexColor(0.9, 0.15, 0.15, 1) end)
    btn:SetScript("OnLeave", function() icon:SetVertexColor(secondary[1], secondary[2], secondary[3], 1) end)

    return btn
end

-- ── Confirmation dialog ───────────────────────────────────────────────────────

local _confirmInstances = {}

---@param addonName string|nil  Addon name for per-addon frame isolation; nil uses the shared default.
local function MakeConfirmInstance(addonName)
    local key = addonName or "_default"
    if _confirmInstances[key] then return _confirmInstances[key] end

    -- Per-instance state.
    local _onAccept  = nil
    local _onRevert  = nil
    local _didAccept = false

    local frameName = addonName and ("zSFConfirmDialog_" .. addonName) or "zSFConfirmDialog"
    local dlg = CreateFrame("Frame", frameName, UIParent)
    dlg:SetSize(420, 150)
    dlg:SetPoint("CENTER")
    dlg:SetFrameStrata("FULLSCREEN_DIALOG")
    dlg:SetFrameLevel(600)
    dlg:SetClampedToScreen(true)
    dlg:Hide()
    table.insert(UISpecialFrames, frameName)

    zSF_FillBG(dlg, 0.04, 0.04, 0.04, 1)

    -- Placeholder Frame for the gradient border, rebuilt at show-time so it
    -- always uses the current theme colors.
    local bp = CreateFrame("Frame", nil, dlg)
    bp:SetAllPoints()
    bp:SetFrameLevel(dlg:GetFrameLevel() + 10)

    local msgText = dlg:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    msgText:SetWidth(380)
    msgText:SetJustifyH("CENTER")
    msgText:SetWordWrap(true)
    msgText:SetPoint("TOP", dlg, "TOP", 0, -24)
    msgText:SetTextColor(unpack(ZSF.COLOR_LABEL))

    -- Cancel – left side.
    local cancelBtn = zSF_BuildActionButton(dlg, CANCEL or "Cancel", nil, 130, { 0.6, 0.6, 0.6 })
    cancelBtn:SetPoint("BOTTOMLEFT", dlg, "BOTTOMLEFT", 24, 16)
    cancelBtn:SetScript("OnClick", function() Popup_Hide(dlg) end)

    -- Confirm – right side.
    local confirmBtn = zSF_BuildActionButton(dlg, OKAY or "OK", nil, 130, { 0.6, 0.6, 0.6 })
    confirmBtn:SetPoint("BOTTOMRIGHT", dlg, "BOTTOMRIGHT", -24, 16)
    confirmBtn:SetScript("OnClick", function()
        _didAccept = true
        Popup_Hide(dlg)
        if _onAccept then _onAccept() end
    end)

    -- Fire the revert callback whenever the dialog closes without the user confirming
    -- (Cancel button, Escape key via UISpecialFrames, or any other dismissal path).
    dlg:SetScript("OnHide", function()
        if not _didAccept and _onRevert then _onRevert() end
        _didAccept = false
        _onRevert  = nil
    end)

    ---@type { show: fun(text:string, onAccept:fun(), onRevert:fun()|nil, themeColors:table|nil) }
    local inst = {}
    function inst.show(text, onAccept, onRevert, themeColors)
        _onAccept  = onAccept
        _onRevert  = onRevert
        _didAccept = false
        msgText:SetText(text)
        local colors = themeColors or {}
        local pri    = colors.primary   or ZSF.DEFAULT_PRIMARY
        local sec    = colors.secondary or ZSF.DEFAULT_SECONDARY
        zSF_DrawGradientBorder(bp, pri, sec, sec, pri)
        Popup_Show(dlg)
    end

    _confirmInstances[key] = inst
    return inst
end

--- Show a modal confirmation prompt built entirely from zSettingsFrame primitives.
--- No external library dependencies. onAccept fires only if the user confirms.
--- onRevert fires if the user dismisses without confirming (Cancel, Escape, click-off).
---@param text     string    Message to display.
---@param onAccept fun()     Called when the user clicks OK.
---@param onRevert fun()|nil Called when the user dismisses without confirming.
function zSF_ShowConfirm(text, onAccept, onRevert)
    MakeConfirmInstance(nil).show(text, onAccept, onRevert, nil)
end

-- ── Input dialog ──────────────────────────────────────────────────────────────
-- Modal dialog with a scrollable multi-line EditBox for gathering text from the
-- user.  Call ctx:ShowInput(config) from a button handler.

local _inputInstances = {}

---@param addonName string|nil  Addon name for per-addon frame isolation; nil uses the shared default.
local function MakeInputInstance(addonName)
    local key = addonName or "_default"
    if _inputInstances[key] then return _inputInstances[key] end

    -- Per-instance state.
    local _onAccept  = nil
    local _onCancel  = nil
    local _didAccept = false

    local W       = 540
    local TITLE_H = 32
    local SEP_H   = 1
    local PAD     = 14
    local BODY_H  = 32   -- reserved for the instruction line (shown/hidden at call-time)
    local BOX_H   = 130
    local BTN_H   = 22
    local BTN_BOT = 12
    local dlgH    = TITLE_H + SEP_H + PAD + BODY_H + 6 + BOX_H + PAD + BTN_H + BTN_BOT

    local frameName = addonName and ("zSFInputDialog_" .. addonName) or "zSFInputDialog"
    local dlg = CreateFrame("Frame", frameName, UIParent)
    dlg:SetSize(W, dlgH)
    dlg._dialogW = W
    dlg._baseH   = dlgH - BOX_H
    dlg:SetPoint("CENTER")
    dlg:SetFrameStrata("FULLSCREEN_DIALOG")
    dlg:SetFrameLevel(600)
    dlg:SetClampedToScreen(true)
    dlg:SetMovable(true)
    dlg:EnableMouse(true)
    dlg:RegisterForDrag("LeftButton")
    dlg:SetScript("OnDragStart", function(self) self:StartMoving() end)
    dlg:SetScript("OnDragStop",  function(self) self:StopMovingOrSizing() end)
    dlg:Hide()
    table.insert(UISpecialFrames, frameName)

    zSF_FillBG(dlg, 0.04, 0.04, 0.04, 1)

    -- Draggable title bar.
    local titleBar = CreateFrame("Frame", nil, dlg)
    titleBar:SetPoint("TOPLEFT",  dlg, "TOPLEFT",  0, 0)
    titleBar:SetPoint("TOPRIGHT", dlg, "TOPRIGHT", 0, 0)
    titleBar:SetHeight(TITLE_H)
    titleBar:EnableMouse(true)
    titleBar:RegisterForDrag("LeftButton")
    titleBar:SetScript("OnDragStart", function() dlg:StartMoving() end)
    titleBar:SetScript("OnDragStop",  function() dlg:StopMovingOrSizing() end)
    zSF_FillBG(titleBar, unpack(ZSF.COLOR_SIDEBAR_BG))

    local titleFS = zSF_Text(titleBar, "GameFontNormal", "CENTER")
    titleFS:SetAllPoints()
    titleFS:SetTextColor(unpack(ZSF.COLOR_LABEL))
    dlg._titleFS = titleFS

    local sep = dlg:CreateTexture(nil, "ARTWORK")
    sep:SetPoint("TOPLEFT",  dlg, "TOPLEFT",  0, -TITLE_H)
    sep:SetPoint("TOPRIGHT", dlg, "TOPRIGHT", 0, -TITLE_H)
    sep:SetHeight(SEP_H)
    sep:SetColorTexture(unpack(ZSF.COLOR_SEPARATOR))

    local bodyFS = zSF_Text(dlg, "GameFontHighlightSmall", "LEFT")
    bodyFS:SetPoint("TOPLEFT",  dlg, "TOPLEFT",  PAD, -(TITLE_H + SEP_H + PAD - 2))
    bodyFS:SetPoint("TOPRIGHT", dlg, "TOPRIGHT", -PAD, -(TITLE_H + SEP_H + PAD - 2))
    bodyFS:SetHeight(BODY_H)
    bodyFS:SetTextColor(unpack(ZSF.COLOR_DESC))
    dlg._bodyFS = bodyFS

    -- Dark framed area containing the scrollable EditBox.
    local boxTop   = -(TITLE_H + SEP_H + PAD + BODY_H + 6)
    local boxW     = W - PAD * 2
    local boxFrame = CreateFrame("Frame", nil, dlg)
    boxFrame:SetSize(boxW, BOX_H)
    boxFrame:SetPoint("TOPLEFT", dlg, "TOPLEFT", PAD, boxTop)
    zSF_FillBG(boxFrame, 0.06, 0.06, 0.06, 1)
    zSF_DrawBorder(boxFrame, ZSF.COLOR_BORDER[1], ZSF.COLOR_BORDER[2], ZSF.COLOR_BORDER[3], 0.65)

    local sf = CreateFrame("ScrollFrame", nil, boxFrame)
    sf:SetPoint("TOPLEFT",     boxFrame, "TOPLEFT",      3, -3)
    sf:SetPoint("BOTTOMRIGHT", boxFrame, "BOTTOMRIGHT", -3,  3)

    local eb = CreateFrame("EditBox", nil, sf)
    eb:SetAllPoints()
    eb:SetMultiLine(true)
    eb:SetAutoFocus(false)
    eb:EnableMouse(true)
    eb:SetFontObject("GameFontHighlightSmall")
    eb:SetTextColor(unpack(ZSF.COLOR_LABEL))
    eb:SetMaxLetters(0)
    sf:SetScrollChild(eb)
    sf:SetScript("OnSizeChanged", function(self, w) eb:SetWidth(w) end)

    local function DoScrollInput(delta)
        local new = math.max(0, math.min(sf:GetVerticalScrollRange(),
            sf:GetVerticalScroll() - delta * 20))
        sf:SetVerticalScroll(new)
    end
    sf:EnableMouseWheel(true)
    sf:SetScript("OnMouseWheel", function(_, delta) DoScrollInput(delta) end)
    eb:SetScript("OnMouseWheel", function(_, delta) DoScrollInput(delta) end)

    sf:SetScript("OnVerticalScroll", function(self, offset)
        eb:SetHitRectInsets(0, 0, offset, eb:GetHeight() - offset - self:GetHeight())
    end)
    sf:SetScript("OnScrollRangeChanged", function(self, _, yrange)
        if yrange == 0 then
            eb:SetHitRectInsets(0, 0, 0, 0)
        else
            local off = self:GetVerticalScroll()
            eb:SetHitRectInsets(0, 0, off, eb:GetHeight() - off - self:GetHeight())
        end
    end)

    eb:SetScript("OnEscapePressed", function() Popup_Hide(dlg) end)
    eb:SetScript("OnEnterPressed",  function()
        -- In single-line mode, Enter triggers accept instead of inserting a newline.
        if not eb:IsMultiLine() then dlg._acceptBtn:Click() end
    end)
    dlg._editBox  = eb
    dlg._boxFrame = boxFrame

    -- Gradient border placeholder rebuilt at show-time with the live theme colors.
    local bp = CreateFrame("Frame", nil, dlg)
    bp:SetAllPoints()
    bp:SetFrameLevel(dlg:GetFrameLevel() + 10)

    local cancelBtn = zSF_BuildActionButton(dlg, CANCEL or "Cancel", nil, 130, { 0.6, 0.6, 0.6 })
    cancelBtn:SetPoint("BOTTOMLEFT", dlg, "BOTTOMLEFT", PAD, BTN_BOT)
    cancelBtn:SetScript("OnClick", function() Popup_Hide(dlg) end)

    local acceptBtn = zSF_BuildActionButton(dlg, OKAY or "OK", nil, 130, { 0.6, 0.6, 0.6 })
    acceptBtn:SetPoint("BOTTOMRIGHT", dlg, "BOTTOMRIGHT", -PAD, BTN_BOT)
    acceptBtn:SetScript("OnClick", function()
        local text = eb:GetText()
        local cb   = _onAccept
        -- Mark accepted before Hide() so OnHide doesn't fire the cancel path.
        _didAccept = true
        _onAccept  = nil
        _onCancel  = nil
        Popup_Hide(dlg)
        if cb then cb(text) end
    end)
    dlg._acceptBtn = acceptBtn

    dlg:SetScript("OnHide", function()
        if not _didAccept then
            local cb = _onCancel
            _onCancel = nil
            _onAccept = nil
            if cb then cb() end
        end
        _didAccept = false
        _onAccept  = nil
        _onCancel  = nil
    end)

    ---@type { show: fun(config:table, themeColors:table|nil) }
    local inst = {}
    function inst.show(config, themeColors)
        local cfg = config or {}

        local isSingle = cfg.mode == "single"
        local boxH     = isSingle and 28 or 130
        dlg:SetSize(dlg._dialogW, dlg._baseH + boxH)
        dlg._boxFrame:SetHeight(boxH)
        dlg._editBox:SetMultiLine(not isSingle)

        _onAccept  = cfg.onAccept
        _onCancel  = cfg.onCancel
        _didAccept = false

        dlg._titleFS:SetText(cfg.title or "")
        if cfg.body and cfg.body ~= "" then
            dlg._bodyFS:SetText(cfg.body)
            dlg._bodyFS:Show()
        else
            dlg._bodyFS:Hide()
        end

        dlg._acceptBtn.label:SetText(cfg.acceptText or OKAY or "OK")
        dlg._editBox:SetText("")

        local colors = themeColors or {}
        local pri    = colors.primary   or ZSF.DEFAULT_PRIMARY
        local sec    = colors.secondary or ZSF.DEFAULT_SECONDARY
        zSF_DrawGradientBorder(bp, pri, sec, sec, pri)

        Popup_Show(dlg)
        dlg._editBox:SetFocus()
    end

    _inputInstances[key] = inst
    return inst
end

--- Show a themed modal dialog for gathering multi-line text input.
---
--- config fields:
---   title      string           Title bar text.
---   body       string|nil       Instruction text shown above the edit area.
---   acceptText string|nil       Accept button label (defaults to global OKAY).
---   onAccept   fun(text:string) Called with the entered text when the user accepts.
---   onCancel   fun()|nil        Called when the dialog is dismissed without accepting.
---   mode       "single"|"multi" Input box size. "single" = one line; "multi" = tall (default).
---@param config table
function zSF_ShowInput(config)
    MakeInputInstance(nil).show(config, nil)
end

-- ── Output dialog ─────────────────────────────────────────────────────────────
-- Modal dialog showing read-only text.  Text is pre-highlighted on show so the
-- user can immediately Ctrl+C to copy.  Any edits the user makes are reverted.

local _outputInstances = {}

---@param addonName string|nil  Addon name for per-addon frame isolation; nil uses the shared default.
local function MakeOutputInstance(addonName)
    local key = addonName or "_default"
    if _outputInstances[key] then return _outputInstances[key] end

    -- Per-instance state.
    local _onClose = nil

    local W       = 540
    local TITLE_H = 32
    local SEP_H   = 1
    local PAD     = 14
    local BODY_H  = 32
    local BOX_H   = 170
    local BTN_H   = 22
    local BTN_BOT = 12
    local dlgH    = TITLE_H + SEP_H + PAD + BODY_H + 6 + BOX_H + PAD + BTN_H + BTN_BOT

    local frameName = addonName and ("zSFOutputDialog_" .. addonName) or "zSFOutputDialog"
    local dlg = CreateFrame("Frame", frameName, UIParent)
    dlg:SetSize(W, dlgH)
    dlg:SetPoint("CENTER")
    dlg:SetFrameStrata("FULLSCREEN_DIALOG")
    dlg:SetFrameLevel(600)
    dlg:SetClampedToScreen(true)
    dlg:SetMovable(true)
    dlg:EnableMouse(true)
    dlg:RegisterForDrag("LeftButton")
    dlg:SetScript("OnDragStart", function(self) self:StartMoving() end)
    dlg:SetScript("OnDragStop",  function(self) self:StopMovingOrSizing() end)
    dlg:Hide()
    table.insert(UISpecialFrames, frameName)

    zSF_FillBG(dlg, 0.04, 0.04, 0.04, 1)

    local titleBar = CreateFrame("Frame", nil, dlg)
    titleBar:SetPoint("TOPLEFT",  dlg, "TOPLEFT",  0, 0)
    titleBar:SetPoint("TOPRIGHT", dlg, "TOPRIGHT", 0, 0)
    titleBar:SetHeight(TITLE_H)
    titleBar:EnableMouse(true)
    titleBar:RegisterForDrag("LeftButton")
    titleBar:SetScript("OnDragStart", function() dlg:StartMoving() end)
    titleBar:SetScript("OnDragStop",  function() dlg:StopMovingOrSizing() end)
    zSF_FillBG(titleBar, unpack(ZSF.COLOR_SIDEBAR_BG))

    local titleFS = zSF_Text(titleBar, "GameFontNormal", "CENTER")
    titleFS:SetAllPoints()
    titleFS:SetTextColor(unpack(ZSF.COLOR_LABEL))
    dlg._titleFS = titleFS

    local sep = dlg:CreateTexture(nil, "ARTWORK")
    sep:SetPoint("TOPLEFT",  dlg, "TOPLEFT",  0, -TITLE_H)
    sep:SetPoint("TOPRIGHT", dlg, "TOPRIGHT", 0, -TITLE_H)
    sep:SetHeight(SEP_H)
    sep:SetColorTexture(unpack(ZSF.COLOR_SEPARATOR))

    local bodyFS = zSF_Text(dlg, "GameFontHighlightSmall", "LEFT")
    bodyFS:SetPoint("TOPLEFT",  dlg, "TOPLEFT",  PAD, -(TITLE_H + SEP_H + PAD - 2))
    bodyFS:SetPoint("TOPRIGHT", dlg, "TOPRIGHT", -PAD, -(TITLE_H + SEP_H + PAD - 2))
    bodyFS:SetHeight(BODY_H)
    bodyFS:SetTextColor(unpack(ZSF.COLOR_DESC))
    dlg._bodyFS = bodyFS

    local boxTop   = -(TITLE_H + SEP_H + PAD + BODY_H + 6)
    local boxW     = W - PAD * 2
    local boxFrame = CreateFrame("Frame", nil, dlg)
    boxFrame:SetSize(boxW, BOX_H)
    boxFrame:SetPoint("TOPLEFT", dlg, "TOPLEFT", PAD, boxTop)
    zSF_FillBG(boxFrame, 0.06, 0.06, 0.06, 1)
    zSF_DrawBorder(boxFrame, ZSF.COLOR_BORDER[1], ZSF.COLOR_BORDER[2], ZSF.COLOR_BORDER[3], 0.65)

    local sf = CreateFrame("ScrollFrame", nil, boxFrame)
    sf:SetPoint("TOPLEFT",     boxFrame, "TOPLEFT",      3, -3)
    sf:SetPoint("BOTTOMRIGHT", boxFrame, "BOTTOMRIGHT", -3,  3)

    local eb = CreateFrame("EditBox", nil, sf)
    eb:SetAllPoints()
    eb:SetMultiLine(true)
    eb:SetAutoFocus(false)
    eb:EnableMouse(true)
    eb:SetFontObject("GameFontHighlightSmall")
    eb:SetTextColor(unpack(ZSF.COLOR_DESC))
    eb:SetMaxLetters(0)
    sf:SetScrollChild(eb)
    sf:SetScript("OnSizeChanged", function(self, w) eb:SetWidth(w) end)

    local function DoScrollOutput(delta)
        local new = math.max(0, math.min(sf:GetVerticalScrollRange(),
            sf:GetVerticalScroll() - delta * 20))
        sf:SetVerticalScroll(new)
    end
    sf:EnableMouseWheel(true)
    sf:SetScript("OnMouseWheel", function(_, delta) DoScrollOutput(delta) end)
    eb:SetScript("OnMouseWheel", function(_, delta) DoScrollOutput(delta) end)

    sf:SetScript("OnVerticalScroll", function(self, offset)
        eb:SetHitRectInsets(0, 0, offset, eb:GetHeight() - offset - self:GetHeight())
    end)
    sf:SetScript("OnScrollRangeChanged", function(self, _, yrange)
        if yrange == 0 then
            eb:SetHitRectInsets(0, 0, 0, 0)
        else
            local off = self:GetVerticalScroll()
            eb:SetHitRectInsets(0, 0, off, eb:GetHeight() - off - self:GetHeight())
        end
    end)

    -- Readonly: revert any user edits immediately.
    local _prog = false
    local _val  = ""
    dlg._setContent = function(text)
        _prog = true
        eb:SetText(text or "")
        eb:SetCursorPosition(0)
        _prog = false
        _val  = text or ""
        sf:SetVerticalScroll(0)
    end
    eb:SetScript("OnTextChanged", function(_, userInput)
        if not _prog and userInput then dlg._setContent(_val) end
    end)
    eb:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)
    eb:SetScript("OnEnterPressed",  function(self) self:ClearFocus() end)
    dlg._editBox = eb

    local bp = CreateFrame("Frame", nil, dlg)
    bp:SetAllPoints()
    bp:SetFrameLevel(dlg:GetFrameLevel() + 10)

    -- Single centered close button.
    local closeBtn = zSF_BuildActionButton(dlg, CLOSE or "Close", nil, 130, { 0.6, 0.6, 0.6 })
    closeBtn:SetPoint("BOTTOM", dlg, "BOTTOM", 0, BTN_BOT)
    closeBtn:SetScript("OnClick", function()
        -- Clear before Hide() so OnHide doesn't double-fire the callback.
        local cb = _onClose
        _onClose = nil
        Popup_Hide(dlg)
        if cb then cb() end
    end)

    dlg:SetScript("OnHide", function()
        local cb = _onClose
        _onClose = nil
        if cb then cb() end
    end)

    ---@type { show: fun(config:table, themeColors:table|nil) }
    local inst = {}
    function inst.show(config, themeColors)
        local cfg = config or {}

        _onClose = cfg.onClose

        dlg._titleFS:SetText(cfg.title or "")
        if cfg.body and cfg.body ~= "" then
            dlg._bodyFS:SetText(cfg.body)
            dlg._bodyFS:Show()
        else
            dlg._bodyFS:Hide()
        end

        dlg._setContent(cfg.text or "")

        local colors = themeColors or {}
        local pri    = colors.primary   or ZSF.DEFAULT_PRIMARY
        local sec    = colors.secondary or ZSF.DEFAULT_SECONDARY
        zSF_DrawGradientBorder(bp, pri, sec, sec, pri)

        Popup_Show(dlg)
        -- Steal keyboard focus so Ctrl+C copies instead of firing a game keybinding.
        -- HighlightText must come after SetFocus; gaining focus clears the selection.
        dlg._editBox:SetFocus()
        dlg._editBox:HighlightText()
    end

    _outputInstances[key] = inst
    return inst
end

--- Show a themed modal dialog for displaying read-only text output.
--- Text is pre-highlighted so the user can immediately Ctrl+C to copy.
--- Any edits the user attempts are silently reverted.
---
--- config fields:
---   title    string       Title bar text.
---   body     string|nil   Instruction text shown above the text area.
---   text     string       The content to display.
---   onClose  fun()|nil    Called when the dialog is dismissed.
---@param config table
function zSF_ShowOutput(config)
    MakeOutputInstance(nil).show(config, nil)
end

-- Expose per-addon factory functions so Init.lua ctx methods can route
-- to the correct isolated instance without needing local access here.
zSettingsFrame._dialogs = {
    confirm   = MakeConfirmInstance,
    input     = MakeInputInstance,
    output    = MakeOutputInstance,
    colorpick = MakeCPInstance,
}
