-- zAddonFramework-1.0/zAF_Widgets.lua
-- Widget constructors: pill toggle, stepper arrows, slider, dropdown, colour
-- picker, colour swatch, action button, close button, and the modal dialogs
-- (confirm, input, output). All colours are parameterised via primary/secondary
-- tables passed at build time.
local ZAF = _G.ZAF  -- namespace for style constants and easing functions
local NC  = zAF_NormalizeColor
local function CtxPri() local t = ZAF._buildCtx and ZAF._buildCtx._themeColors; return t and t.primary end
local function CtxSec() local t = ZAF._buildCtx and ZAF._buildCtx._themeColors; return t and t.secondary end

-- ── 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
local ARROW_H   = 20
local CB_INSET  = 3

-- Popup fade constants.
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 = zAF_Tween(0, 1, POPUP_FADE_IN, ZAF.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 = zAF_Tween(fromAlpha, 0, POPUP_FADE_OUT, ZAF.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
local PILL_PAD  = 2
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       Colour table { r, g, b[, a] } or ColorMixin for the ON track.
---@param indicatorColor table|nil   Colour table { r, g, b[, a] } or ColorMixin for knob ON colour; defaults to white.
---@return Button pill
function zAF_MakePillToggle(parent, secondary, indicatorColor)
    secondary      = NC(secondary or CtxSec() or ZAF.DEFAULT_SECONDARY)
    indicatorColor = indicatorColor and NC(indicatorColor)
    local pill = CreateFrame("Button", nil, parent)
    pill:SetSize(PILL_W, PILL_H)
    pill:EnableMouse(true)

    local circlePath = zAF_AssetPath("zAF_Circle.tga")
    local pillPath   = zAF_AssetPath("zAF_Pill.tga")

    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)

    local trackTint = pill:CreateTexture(nil, "BORDER")
    trackTint:SetAllPoints()
    trackTint:AddMaskTexture(trackMask)

    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)

    local animT      = 0
    local animTarget = 0
    local POS_OFF    = PILL_PAD
    local POS_ON     = PILL_W - PILL_KNOB - PILL_PAD
    local CENTER_OFF     = POS_OFF + PILL_KNOB / 2
    local CENTER_ON      = POS_ON  + PILL_KNOB / 2
    local KNOB_STRETCH   = 6
    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: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

    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)
    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 zAF_MakeStepperArrow(parent, flipH, primary, secondary)
    primary   = NC(primary   or CtxPri() or ZAF.DEFAULT_PRIMARY)
    secondary = NC(secondary or CtxSec() or ZAF.DEFAULT_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 zAF_MakeVerticalArrow(parent, pointDown, primary, secondary)
    primary   = NC(primary   or CtxPri() or ZAF.DEFAULT_PRIMARY)
    secondary = NC(secondary or CtxSec() or ZAF.DEFAULT_SECONDARY)
    local btn = CreateFrame("Frame", nil, parent)
    btn:SetSize(ARROW_W, ARROW_W)

    local norm = btn:CreateTexture(nil, "ARTWORK")
    norm:SetTexture(RENOWN_SHEET)
    norm:SetTexCoord(RENOWN_L, RENOWN_R, RENOWN_T, RENOWN_B)
    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 ───────────────────────────────────────────────────────────────────

local TRACK_H  = 4
local THUMB_SZ = 12
local INPUT_H  = 18

--- 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    Colour table { r, g, b[, a] } or ColorMixin.
---@param secondary table    Colour table { r, g, b[, a] } or ColorMixin.
---@param inline    boolean|nil  True → input/arrows sit right of the track on one line.
---@return table result
function zAF_BuildSlider(cell, minVal, maxVal, curVal, step, primary, secondary, inline)
    primary   = NC(primary   or CtxPri() or ZAF.DEFAULT_PRIMARY)
    secondary = NC(secondary or CtxSec() or ZAF.DEFAULT_SECONDARY)
    step   = step or 1
    curVal = math.max(minVal, math.min(maxVal, curVal))

    local value = curVal

    -- ── Track ────────────────────────────────────────────────────────────────
    local trackFrame = CreateFrame("Frame", nil, cell)
    trackFrame:SetHeight(THUMB_SZ)

    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)

    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)

    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(zAF_AssetPath("zAF_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

    ---@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:ClearAllPoints()
        thumb:SetPoint("CENTER", trackDark, "LEFT", fillW, 0)
    end

    -- trackDark:GetWidth() returns 0 on hidden frames; correct on first show.
    trackFrame:SetScript("OnShow", function()
        UpdateVisual(value)
    end)

    ---@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)
    zAF_FillBG(inputFrame, 0.06, 0.06, 0.06, 1)
    zAF_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(ZAF.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(ZAF.COLOR_LABEL))
    end)
    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

    local HOLD_DELAY    = 0.5
    local INTERVAL_SLOW = 0.25
    local INTERVAL_FAST = 0.06
    local RAMP_DURATION = 2.0

    local function WireStepperHold(btn, delta)
        local timer     = nil
        local rampStart = 0

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

        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  = zAF_MakeStepperArrow(cell, false, primary, secondary)
    local stepRight = zAF_MakeStepperArrow(cell, true,  primary, secondary)
    if inline then
        local insetY    = math.floor((ARROW_H - THUMB_SZ) / 2)
        local inputOffY = math.floor((ARROW_H - INPUT_H)  / 2)
        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

    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)

    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)

    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)

    UpdateVisual(value)
    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))

    local result = {
        trackFrame  = trackFrame,
        inputText   = inputText,
        inputFrame  = inputFrame,
        ---@return number
        GetValue    = function() return value end,
        ---@param v number
        SetValue    = function(v)
            value = math.max(minVal, math.min(maxVal, v))
            inputText:SetText(FormatSliderVal(value, step))
            UpdateVisual(value)
        end,
        ---@param fn fun(v: number)
        SetOnChanged = function(fn) onChanged = fn end,
        UpdateVisual = function() UpdateVisual(value) end,
    }
    return result
end

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

local DD_MENU_MAX_H = 200
local DD_ITEM_H     = 22
local DD_MENU_PAD   = 4

---@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

---@type Frame|nil
local _openMenu = nil
---@type Button|nil
local _ddPopupOwner = nil

---@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()
    zAF_FillBG(menu, 0.06, 0.06, 0.06, 0.97)
    zAF_DrawBorder(menu, ZAF.COLOR_BORDER[1], ZAF.COLOR_BORDER[2], ZAF.COLOR_BORDER[3], ZAF.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)
    zAF_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
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

    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(ZAF.COLOR_LABEL))

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

            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 ZAF.COLOR_LABEL[1],
                    (item._key == cur) and PG  or ZAF.COLOR_LABEL[2],
                    (item._key == cur) and PB  or ZAF.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.
---@param parent  Frame
---@param w       number  Pixel width.
---@param primary table   { r, g, b }
---@return Button     btn
---@return FontString text
---@return Texture   arrow
function zAF_MakeDropButton(parent, w, primary)
    primary = NC(primary or CtxPri() or ZAF.DEFAULT_PRIMARY)
    local btn = CreateFrame("Button", nil, parent)
    btn:SetSize(w, DD_H)
    btn:EnableMouse(true)
    zAF_FillBG(btn, 0.10, 0.10, 0.10, 1)
    zAF_DrawBorder(btn)

    local text = zAF_Text(btn, "GameFontHighlightSmall", "LEFT")
    text:SetTextColor(unpack(ZAF.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.
---@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 zAF_BuildDropdown(cell, selText, primary)
    primary = NC(primary or CtxPri() or ZAF.DEFAULT_PRIMARY)
    EnsureDDCatcher()

    local cellW = cell:GetWidth()

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

    ---@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)

        zAF_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)

        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

        _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)

    dd:SetScript("OnEnter", function()
        zAF_DrawBorder(dd, ZAF.COLOR_BORDER[1], ZAF.COLOR_BORDER[2], ZAF.COLOR_BORDER[3], 0.80)
    end)
    dd:SetScript("OnLeave", function()
        zAF_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,
        ---@param fn fun(item: Button, key: string)
        SetItemDecorator = function(fn) itemDecorator = fn end,
        OpenMenu  = OpenMenu,
        CloseMenu = CloseOpenMenu,
    }
end

-- ── Custom colour picker ──────────────────────────────────────────────────────

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

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

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

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

local CP_W         = 300
local CP_CANVAS_W  = 200
local CP_CANVAS_H  = 150
local CP_BAR_H     = 14
local CP_BAR_W     = CP_CANVAS_W
local CP_PAD       = 10
local CP_INPUT_W   = 40
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
local function MakeCPInstance(addonName)
    local key = addonName or "_default"
    if _cpInstances[key] then return _cpInstances[key] end

    local _cpH, _cpS, _cpV, _cpA
    local _cpOrigR, _cpOrigG, _cpOrigB, _cpOrigA
    local _cpOnChange, _cpOnAccept, _cpOnCancel, _cpResetFn
    local _cpHasAlpha = false

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

    zAF_FillBG(f, 0.05, 0.05, 0.05, 0.97)

    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(ZAF.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)

    local canvasHue = canvas:CreateTexture(nil, "BACKGROUND")
    canvasHue:SetAllPoints()
    canvasHue:SetColorTexture(1, 0, 0, 1)

    local canvasWhite = canvas:CreateTexture(nil, "BORDER")
    canvasWhite:SetAllPoints()
    canvasWhite:SetColorTexture(1, 1, 1, 1)
    zAF_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)
    zAF_DisablePixelSnap(canvasBlack)
    canvasBlack:SetGradient("VERTICAL", CreateColor(0,0,0,1), CreateColor(0,0,0,0))

    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)

    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
            seg:SetPoint("TOPRIGHT", hueBar, "TOPRIGHT", 0, 0)
        end
        local cs, ce = HUE_STOPS[i], HUE_STOPS[i + 1]
        seg:SetColorTexture(1, 1, 1, 1)
        zAF_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 ─────────────────────────────────────────────────────────────
    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()

    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)

    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)
        zAF_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)
        zAF_DisablePixelSnap(swatchCur)
    end

    -- ── Input row ─────────────────────────────────────────────────────────────
    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(ZAF.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(ZAF.COLOR_LABEL))
        box:SetTextInsets(2, 2, 1, 1)
        zAF_FillBG(box, 0.08, 0.08, 0.08, 1)
        zAF_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)
    local lblA, boxA = MakeInput("A", (CP_INPUT_H + 4) * 3)
    boxA:SetMaxLetters(3)

    local hexLbl = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    hexLbl:SetText("#")
    hexLbl:SetTextColor(unpack(ZAF.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(ZAF.COLOR_LABEL))
    boxHex:SetTextInsets(2, 2, 1, 1)
    zAF_FillBG(boxHex, 0.08, 0.08, 0.08, 1)
    zAF_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 = zAF_BuildActionButton(btnRow, OKAY or "OK", nil, CP_BTN_W, { 0.5, 0.8, 0.5 })
    btnOK:SetPoint("RIGHT", btnRow, "RIGHT", 0, 0)

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

    local btnReset = zAF_BuildActionButton(f, RESET or "Reset", nil, CP_BTN_W, { 0.6, 0.6, 0.6 })
    btnReset:Hide()

    local btnClass = zAF_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)

    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 ────────────────────────────────────────────────────────
    local function RelayoutFrame()
        local anchorBelow = _cpHasAlpha and alphaBar or hueBar
        swatchRow:ClearAllPoints()
        swatchRow:SetPoint("TOPLEFT", anchorBelow, "BOTTOMLEFT", 0, -6)
        swatchRow:SetWidth(CP_CANVAS_W)

        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)

        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

        btnRow:ClearAllPoints()
        btnRow:SetPoint("BOTTOMLEFT",  f, "BOTTOMLEFT",  CP_PAD,  CP_PAD)
        btnRow:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -CP_PAD, CP_PAD)

        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()
        -- treated as cancel if closed without OK/Cancel
    end)

    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)
        zAF_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    = NC(colors.primary   or ZAF.DEFAULT_PRIMARY)
        local sec    = NC(colors.secondary or ZAF.DEFAULT_SECONDARY)
        zAF_DrawGradientBorder(f, pri, sec, sec, pri)
        f._fullRefresh()

        Popup_Show(f)
    end

    _cpInstances[key] = inst
    return inst
end

--- Open the custom colour 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 zAF_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 zAF_BuildColorSwatch(cell, r, g, b, a, primary)
    primary = NC(primary or CtxPri() or ZAF.DEFAULT_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 = zAF_FillBG(swatch, r or 1, g or 1, b or 1, 1)
    zAF_DrawBorder(swatch, primary[1], primary[2], primary[3], 0.65)

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

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

-- ── Action button ─────────────────────────────────────────────────────────────

--- Build a flat action button with optional icon.
---@param parent      Frame
---@param labelText   string
---@param iconAtlas   string|nil
---@param width       number|nil         Default 140.
---@param secondary   table              Colour table { r, g, b[, a] } or ColorMixin for hover/press overlay.
---@param iconColor   table|false|nil    Colour table { r, g, b[, a] } or ColorMixin; false/nil = show raw.
---@param borderColor table|nil          Colour table { r, g, b[, a] } or ColorMixin; nil uses ZAF.COLOR_BORDER.
---@param rotateIcon  number|nil         Clockwise degrees.
---@param flipIcon    "horizontal"|"vertical"|nil
---@return Button btn
function zAF_BuildActionButton(parent, labelText, iconAtlas, width, secondary, iconColor, borderColor, rotateIcon, flipIcon)
    secondary   = NC(secondary or CtxSec() or ZAF.DEFAULT_SECONDARY)
    iconColor   = iconColor   and NC(iconColor)
    borderColor = borderColor and NC(borderColor)
    width = width or 140

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

    zAF_FillBG(btn, ZAF.COLOR_TAB_ACTIVE[1], ZAF.COLOR_TAB_ACTIVE[2],
               ZAF.COLOR_TAB_ACTIVE[3], ZAF.COLOR_TAB_ACTIVE[4])

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

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

        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
                    icon:SetTexCoord(l, r, b, t)
                end
            else
                icon:SetAtlas(iconAtlas)
            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

    if borderColor then
        zAF_DrawBorder(btn, borderColor[1], borderColor[2], borderColor[3])
    else
        zAF_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(ZAF.COLOR_LABEL))
    btn.label = lbl

    return btn
end

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

--- Build the title-bar close button.
---@param parent    Frame
---@param secondary table  Colour table { r, g, b[, a] } or ColorMixin.
---@return Button
function zAF_BuildCloseButton(parent, secondary)
    secondary = NC(secondary or CtxSec() or ZAF.DEFAULT_SECONDARY)
    local btn = CreateFrame("Button", nil, parent)
    btn:SetSize(20, 20)
    btn:EnableMouse(true)

    local icon = btn:CreateTexture(nil, "ARTWORK")
    icon:SetTexture(zAF_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
local function MakeConfirmInstance(addonName)
    local key = addonName or "_default"
    if _confirmInstances[key] then return _confirmInstances[key] end

    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)

    zAF_FillBG(dlg, 0.04, 0.04, 0.04, 1)

    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(ZAF.COLOR_LABEL))

    local cancelBtn = zAF_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)

    local confirmBtn = zAF_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)

    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    = NC(colors.primary   or ZAF.DEFAULT_PRIMARY)
        local sec    = NC(colors.secondary or ZAF.DEFAULT_SECONDARY)
        zAF_DrawGradientBorder(bp, pri, sec, sec, pri)
        Popup_Show(dlg)
    end

    _confirmInstances[key] = inst
    return inst
end

--- Show a modal confirmation prompt.
---@param text     string
---@param onAccept fun()
---@param onRevert fun()|nil
function zAF_ShowConfirm(text, onAccept, onRevert)
    MakeConfirmInstance(nil).show(text, onAccept, onRevert, nil)
end

-- ── Input dialog ──────────────────────────────────────────────────────────────

local _inputInstances = {}

---@param addonName string|nil
local function MakeInputInstance(addonName)
    local key = addonName or "_default"
    if _inputInstances[key] then return _inputInstances[key] end

    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
    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)

    zAF_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)
    zAF_FillBG(titleBar, unpack(ZAF.COLOR_SIDEBAR_BG))

    local titleFS = zAF_Text(titleBar, "GameFontNormal", "CENTER")
    titleFS:SetAllPoints()
    titleFS:SetTextColor(unpack(ZAF.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(ZAF.COLOR_SEPARATOR))

    local bodyFS = zAF_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(ZAF.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)
    zAF_FillBG(boxFrame, 0.06, 0.06, 0.06, 1)
    zAF_DrawBorder(boxFrame, ZAF.COLOR_BORDER[1], ZAF.COLOR_BORDER[2], ZAF.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(ZAF.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()
        if not eb:IsMultiLine() then dlg._acceptBtn:Click() end
    end)
    dlg._editBox  = eb
    dlg._boxFrame = boxFrame

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

    local cancelBtn = zAF_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 = zAF_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
        _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    = NC(colors.primary   or ZAF.DEFAULT_PRIMARY)
        local sec    = NC(colors.secondary or ZAF.DEFAULT_SECONDARY)
        zAF_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 text input.
---@param config table
function zAF_ShowInput(config)
    MakeInputInstance(nil).show(config, nil)
end

-- ── Output dialog ─────────────────────────────────────────────────────────────

local _outputInstances = {}

---@param addonName string|nil
local function MakeOutputInstance(addonName)
    local key = addonName or "_default"
    if _outputInstances[key] then return _outputInstances[key] end

    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)

    zAF_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)
    zAF_FillBG(titleBar, unpack(ZAF.COLOR_SIDEBAR_BG))

    local titleFS = zAF_Text(titleBar, "GameFontNormal", "CENTER")
    titleFS:SetAllPoints()
    titleFS:SetTextColor(unpack(ZAF.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(ZAF.COLOR_SEPARATOR))

    local bodyFS = zAF_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(ZAF.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)
    zAF_FillBG(boxFrame, 0.06, 0.06, 0.06, 1)
    zAF_DrawBorder(boxFrame, ZAF.COLOR_BORDER[1], ZAF.COLOR_BORDER[2], ZAF.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(ZAF.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)

    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)

    local closeBtn = zAF_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()
        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    = NC(colors.primary   or ZAF.DEFAULT_PRIMARY)
        local sec    = NC(colors.secondary or ZAF.DEFAULT_SECONDARY)
        zAF_DrawGradientBorder(bp, pri, sec, sec, pri)

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

    _outputInstances[key] = inst
    return inst
end

--- Show a themed modal dialog for displaying read-only text output.
---@param config table
function zAF_ShowOutput(config)
    MakeOutputInstance(nil).show(config, nil)
end

-- ── Date-range picker ─────────────────────────────────────────────────────────
--
-- A flyout calendar that lets the user choose a start and end date+time.
-- Displays in local time; the returned timestamps are from time() which also
-- operates in local time, so no conversion is needed between display and storage.

---@type Frame|nil
local _calPopup = nil
---@type Frame|nil
local _calCatcher = nil

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

-- Days in each month for a given year (accounts for leap years).
---@param m number  1-12
---@param y number  full year
---@return number
local function DaysInMonth(m, y)
    local d = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }
    if m == 2 and (y % 4 == 0 and (y % 100 ~= 0 or y % 400 == 0)) then return 29 end
    return d[m]
end

-- 0=Sunday … 6=Saturday for the 1st of the given month.
---@param m number
---@param y number
---@return number
local function FirstWeekday(m, y)
    -- Zeller-ish: ask WoW Lua what weekday the 1st falls on.
    -- date("!%w") would be UTC; we use local date() intentionally.
    return tonumber(date("%w", time({ year=y, month=m, day=1, hour=0, min=0, sec=0 })))
end

local CAL_DAY_SZ  = 20   -- each day button size (square)
local CAL_GAP     = 2    -- gap between day buttons
local CAL_COLS    = 7
local CAL_ROWS    = 6
local CAL_DOW     = { "Su","Mo","Tu","We","Th","Fr","Sa" }
local CAL_MONTHS  = {
    "January","February","March","April","May","June",
    "July","August","September","October","November","December"
}

local TIME_BOX_W  = 28   -- width of hour/min input boxes
local TIME_BOX_H  = 18
local CAL_SIDE_W  = CAL_COLS * CAL_DAY_SZ + (CAL_COLS - 1) * CAL_GAP   -- 146
local CAL_SIDE_H  = 24 + 16 + 4 + CAL_ROWS * (CAL_DAY_SZ + CAL_GAP)   -- header+dow+grid
local CAL_PAD     = 12
local CAL_SEP_GAP = 16   -- gap between the two calendar sides
local CAL_POPUP_W = CAL_PAD * 2 + CAL_SIDE_W * 2 + CAL_SEP_GAP
local CAL_POPUP_H = CAL_PAD * 2 + CAL_SIDE_H + 28 + 12  -- sides + time row + buttons

-- Build one calendar month panel (prev/next nav, DOW labels, day grid).
-- Returns a table with fields: frame, dayBtns, monthLabel, prevBtn, nextBtn,
--   curYear, curMonth, SetMonth(y,m), UpdateSelection(startTs, endTs)
---@param parent        Frame
---@param primary       number[]
---@param secondary     number[]
---@param side          string    "start"|"end"  — which side of the range
---@param onDayClicked  fun(y:number, m:number, d:number)
---@return table
local function BuildCalSide(parent, primary, secondary, side, onDayClicked)
    -- Mutable theme table; all closures read from here so SetTheme takes effect live.
    local c = {
        PR = primary[1],   PG = primary[2],   PB = primary[3],
        SR = secondary[1], SG = secondary[2], SB = secondary[3],
    }

    local f = CreateFrame("Frame", nil, parent)
    f:SetSize(CAL_SIDE_W, CAL_SIDE_H)

    -- Month header: [<]  Month YYYY  [>]
    local prevBtn = zAF_MakeStepperArrow(f, false, primary, secondary)
    prevBtn:SetPoint("TOPLEFT", f, "TOPLEFT", 0, 0)

    local nextBtn = zAF_MakeStepperArrow(f, true, primary, secondary)
    nextBtn:SetPoint("TOPRIGHT", f, "TOPRIGHT", 0, 0)

    local monthLabel = zAF_Text(f, "GameFontHighlightSmall", "CENTER")
    monthLabel:SetTextColor(c.PR, c.PG, c.PB, 1)
    monthLabel:SetPoint("LEFT",  prevBtn, "RIGHT", 4, 0)
    monthLabel:SetPoint("RIGHT", nextBtn, "LEFT",  -4, 0)
    monthLabel:SetHeight(18)
    monthLabel:SetPoint("TOP", f, "TOP", 0, 0)

    -- Day-of-week labels row
    local dowY = -24
    for i, lbl in ipairs(CAL_DOW) do
        local tx = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        tx:SetSize(CAL_DAY_SZ, 14)
        tx:SetTextColor(0.45, 0.45, 0.45, 1)
        tx:SetJustifyH("CENTER")
        tx:SetText(lbl)
        tx:SetPoint("TOPLEFT", f, "TOPLEFT", (i - 1) * (CAL_DAY_SZ + CAL_GAP), dowY)
    end

    -- Day grid (7 cols × 6 rows = 42 buttons, many hidden for short months)
    ---@type Button[]
    local dayBtns = {}
    local gridY = dowY - 16 - 4
    for row = 0, CAL_ROWS - 1 do
        for col = 0, CAL_COLS - 1 do
            local btn = CreateFrame("Button", nil, f)
            btn:SetSize(CAL_DAY_SZ, CAL_DAY_SZ)
            btn:SetPoint("TOPLEFT", f, "TOPLEFT",
                col * (CAL_DAY_SZ + CAL_GAP),
                gridY - row * (CAL_DAY_SZ + CAL_GAP))
            btn:EnableMouse(true)

            local bg = btn:CreateTexture(nil, "BACKGROUND")
            bg:SetAllPoints()
            bg:SetColorTexture(0, 0, 0, 0)

            local num = zAF_Text(btn, "GameFontHighlightSmall", "CENTER")
            num:SetAllPoints()
            num:SetTextColor(unpack(ZAF.COLOR_LABEL))

            btn._bg  = bg
            btn._num = num
            btn._day = 0

            btn:SetScript("OnEnter", function(self)
                if self._day > 0 then
                    self._bg:SetColorTexture(c.PR, c.PG, c.PB, 0.20)
                end
            end)
            btn:SetScript("OnLeave", function(self)
                -- bg colour is restored by UpdateSelection
                self._bg:SetColorTexture(
                    self._bgR or 0, self._bgG or 0, self._bgB or 0, self._bgA or 0)
            end)
            btn:SetScript("OnClick", function(self)
                if self._day > 0 then
                    onDayClicked(self._year, self._month, self._day)
                end
            end)

            dayBtns[row * CAL_COLS + col + 1] = btn
        end
    end

    local curYear, curMonth = 0, 0

    -- Repaint every day button for the given year/month and selection.
    ---@param startTs number|nil
    ---@param endTs   number|nil
    local function UpdateSelection(startTs, endTs)
        local firstDow = FirstWeekday(curMonth, curYear)
        local daysInM  = DaysInMonth(curMonth, curYear)

        -- Normalise the selected range to year/month/day triples for comparison.
        local sY, sM, sD, eY, eM, eD
        if startTs then
            sY = tonumber(date("%Y", startTs))
            sM = tonumber(date("%m", startTs))
            sD = tonumber(date("%d", startTs))
        end
        if endTs then
            eY = tonumber(date("%Y", endTs))
            eM = tonumber(date("%m", endTs))
            eD = tonumber(date("%d", endTs))
        end

        local todayStr = date("%Y%m%d")

        for idx, btn in ipairs(dayBtns) do
            local gridIdx = idx - 1    -- 0-based
            local day     = gridIdx - firstDow + 1
            if day < 1 or day > daysInM then
                btn._day = 0
                btn:Hide()
            else
                btn._day   = day
                btn._year  = curYear
                btn._month = curMonth
                btn:Show()
                btn._num:SetText(tostring(day))

                local thisDateStr = string.format("%04d%02d%02d", curYear, curMonth, day)
                local isToday     = (thisDateStr == todayStr)

                -- Determine range membership
                local isStart = sY and (curYear == sY and curMonth == sM and day == sD)
                local isEnd   = eY and (curYear == eY and curMonth == eM and day == eD)
                local inRange = false
                if sY and eY then
                    local ds = sY * 10000 + sM * 100 + sD
                    local de = eY * 10000 + eM * 100 + eD
                    local dt = curYear * 10000 + curMonth * 100 + day
                    inRange = (dt > ds and dt < de)
                end

                local bgR, bgG, bgB, bgA = 0, 0, 0, 0
                local txR, txG, txB = unpack(ZAF.COLOR_LABEL)

                if isStart or isEnd then
                    bgR, bgG, bgB, bgA = c.PR, c.PG, c.PB, 0.55
                    txR, txG, txB = 0.05, 0.05, 0.05
                elseif inRange then
                    bgR, bgG, bgB, bgA = c.PR, c.PG, c.PB, 0.15
                elseif isToday then
                    bgR, bgG, bgB, bgA = c.SR, c.SG, c.SB, 0.20
                    txR, txG, txB = c.SR, c.SG, c.SB
                end

                btn._bg:SetColorTexture(bgR, bgG, bgB, bgA)
                btn._bgR, btn._bgG, btn._bgB, btn._bgA = bgR, bgG, bgB, bgA
                btn._num:SetTextColor(txR, txG, txB, 1)
            end
        end
    end

    ---@param y number
    ---@param m number
    local function SetMonth(y, m)
        curYear  = y
        curMonth = m
        monthLabel:SetText(CAL_MONTHS[m] .. "  " .. y)
        UpdateSelection(nil, nil)
    end

    prevBtn:SetScript("OnMouseDown", nil)
    prevBtn:SetScript("OnMouseUp",   nil)
    nextBtn:SetScript("OnMouseDown", nil)
    nextBtn:SetScript("OnMouseUp",   nil)

    return {
        frame           = f,
        dayBtns         = dayBtns,
        monthLabel      = monthLabel,
        prevBtn         = prevBtn,
        nextBtn         = nextBtn,
        SetMonth        = SetMonth,
        UpdateSelection = UpdateSelection,
        GetCurYear      = function() return curYear  end,
        GetCurMonth     = function() return curMonth end,
        ---@param p number[]
        ---@param s number[]
        SetTheme        = function(p, s)
            c.PR, c.PG, c.PB = p[1], p[2], p[3]
            c.SR, c.SG, c.SB = s[1], s[2], s[3]
        end,
    }
end

-- Build a small hour:minute time input row.
-- Returns { frame, GetHour, GetMin, SetTime }
---@param parent  Frame
---@param primary number[]
---@param label   string
---@return table
local function BuildTimeInput(parent, primary, label)
    local PR, PG, PB = primary[1], primary[2], primary[3]

    local f = CreateFrame("Frame", nil, parent)
    f:SetHeight(TIME_BOX_H)

    local lbl = zAF_Text(f, "GameFontHighlightSmall", "LEFT")
    lbl:SetTextColor(0.45, 0.45, 0.45, 1)
    lbl:SetText(label)
    lbl:SetPoint("LEFT", f, "LEFT", 0, 0)
    lbl:SetWidth(30)

    local function MakeNumBox(xOff)
        local bg = CreateFrame("Frame", nil, f)
        bg:SetSize(TIME_BOX_W, TIME_BOX_H)
        bg:SetPoint("LEFT", f, "LEFT", xOff, 0)
        bg:SetFrameLevel(f:GetFrameLevel() + 1)
        zAF_FillBG(bg, 0.07, 0.07, 0.10, 0.90)
        zAF_DrawBorder(bg, PR, PG, PB, 0.50)

        local eb = CreateFrame("EditBox", nil, bg)
        eb:SetPoint("TOPLEFT", bg, "TOPLEFT", 3, -2)
        eb:SetPoint("BOTTOMRIGHT", bg, "BOTTOMRIGHT", -3, 2)
        eb:SetAutoFocus(false)
        eb:SetNumeric(true)
        eb:SetMaxLetters(2)
        eb:SetFont(GameFontHighlightSmall:GetFont())
        eb:SetTextColor(0.9, 0.9, 0.9, 1)
        eb:SetJustifyH("CENTER")

        -- Highlight all on focus so typing immediately replaces the value.
        eb:SetScript("OnEditFocusGained", function(self) self:HighlightText() end)

        return bg, eb
    end

    local hourBg, hourEB = MakeNumBox(32)
    local colon = zAF_Text(f, "GameFontHighlightSmall", "CENTER")
    colon:SetText(":")
    colon:SetTextColor(0.45, 0.45, 0.45, 1)
    colon:SetSize(8, TIME_BOX_H)
    colon:SetPoint("LEFT", f, "LEFT", 32 + TIME_BOX_W + 1, 0)

    local minBg, minEB = MakeNumBox(32 + TIME_BOX_W + 8)

    -- Clamp on focus lost.
    hourEB:SetScript("OnEditFocusLost", function(self)
        local v = tonumber(self:GetText()) or 0
        self:SetText(string.format("%02d", math.max(0, math.min(23, v))))
    end)
    minEB:SetScript("OnEditFocusLost", function(self)
        local v = tonumber(self:GetText()) or 0
        self:SetText(string.format("%02d", math.max(0, math.min(59, v))))
    end)

    f:SetWidth(32 + TIME_BOX_W * 2 + 8)

    return {
        frame   = f,
        ---@return number
        GetHour = function()
            return math.max(0, math.min(23, tonumber(hourEB:GetText()) or 0))
        end,
        ---@return number
        GetMin  = function()
            return math.max(0, math.min(59, tonumber(minEB:GetText()) or 0))
        end,
        ---@param h number
        ---@param m number
        SetTime = function(h, m)
            hourEB:SetText(string.format("%02d", h))
            minEB:SetText(string.format("%02d", m))
        end,
        ClearFocus = function()
            hourEB:ClearFocus()
            minEB:ClearFocus()
        end,
        ---@param p number[]
        SetTheme = function(p)
            zAF_DrawBorder(hourBg, p[1], p[2], p[3], 0.50)
            zAF_DrawBorder(minBg,  p[1], p[2], p[3], 0.50)
        end,
    }
end

local function EnsureCalPopup()
    if _calPopup then return end

    local pop = CreateFrame("Frame", "zAFCalPopup", UIParent)
    pop:SetSize(CAL_POPUP_W, CAL_POPUP_H)
    pop:SetFrameStrata("FULLSCREEN_DIALOG")
    pop:SetFrameLevel(420)
    pop:SetClampedToScreen(true)
    pop:EnableMouse(true)
    pop:Hide()
    zAF_FillBG(pop, 0.06, 0.06, 0.06, 0.97)
    zAF_DrawBorder(pop, ZAF.COLOR_BORDER[1], ZAF.COLOR_BORDER[2], ZAF.COLOR_BORDER[3], ZAF.COLOR_BORDER[4])
    table.insert(UISpecialFrames, "zAFCalPopup")

    -- State: what the popup is currently editing.
    ---@type number|nil
    pop._startTs  = nil
    ---@type number|nil
    pop._endTs    = nil
    pop._primary  = NC(ZAF.DEFAULT_PRIMARY)
    pop._secondary = NC(ZAF.DEFAULT_SECONDARY)
    ---@type fun(startTs:number|nil, endTs:number|nil)|nil
    pop._onApply  = nil
    ---@type Button|nil
    pop._owner    = nil  -- the trigger button that opened us

    -- ── Calendar sides ────────────────────────────────────────────────────────

    -- Populated in the first show() call once primary is known; stored on the popup.
    -- We build them lazily here using placeholder white colour and recolour on show.

    local function Noop() end

    local startSide = BuildCalSide(pop, NC(ZAF.DEFAULT_PRIMARY), NC(ZAF.DEFAULT_SECONDARY),
        "start", Noop)
    startSide.frame:SetPoint("TOPLEFT", pop, "TOPLEFT", CAL_PAD, -CAL_PAD)

    local endSide = BuildCalSide(pop, NC(ZAF.DEFAULT_PRIMARY), NC(ZAF.DEFAULT_SECONDARY),
        "end", Noop)
    endSide.frame:SetPoint("TOPLEFT", pop, "TOPLEFT",
        CAL_PAD + CAL_SIDE_W + CAL_SEP_GAP, -CAL_PAD)

    -- Vertical separator between the two sides.
    local vsep = pop:CreateTexture(nil, "ARTWORK")
    vsep:SetWidth(1)
    vsep:SetPoint("TOP",    pop, "TOP",    -(CAL_SEP_GAP / 2), -CAL_PAD)
    vsep:SetPoint("BOTTOM", pop, "BOTTOM",  (CAL_SEP_GAP / 2),  (CAL_POPUP_H - CAL_PAD - CAL_SIDE_H))
    vsep:SetColorTexture(unpack(ZAF.COLOR_SEPARATOR))
    zAF_DisablePixelSnap(vsep)

    -- Vertical separator above the button row.
    local hsep = pop:CreateTexture(nil, "ARTWORK")
    hsep:SetHeight(1)
    hsep:SetPoint("BOTTOMLEFT",  pop, "BOTTOMLEFT",  CAL_PAD,  32 + CAL_PAD)
    hsep:SetPoint("BOTTOMRIGHT", pop, "BOTTOMRIGHT", -CAL_PAD, 32 + CAL_PAD)
    hsep:SetColorTexture(unpack(ZAF.COLOR_SEPARATOR))
    zAF_DisablePixelSnap(hsep)

    -- ── Time inputs ───────────────────────────────────────────────────────────

    local startTime = BuildTimeInput(pop, NC(ZAF.DEFAULT_PRIMARY), "From")
    startTime.frame:SetPoint("BOTTOMLEFT", pop, "BOTTOMLEFT",
        CAL_PAD, CAL_PAD + 34)

    local endTime = BuildTimeInput(pop, NC(ZAF.DEFAULT_PRIMARY), "To")
    endTime.frame:SetPoint("BOTTOMLEFT", pop, "BOTTOMLEFT",
        CAL_PAD + CAL_SIDE_W + CAL_SEP_GAP, CAL_PAD + 34)

    -- ── Apply / Clear buttons ─────────────────────────────────────────────────

    local applyBtn = zAF_BuildActionButton(pop, "Apply", nil, 70)
    applyBtn:SetPoint("BOTTOMRIGHT", pop, "BOTTOMRIGHT", -CAL_PAD, CAL_PAD)

    local clearBtn = zAF_BuildActionButton(pop, "Clear", nil, 60)
    clearBtn:SetPoint("RIGHT", applyBtn, "LEFT", -6, 0)

    -- ── Range state helpers ───────────────────────────────────────────────────

    local function RefreshBothSides()
        startSide.UpdateSelection(pop._startTs, pop._endTs)
        endSide.UpdateSelection(pop._startTs, pop._endTs)
    end

    local function OnDayClicked(y, m, d)
        -- First click → set start and clear end.
        -- If start already set and no end, set end (ensure ordering).
        -- If both set, restart with a new start click.
        if pop._startTs and not pop._endTs then
            local t = time({ year=y, month=m, day=d,
                hour=startTime.GetHour(), min=startTime.GetMin(), sec=0 })
            if t < pop._startTs then
                -- Clicked before current start → swap: make this the new start.
                pop._endTs   = pop._startTs
                pop._startTs = t
                local eh = tonumber(date("%H", pop._endTs))
                local em = tonumber(date("%M", pop._endTs))
                endTime.SetTime(eh, em)
                startTime.SetTime(startTime.GetHour(), startTime.GetMin())
            else
                pop._endTs = t
                endTime.SetTime(startTime.GetHour(), startTime.GetMin())
            end
        else
            -- Start fresh.
            pop._endTs   = nil
            pop._startTs = time({ year=y, month=m, day=d,
                hour=startTime.GetHour(), min=startTime.GetMin(), sec=0 })
            startTime.SetTime(startTime.GetHour(), startTime.GetMin())
        end
        RefreshBothSides()
    end

    -- Wire the day-click callbacks now that OnDayClicked is defined.
    -- Re-build both sides with the real callback.
    -- Rather than rebuilding, wire via a trampoline stored on startSide/endSide.
    for _, btn in ipairs(startSide.dayBtns) do
        btn:SetScript("OnClick", function(self)
            if self._day > 0 then OnDayClicked(self._year, self._month, self._day) end
        end)
    end
    for _, btn in ipairs(endSide.dayBtns) do
        btn:SetScript("OnClick", function(self)
            if self._day > 0 then OnDayClicked(self._year, self._month, self._day) end
        end)
    end

    -- ── Month navigation ──────────────────────────────────────────────────────

    local function Navigate(side, delta)
        local y = side.GetCurYear()
        local m = side.GetCurMonth() + delta
        if m < 1  then m = 12; y = y - 1 end
        if m > 12 then m = 1;  y = y + 1 end
        side.SetMonth(y, m)
        RefreshBothSides()
    end

    startSide.prevBtn:SetScript("OnMouseDown", function() Navigate(startSide, -1) end)
    startSide.nextBtn:SetScript("OnMouseDown", function() Navigate(startSide,  1) end)
    endSide.prevBtn:SetScript("OnMouseDown",   function() Navigate(endSide,   -1) end)
    endSide.nextBtn:SetScript("OnMouseDown",   function() Navigate(endSide,    1) end)

    -- ── Button actions ────────────────────────────────────────────────────────

    applyBtn:SetScript("OnClick", function()
        startTime.ClearFocus()
        endTime.ClearFocus()
        -- Recompose timestamps incorporating the current time-input values.
        local sTs = pop._startTs
        local eTs = pop._endTs
        if sTs then
            sTs = time({ year  = tonumber(date("%Y", sTs)),
                         month = tonumber(date("%m", sTs)),
                         day   = tonumber(date("%d", sTs)),
                         hour  = startTime.GetHour(),
                         min   = startTime.GetMin(),
                         sec   = 0 })
        end
        if eTs then
            eTs = time({ year  = tonumber(date("%Y", eTs)),
                         month = tonumber(date("%m", eTs)),
                         day   = tonumber(date("%d", eTs)),
                         hour  = endTime.GetHour(),
                         min   = endTime.GetMin(),
                         sec   = 59 })
        end
        pop._startTs = sTs
        pop._endTs   = eTs
        if pop._onApply then pop._onApply(sTs, eTs) end
        Popup_Hide(pop)
        if _calCatcher then _calCatcher:Hide() end
    end)

    clearBtn:SetScript("OnClick", function()
        pop._startTs = nil
        pop._endTs   = nil
        startTime.SetTime(0, 0)
        endTime.SetTime(23, 59)
        RefreshBothSides()
        if pop._onApply then pop._onApply(nil, nil) end
        Popup_Hide(pop)
        if _calCatcher then _calCatcher:Hide() end
    end)

    -- Store references for use in show().
    pop._startSide  = startSide
    pop._endSide    = endSide
    pop._startTime  = startTime
    pop._endTime    = endTime
    pop._applyBtn   = applyBtn
    pop._clearBtn   = clearBtn

    _calPopup = pop
end

-- Navigate a calendar side by delta months and refresh both sides' day highlighting.
-- Module-level so it can be called from outside EnsureCalPopup's closure.
---@param side  table
---@param delta number
local function CalPopupNavigate(side, delta)
    local y = side.GetCurYear()
    local m = side.GetCurMonth() + delta
    if m < 1  then m = 12; y = y - 1 end
    if m > 12 then m = 1;  y = y + 1 end
    side.SetMonth(y, m)
    _calPopup._startSide.UpdateSelection(_calPopup._startTs, _calPopup._endTs)
    _calPopup._endSide.UpdateSelection(_calPopup._startTs, _calPopup._endTs)
end

-- Format a timestamp for display on the trigger button (local time).
---@param ts number
---@return string
local function FormatPickerTS(ts)
    return date("%m/%d/%y %H:%M", ts)
end

-- Recolour the border and primary elements of the popup to match the caller's theme.
---@param primary   number[]
---@param secondary number[]
local function CalPopupApplyTheme(primary, secondary)
    zAF_DrawBorder(_calPopup, primary[1], primary[2], primary[3], 0.65)
    local PR, PG, PB = primary[1], primary[2], primary[3]
    local SR, SG, SB = secondary[1], secondary[2], secondary[3]
    local function RecolourSide(side)
        side.SetTheme(primary, secondary)
        side.monthLabel:SetTextColor(PR, PG, PB, 1)
        -- Re-wire all mouse scripts so hover/leave use this caller's colours,
        -- not the defaults baked in at lazy-init time.
        for _, arrowBtn in ipairs({ side.prevBtn, side.nextBtn }) do
            local norm = arrowBtn.normalTex
            norm:SetVertexColor(PR, PG, PB, 1)
            arrowBtn:SetScript("OnEnter", function()
                norm:SetVertexColor(SR, SG, SB, 1)
            end)
            arrowBtn:SetScript("OnLeave", function()
                norm:SetVertexColor(PR, PG, PB, 1)
            end)
            arrowBtn:SetScript("OnMouseDown", function()
                norm:SetVertexColor(0.72, 0.72, 0.72, 1)
            end)
            arrowBtn:SetScript("OnMouseUp", function()
                if arrowBtn:IsMouseOver() then
                    norm:SetVertexColor(SR, SG, SB, 1)
                else
                    norm:SetVertexColor(PR, PG, PB, 1)
                end
            end)
        end
        -- Refresh day button colors with the new theme by re-running selection.
        side.UpdateSelection(_calPopup._startTs, _calPopup._endTs)
    end
    RecolourSide(_calPopup._startSide)
    RecolourSide(_calPopup._endSide)
    _calPopup._startTime.SetTheme(primary)
    _calPopup._endTime.SetTheme(primary)
    _calPopup._applyBtn._overlay:SetColorTexture(secondary[1], secondary[2], secondary[3], 1)
    _calPopup._clearBtn._overlay:SetColorTexture(secondary[1], secondary[2], secondary[3], 1)
end

--- Build a date-range picker button that opens a calendar flyout.
--- The picker stores and returns absolute Unix timestamps (local-time epoch as
--- returned by time()). The flyout displays dates in the user's local time zone
--- because date() operates in local time.
---@param cell      Frame      Parent cell frame (SetHeight called at end to DD_H).
---@param primary   table|nil  { r, g, b } accent colour. Defaults to context primary or ZAF.DEFAULT_PRIMARY.
---@param secondary table|nil  { r, g, b } hover colour.  Defaults to context secondary or ZAF.DEFAULT_SECONDARY.
---@return table result
---  result.frame        Button       Trigger button.
---  result.text         FontString   Label showing current range.
---  result.GetRange     fun(): number|nil, number|nil
---  result.SetRange     fun(startTs:number|nil, endTs:number|nil)
---  result.ClearRange   fun()
---  result.SetOnChanged fun(fn: fun(startTs:number|nil, endTs:number|nil))
---  result.OpenPicker   fun()
---  result.ClosePicker  fun()
function zAF_BuildDateRangePicker(cell, primary, secondary)
    primary   = NC(primary   or CtxPri() or ZAF.DEFAULT_PRIMARY)
    secondary = NC(secondary or CtxSec() or ZAF.DEFAULT_SECONDARY)

    local cellW = cell:GetWidth()

    local btn, btnText, _ = zAF_MakeDropButton(cell, cellW, primary)
    btn:SetPoint("TOPLEFT", cell, "TOPLEFT", 0, 0)

    local ANY_DATE_LABEL = "Any Date"

    ---@type number|nil
    local startTs = nil
    ---@type number|nil
    local endTs   = nil
    ---@type fun(s:number|nil, e:number|nil)|nil
    local onChanged = nil

    local function UpdateLabel()
        if startTs or endTs then
            local s = startTs and FormatPickerTS(startTs) or "..."
            local e = endTs   and FormatPickerTS(endTs)   or "..."
            btnText:SetText(s .. " – " .. e)
        else
            btnText:SetText(ANY_DATE_LABEL)
        end
    end

    UpdateLabel()

    local function OpenPicker()
        EnsureCalPopup()
        EnsureCalCatcher()

        -- If another owner already has it open, pass-through as a toggle.
        if _calPopup:IsShown() and _calPopup._owner == btn then
            Popup_Hide(_calPopup)
            return
        end
        if _calPopup:IsShown() then
            Popup_Hide(_calPopup)
        end

        _calPopup._owner     = btn
        _calPopup._startTs   = startTs
        _calPopup._endTs     = endTs
        _calPopup._primary   = primary
        _calPopup._secondary = secondary
        _calPopup._onApply   = function(sTs, eTs)
            startTs = sTs
            endTs   = eTs
            UpdateLabel()
            if onChanged then onChanged(startTs, endTs) end
        end

        -- Navigate start-side to start date (or today), end-side one month ahead.
        local nowY = tonumber(date("%Y"))
        local nowM = tonumber(date("%m"))
        local sY, sM = nowY, nowM
        local eY, eM = nowY, nowM + 1
        if eM > 12 then eM = 1; eY = eY + 1 end

        if startTs then
            sY = tonumber(date("%Y", startTs))
            sM = tonumber(date("%m", startTs))
            eY, eM = sY, sM + 1
            if eM > 12 then eM = 1; eY = eY + 1 end
        end
        if endTs then
            eY = tonumber(date("%Y", endTs))
            eM = tonumber(date("%m", endTs))
        end

        _calPopup._startSide.SetMonth(sY, sM)
        _calPopup._endSide.SetMonth(eY, eM)
        _calPopup._startSide.UpdateSelection(startTs, endTs)
        _calPopup._endSide.UpdateSelection(startTs, endTs)

        -- Apply theme AFTER setting up the calendar so day button colors use the caller's theme.
        CalPopupApplyTheme(primary, secondary)

        -- Re-apply Navigate handlers AFTER CalPopupApplyTheme to prevent them from being overwritten.
        -- Each handler combines navigation with the visual pressed-state feedback.
        _calPopup._startSide.prevBtn:SetScript("OnMouseDown", function()
            _calPopup._startSide.prevBtn.normalTex:SetVertexColor(0.72, 0.72, 0.72, 1)
            CalPopupNavigate(_calPopup._startSide, -1)
        end)
        _calPopup._startSide.nextBtn:SetScript("OnMouseDown", function()
            _calPopup._startSide.nextBtn.normalTex:SetVertexColor(0.72, 0.72, 0.72, 1)
            CalPopupNavigate(_calPopup._startSide, 1)
        end)
        _calPopup._endSide.prevBtn:SetScript("OnMouseDown", function()
            _calPopup._endSide.prevBtn.normalTex:SetVertexColor(0.72, 0.72, 0.72, 1)
            CalPopupNavigate(_calPopup._endSide, -1)
        end)
        _calPopup._endSide.nextBtn:SetScript("OnMouseDown", function()
            _calPopup._endSide.nextBtn.normalTex:SetVertexColor(0.72, 0.72, 0.72, 1)
            CalPopupNavigate(_calPopup._endSide, 1)
        end)

        -- Restore time inputs from stored timestamps.
        if startTs then
            _calPopup._startTime.SetTime(
                tonumber(date("%H", startTs)), tonumber(date("%M", startTs)))
        else
            _calPopup._startTime.SetTime(0, 0)
        end
        if endTs then
            _calPopup._endTime.SetTime(
                tonumber(date("%H", endTs)), tonumber(date("%M", endTs)))
        else
            _calPopup._endTime.SetTime(23, 59)
        end

        -- Anchor: below the button if room, above if not.
        _calPopup:ClearAllPoints()
        local _, btnY = btn:GetCenter()
        if btnY and (btnY - btn:GetHeight() / 2 - CAL_POPUP_H) < 10 then
            _calPopup:SetPoint("BOTTOMLEFT", btn, "TOPLEFT", 0, 2)
        else
            _calPopup:SetPoint("TOPLEFT", btn, "BOTTOMLEFT", 0, -2)
        end

        _calCatcher:SetScript("OnMouseDown", function() Popup_Hide(_calPopup); _calCatcher:Hide() end)
        _calCatcher:Show()
        Popup_Show(_calPopup)
    end

    local function ClosePicker()
        if _calPopup then Popup_Hide(_calPopup) end
        if _calCatcher then _calCatcher:Hide() end
    end

    btn:SetScript("OnClick", function() OpenPicker() end)
    btn:SetScript("OnEnter", function()
        zAF_DrawBorder(btn, ZAF.COLOR_BORDER[1], ZAF.COLOR_BORDER[2], ZAF.COLOR_BORDER[3], 0.80)
    end)
    btn:SetScript("OnLeave", function() zAF_DrawBorder(btn) end)

    cell:SetHeight(DD_H)

    return {
        frame        = btn,
        text         = btnText,
        ---@return number|nil, number|nil
        GetRange     = function() return startTs, endTs end,
        ---@param sTs number|nil
        ---@param eTs number|nil
        SetRange     = function(sTs, eTs)
            startTs = sTs
            endTs   = eTs
            UpdateLabel()
        end,
        ClearRange   = function()
            startTs = nil
            endTs   = nil
            UpdateLabel()
            if _calPopup and _calPopup._owner == btn and _calPopup:IsShown() then
                _calPopup._startTs = nil
                _calPopup._endTs   = nil
                _calPopup._startSide.UpdateSelection(nil, nil)
                _calPopup._endSide.UpdateSelection(nil, nil)
            end
        end,
        ---@param fn fun(startTs:number|nil, endTs:number|nil)
        SetOnChanged = function(fn) onChanged = fn end,
        OpenPicker   = OpenPicker,
        ClosePicker  = ClosePicker,
    }
end

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