-- zSettingsFrame/zSF_Constructor.lua
-- Schema walker, ControlMixin, BUILDERS, and row auto-packing layout.
-- Walks a schema table and turns each entry into a live widget frame,
-- wired to the adapter for data binding.
-- Addon-agnostic: all DB access goes through the registered adapter.
local ZSF = _G.ZSF  -- namespace for style constants and easing functions

-- ── Width fraction mapping ───────────────────────────────────────────────────

local WIDTH_FRACTIONS = {
    full    = 1.0,
    half    = 0.5,
    third   = 1 / 3,
    quarter = 0.25,
}

--- Return the effective widthKey for a schema entry.
--- When hosted inside WoW Settings: use widthBlizzard if supplied, otherwise
--- force "full" so all widgets expand to fill the narrower content column.
--- The normal width field is ignored entirely in hosted mode.
---@param entry   table   Schema entry (fields: width, widthBlizzard)
---@param default string  Fallback widthKey when entry provides nothing.
---@return string
local function ResolveWidth(entry, default)
    if zSF_IsHosted and zSF_IsHosted() then
        return entry.widthBlizzard or "full"
    end
    return entry.width or default
end

-- Defaults per widget type when width is omitted.
-- PILL_H mirrors the constant in zSF_Widgets but lives here to size partial-width toggle rows.
local PARTIAL_TOGGLE_H = 20  -- cell content height (matches DD_H / SWATCH_H for aligned rows)
local PILL_W           = 36  -- toggle pill width, mirrored from zSF_Widgets for label offset
local SLIDER_CELL_H    = 34  -- track(12) + gap(4) + input(18); matches zSF_BuildSlider output

local DEFAULT_WIDTHS = {
    header  = "full",
    spacer  = "full",
    button  = "third",  -- up to 3 buttons pack into one row by default
    filler  = "full",
    text    = "full",
    toggle  = "full",
    input   = "full",
    group   = "full",
    tab     = "full",
    -- All widget types default to full-width unless the schema entry
    -- explicitly sets width = "quarter" | "third" | "half".
    range      = "full",
    select     = "full",
    -- color/colorAlpha: inline swatch row, always full-width.
    color      = "full",
    colorAlpha = "full",
    fontSelect = "full",
    fontFlags  = "full",
    sound      = "full",
    bgSelect   = "full",
    barSelect  = "full",
    borderSelect = "full",
    -- Variable-height full-width widgets.
    textarea  = "full",
    output    = "full",
    orderList = "full",
    -- Multi-check picker and keybinding capture.
    multiselect = "full",
    keybind     = "full",
}

--- Return the effective content side padding for the current hosting context.
--- Returns 8 when hosted inside WoW's SettingsPanel — enough breathing room from
--- the stripe edge without wasting the constrained canvas width.
--- StampRowStripe uses math.max(GetSidePad()-8, 0) so stripes still reach
--- edge-to-edge at this value while widget content is inset by 8px.
---@return number
local function GetSidePad()
    if zSF_IsHosted and zSF_IsHosted() then return 8 end
    return ZSF.CONTENT_PAD_X
end

--- Action-column width that matches a non-first-column quarter cell.
--- Quarter cells at column positions 2+ have their usable width reduced by
--- ZSF.COL_INNER_PAD (the inset that prevents widgets touching the divider).
--- Subtracting it here keeps full-row and half-row action controls at the
--- same visual size as the common (non-first) quarter cell.
---@return number
local function GetActionW()
    local cw = zSF_GetContentWidth()
    if cw == 0 then return ZSF.ACTION_COL_WIDTH end
    local pad = GetSidePad()
    return math.floor(0.25 * (cw - 5 * pad)) - ZSF.COL_INNER_PAD
end

--- Interactable width matching a non-first-column third cell.
---@return number
local function GetThirdW()
    local cw = zSF_GetContentWidth()
    if cw == 0 then return ZSF.ACTION_COL_WIDTH end
    local pad = GetSidePad()
    return math.floor((1/3) * (cw - 4 * pad)) - ZSF.COL_INNER_PAD
end

--- Resolve the interactable widget width for a schema entry.
--- Returns a third-column width when entry.interactableWidth = "third",
--- otherwise defaults to the quarter-column width.
---@param entry table
---@return number
local function GetInteractableW(entry)
    if entry.interactableWidth == "third" then return GetThirdW() end
    return GetActionW()
end

-- Widget label height and row gap (kept consistent with prototype).
local WIDGET_LABEL_H   = 16
-- Total height of the stacked [label + 2px gap + content] block for third/quarter widgets.
-- Used to dynamically center the block in whatever row height is in effect.
local LABELED_BLOCK_H  = WIDGET_LABEL_H + 2 + PARTIAL_TOGGLE_H  -- 16 + 2 + 20 = 38

-- Vertical top-offsets for labels centred in ZSF.ROW_HEIGHT rows.
-- Single label (~14 px) centred in 50: top = (50-14)/2 = 18.
-- Label + desc block (~28 px) centred in 50: top = (50-28)/2 = 11.
local ROW_LABEL_Y      = -math.floor((ZSF.ROW_HEIGHT - 14) / 2)
local ROW_LABEL_DESC_Y = -math.floor((ZSF.ROW_HEIGHT - 28) / 2)

local WIDGET_ROW_GAP -- set at build time from ZSF.CONTENT_PAD_X

-- ── Row stripe system ────────────────────────────────────────────────────────
-- Tracks how many content rows have been placed so that odd and even rows get
-- alternating background tints.  The counter is per-parent so each tab/page
-- starts fresh.  Headers do not count toward the stripe and do not reset it,
-- so the pattern flows uninterrupted across section boundaries.

local _rowCounters = {}

--- Increment the row counter for parent and stamp an alternating bg texture
--- onto frame.  Frame must already be parented to (or anchored inside) parent.
---@param frame  Frame
---@param parent Frame
---@param colors table  Effective panel colors (from zSF_BuildFromSchema or _eSnap).
local function StampRowStripe(frame, parent, colors)
    if not _rowCounters[parent] then _rowCounters[parent] = 0 end
    _rowCounters[parent] = _rowCounters[parent] + 1
    local col = (_rowCounters[parent] % 2 == 0) and (colors.rowStripeEven or ZSF.COLOR_ROW_STRIPE_EVEN) or (colors.rowStripeOdd or ZSF.COLOR_ROW_STRIPE_ODD)
    local bg = frame:CreateTexture(nil, "BACKGROUND", nil, -1)
    local sideX = GetSidePad()
    local stripeInset = math.max(sideX - 8, 0)
    bg:SetPoint("TOPLEFT",     frame, "TOPLEFT",      stripeInset, 0)
    bg:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -stripeInset, 0)
    bg:SetColorTexture(col[1], col[2], col[3], col[4])
    -- Keep a reference so zSF_RelayoutContentFrame can recolor on showWhen changes.
    frame._zsfStripeBg = bg
end

-- ── ControlMixin ──────────────────────────────────────────────────────────────
-- Mixed into every widget frame built by the constructor.

local ControlMixin = {}

---@param schemaEntry table       The schema entry for this widget.
---@param adapter     zSF_Adapter The registered adapter.
---@param trackerID   number|nil  Tracker index (0-4) or nil for global settings.
function ControlMixin:OnLoad(schemaEntry, adapter, trackerID)
    self._dbKey          = schemaEntry.key
    self._trackerID    = trackerID
    self._trackerKey   = schemaEntry.trackerKey
    self._visibleWhen     = schemaEntry.showWhen
    self._disabledWhen  = schemaEntry.disableWhen
    self._overrideGet    = schemaEntry.get
    self._overrideSet    = schemaEntry.set
    self._adapter      = adapter
    self._childWidgets     = {}
    self._cachedValue = nil
end

--- Read the current value from the DB via adapter.get.
function ControlMixin:ReadValue()
    if self._overrideGet then
        self._cachedValue = self._overrideGet(self._trackerID)
        return
    end
    if not self._dbKey then return end
    local fullKey = self:_ResolveKey()
    self._cachedValue = self._adapter.get(fullKey)
    if self._cachedValue == nil then
        self._cachedValue = self._adapter.getDefault(fullKey)
    end
end

--- Write the local value to the DB via adapter.set.
function ControlMixin:WriteValue()
    if self._overrideSet then
        if self._ctx then self._ctx._selfWriting = true end
        self._overrideSet(self._cachedValue, self._trackerID)
        if self._ctx then self._ctx._selfWriting = false end
        return
    end
    if not self._dbKey then return end
    if self._ctx then self._ctx._selfWriting = true end
    self._adapter.set(self:_ResolveKey(), self._cachedValue)
    if self._ctx then self._ctx._selfWriting = false end
end

--- Build the full DB key, prepending tracker prefix when applicable.
---@return string
function ControlMixin:_ResolveKey()
    if self._trackerKey and self._trackerID ~= nil then
        return "t" .. self._trackerID .. self._dbKey
    end
    return self._dbKey
end

---@param value any
function ControlMixin:SetCachedValue(value)
    self._cachedValue = value
end

---@return any
function ControlMixin:GetCachedValue()
    return self._cachedValue
end

--- Refresh UI state from the DB. Called on panel show and profile switches.
---@param force boolean|nil
function ControlMixin:Refresh(force)
    -- Conditional visibility.
    if self._visibleWhen then
        local shouldShow = self._visibleWhen(self._trackerID)
        if self:IsShown() ~= shouldShow then
            self:SetShown(shouldShow)
            -- Reflow sibling rows to close/open the gap this widget occupied.
            if self._layoutFrame then
                zSF_RelayoutContentFrame(self._layoutFrame)
            end
        end
    end
    -- Conditional disable.
    if self._disabledWhen then
        local shouldDisable = self._disabledWhen(self._trackerID)
        -- Only apply changes when state actually flips to avoid redundant work.
        if shouldDisable ~= self._wasDisabled then
            self._wasDisabled = shouldDisable
            self:SetAlpha(shouldDisable and ((self._ctx and self._ctx._colors and self._ctx._colors.disabledAlpha) or ZSF.DISABLED_ALPHA) or 1.0)
            -- Lazy-create a blocking overlay Frame the first time a widget is
            -- disabled.  A Frame (not a Texture) is required so it can absorb
            -- mouse events on all child controls via a higher frame level.
            if shouldDisable and not self._disOverlay then
                local ov = CreateFrame("Frame", nil, self)
                ov:SetAllPoints()
                ov:SetFrameLevel(self:GetFrameLevel() + 50)
                ov:EnableMouse(true)  -- swallows clicks/drags on child controls
                local ovTex = ov:CreateTexture(nil, "OVERLAY")
                ovTex:SetAllPoints()
                ovTex:SetColorTexture(0, 0, 0, 0)
                self._disOverlay = ov
            end
            if self._disOverlay then
                self._disOverlay:SetShown(shouldDisable)
            end
        end
    end
    -- Widget-specific refresh.
    if self._refreshHandler then
        self._refreshHandler(self, force)
    end
end

---@param handler fun(self: table, force: boolean|nil)
function ControlMixin:SetRefreshHandler(handler)
    self._refreshHandler = handler
end

--- Mix ControlMixin methods into a frame and register it for refresh.
---@param frame       Frame
---@param schemaEntry table
---@param adapter     zSF_Adapter
---@param trackerID   number|nil
local function ApplyControlMixin(frame, schemaEntry, adapter, trackerID)
    for k, v in pairs(ControlMixin) do
        frame[k] = v
    end
    frame:OnLoad(schemaEntry, adapter, trackerID)
    frame:ReadValue()
    frame._ctx = zSettingsFrame._buildCtx
    if frame._ctx then
        table.insert(frame._ctx._widgets, frame)
    end
end

-- ── Confirm gate ─────────────────────────────────────────────────────────────

--- Call commitFn immediately, or show a confirm dialog first if confirmText is set.
--- revertFn is called if the user dismisses the dialog without confirming.
---@param ctx         zSF_AddonContext
---@param confirmText string|nil
---@param commitFn    fun()
---@param revertFn    fun()|nil
local function MaybeConfirm(ctx, confirmText, commitFn, revertFn)
    if confirmText then
        ctx:ShowConfirm(confirmText, commitFn, revertFn)
    else
        commitFn()
    end
end

-- ── Individual builders ──────────────────────────────────────────────────────
-- Each builder returns (widget, contentFrame).
-- contentFrame is only set for group/tab types that contain child widgets.

---@param entry     table
---@param parent    Frame
---@param adapter   zSF_Adapter
---@param trackerID number|nil
---@param primary   table
---@param secondary table
---@return Frame widget
---@return Frame|nil contentFrame

-- ── Shared desc helpers ───────────────────────────────────────────────────────

--- Returns true when the inline desc text should be rendered.
--- On partial-width rows the default is to suppress it; set descVisibility = "show" to force it.
--- On full-width rows descVisibility = "hide" suppresses it.
---@param entry     table
---@param isPartial boolean|nil  True when the row is half, third, or quarter wide.
---@return boolean
local function ShouldShowDescInline(entry, isPartial)
    if not entry.desc then return false end
    if zSF_IsHosted and zSF_IsHosted() then return false end
    if isPartial then
        return entry.descVisibility == "show"
    end
    return entry.descVisibility ~= "hide"
end

--- Returns true when the hover tooltip should be shown.
--- tooltipVisibility = "hide" suppresses it regardless of descVisibility.
---@param entry table
---@return boolean
local function ShouldShowTooltip(entry)
    return entry.desc ~= nil and entry.tooltipVisibility ~= "hide"
end

--- Position a label in the left side of a half-width cell, adding inline desc
--- text and/or a hover tooltip based on descVisibility.
---@param lbl      FontString  Already-created, text already set.
---@param cell     Frame       The row cell this label lives in.
---@param labelW   number      Width cap for the label column.
---@param entry    table       Schema entry.
---@param secondary table      { r, g, b } accent colour for the tooltip title.
---@param colors   table       Effective panel colors.
local function LayoutHalfLabel(lbl, cell, labelW, entry, secondary, colors)
    local ZSF_COLOR_DESC = colors.desc or ZSF.COLOR_DESC
    local showDesc = ShouldShowDescInline(entry, true)
    lbl:SetWidth(labelW)
    lbl:SetPoint("TOPLEFT", cell, "TOPLEFT", 0, showDesc and ROW_LABEL_DESC_Y or ROW_LABEL_Y)
    if entry.desc then
        if showDesc then
            local descFS = zSF_Text(cell, "GameFontHighlightSmall")
            descFS:SetText(entry.desc)
            descFS:SetTextColor(unpack(ZSF_COLOR_DESC))
            descFS:SetPoint("TOPLEFT", lbl, "BOTTOMLEFT", 0, -2)
            descFS:SetWidth(labelW)
        else
            if ShouldShowTooltip(entry) then
                zSF_AddTooltip(lbl, entry.name or "", entry.desc, secondary)
            end
        end
    end
end

--- Build the standard full-width label/desc block and action column frame.
--- Shared preamble across all full-width builders.
---@param row       Frame      Row frame, already sized.
---@param entry     table      Schema entry; reads .name, .desc, .tooltipVisibility.
---@param secondary table      Accent colour for tooltip title.
---@param colors    table      Effective panel colors.
---@param aw        number|nil Override action column width. Defaults to GetActionW().
---@return FontString label  The name label.
---@return Frame      action Action column frame pinned to the row's right.
---@return number     aw     Action column pixel width.
local function BuildFullWidthLabel(row, entry, secondary, colors, aw)
    local ZSF_COLOR_LABEL = colors.label or ZSF.COLOR_LABEL
    local ZSF_COLOR_DESC  = colors.desc  or ZSF.COLOR_DESC
    local PAD_X   = GetSidePad()
    aw = aw or GetActionW()
    local LABEL_W = zSF_GetContentWidth() - aw - (PAD_X * 2) - 8

    local label = zSF_Text(row, "GameFontNormalLarge")
    label:SetText(entry.name or "")
    label:SetTextColor(unpack(ZSF_COLOR_LABEL))
    local showFullDesc = ShouldShowDescInline(entry)
    label:SetPoint("TOPLEFT", row, "TOPLEFT", PAD_X, showFullDesc and ROW_LABEL_DESC_Y or ROW_LABEL_Y)
    label:SetWidth(LABEL_W)

    if entry.desc then
        if showFullDesc then
            local desc = zSF_Text(row, "GameFontHighlightSmall")
            desc:SetText(entry.desc)
            desc:SetTextColor(unpack(ZSF_COLOR_DESC))
            desc:SetPoint("TOPLEFT", label, "BOTTOMLEFT", 0, -2)
            desc:SetWidth(LABEL_W)
        else
            if ShouldShowTooltip(entry) then
                zSF_AddTooltip(label, entry.name or "", entry.desc, secondary)
            end
        end
    end

    local action = CreateFrame("Frame", nil, row)
    action:SetPoint("RIGHT", row, "RIGHT", -PAD_X, 0)
    action:SetSize(aw, row:GetHeight())
    return label, action, aw
end

-- ── Toggle ───────────────────────────────────────────────────────────────────

local function Build_Toggle(entry, parent, adapter, trackerID, primary, secondary, colors)
    local ZSF_COLOR_LABEL = colors.label or ZSF.COLOR_LABEL
    local ZSF_COLOR_DESC  = colors.desc  or ZSF.COLOR_DESC
    local _confirm    = entry.confirm
    local _confirmOn  = entry.confirmOn
    local _confirmOff = entry.confirmOff
    local row = CreateFrame("Frame", nil, parent)
    ApplyControlMixin(row, entry, adapter, trackerID)

    local widthKey = ResolveWidth(entry, DEFAULT_WIDTHS.toggle)
    local fraction = WIDTH_FRACTIONS[widthKey] or 1.0

    -- Partial-width: pill and label always managed inside the cell, not by FlushRow.
    if fraction < 0.999 then
        row:SetHeight(ZSF.ROW_HEIGHT)
        -- Tell FlushRow not to add a label row above this widget.
        row._selfLabeled = true
        -- Font is a compile-time decision: third gets large, quarter gets normal.
        local fontObj = (fraction >= 0.33) and "GameFontNormalLarge" or "GameFontNormal"
        row._buildWidget = function(cell, frac)
            local isHalf = (frac and frac >= 0.499)
            local curVal = row:GetCachedValue()
            local pill   = zSF_MakePillToggle(cell, secondary, colors.toggleIndicatorColor)

            if isHalf then
                -- Half: label may use almost the full cell — pill is small, so describe
                -- available width as the gap right up to the pill's left edge.
                local labelW = cell:GetWidth() - PILL_W - ZSF.COL_INNER_PAD
                local lbl = zSF_Text(cell, "GameFontNormalLarge")
                lbl:SetText(entry.name or "")
                lbl:SetTextColor(unpack(ZSF_COLOR_LABEL))
                LayoutHalfLabel(lbl, cell, labelW, entry, secondary, colors)
                pill:SetPoint("RIGHT", cell, "RIGHT", 0, 0)
            else
                -- Third/quarter: name on the left, pill on the right.
                -- fontObj was decided at build time so third and quarter are
                -- explicitly different sizes without runtime fraction checks here.
                local lbl = zSF_Text(cell, fontObj)
                lbl:SetText(entry.name or "")
                lbl:SetTextColor(unpack(ZSF_COLOR_LABEL))
                lbl:SetPoint("LEFT",  cell,   "LEFT",  0, 0)
                lbl:SetPoint("RIGHT", cell, "RIGHT", -(PILL_W + 6), 0)
                if ShouldShowTooltip(entry) then
                    zSF_AddTooltip(lbl, entry.name or "", entry.desc, secondary)
                end
                pill:SetPoint("RIGHT", cell, "RIGHT", 0, 0)
            end

            pill:SetChecked(curVal == true)
            pill:SetScript("OnClick", function(self)
                local proposed = not self.checked
                local confirmMsg = _confirm
                    or (proposed     and _confirmOn)
                    or (not proposed and _confirmOff)
                if confirmMsg then
                    MaybeConfirm(row._ctx, confirmMsg, function()
                        pill:SetChecked(proposed)
                        row:SetCachedValue(proposed)
                        row:WriteValue()
                        row._ctx:RefreshAll()
                    end)
                    return
                end
                pill._animating = true
                pill.DoClick(self)
                row:SetCachedValue(self:GetChecked())
                row:WriteValue()
                row._ctx:RefreshAll()
            end)
            row:SetRefreshHandler(function(self)
                self:ReadValue()
                if pill._animating then
                    pill._animating = nil
                    return
                end
                pill:SetChecked(self:GetCachedValue() == true)
            end)
        end
        return row, nil
    end

    -- Full-width: label on the left, pill on the right in the action column.
    row:SetHeight(ZSF.ROW_HEIGHT)
    StampRowStripe(row, parent, colors)

    local _, action = BuildFullWidthLabel(row, entry, secondary, colors)

    local pill = zSF_MakePillToggle(action, secondary, colors.toggleIndicatorColor)
    pill:SetPoint("RIGHT", action, "RIGHT", 0, 0)

    local curVal = row:GetCachedValue()
    pill:SetChecked(curVal == true)

    pill:SetScript("OnClick", function(self)
        local proposed = not self.checked
        local confirmMsg = _confirm
            or (proposed     and _confirmOn)
            or (not proposed and _confirmOff)
        if confirmMsg then
            MaybeConfirm(row._ctx, confirmMsg, function()
                pill:SetChecked(proposed)
                row:SetCachedValue(proposed)
                row:WriteValue()
                row._ctx:RefreshAll()
            end)
            return
        end
        pill._animating = true
        pill.DoClick(self)
        row:SetCachedValue(self:GetChecked())
        row:WriteValue()
        row._ctx:RefreshAll()
    end)

    row:SetRefreshHandler(function(self)
        self:ReadValue()
        if pill._animating then
            pill._animating = nil
            return
        end
        pill:SetChecked(self:GetCachedValue() == true)
    end)

    return row, nil
end

-- ── Range (slider) ───────────────────────────────────────────────────────────

--- Helper: wire slider result to the row's data binding.
---@param row    Frame
---@param entry  table
---@param result table  Return value of zSF_BuildSlider.
local function WireSlider(row, entry, result)
    local _confirm = entry.confirm
    local _min     = entry.min or 0
    result.SetOnChanged(function(val)
        local oldVal = row:GetCachedValue()
        MaybeConfirm(row._ctx, _confirm,
            function()
                row:SetCachedValue(val)
                row:WriteValue()
            end,
            function()
                result.SetValue(oldVal)
            end
        )
    end)
    row:SetRefreshHandler(function(self)
        self:ReadValue()
        result.SetValue(self:GetCachedValue() or _min)
    end)
end

local function Build_Range(entry, parent, adapter, trackerID, primary, secondary, colors)
    local ZSF_COLOR_LABEL = colors.label or ZSF.COLOR_LABEL
    local ZSF_COLOR_DESC  = colors.desc  or ZSF.COLOR_DESC
    local row = CreateFrame("Frame", nil, parent)
    ApplyControlMixin(row, entry, adapter, trackerID)

    local widthKey = ResolveWidth(entry, DEFAULT_WIDTHS.range)
    local fraction = WIDTH_FRACTIONS[widthKey] or 1.0

    if fraction >= 0.999 then
        -- Full-width: label+desc on left, slider vertically centred in the action column.
        row:SetHeight(ZSF.ROW_HEIGHT)
        StampRowStripe(row, parent, colors)

        local _, action, aw = BuildFullWidthLabel(row, entry, secondary, colors)

        local sliderCell = CreateFrame("Frame", nil, action)
        sliderCell:SetWidth(aw)
        sliderCell:SetHeight(SLIDER_CELL_H)
        sliderCell:SetPoint("TOPLEFT", action, "TOPLEFT", 0,
            -math.floor((ZSF.ROW_HEIGHT - SLIDER_CELL_H) / 2))

        local curVal = row:GetCachedValue() or entry.value or entry.min or 0
        local result = zSF_BuildSlider(sliderCell,
            entry.min or 0, entry.max or 100, curVal,
            entry.step or 1, primary, secondary)
        WireSlider(row, entry, result)

        return row, nil
    end

    if fraction >= 0.499 then
        -- Half-width: label on left, slider on right at action-column width.
        row:SetHeight(ZSF.ROW_HEIGHT)
        row._buildWidget = function(cell, frac)
            local gap     = GetSidePad()
            local sliderW = GetActionW()
            local labelW  = cell:GetWidth() - sliderW - gap

            local lbl = zSF_Text(cell, "GameFontNormalLarge")
            lbl:SetText(entry.name or "")
            lbl:SetTextColor(unpack(ZSF_COLOR_LABEL))
            LayoutHalfLabel(lbl, cell, labelW, entry, secondary, colors)

            local sliderCell = CreateFrame("Frame", nil, cell)
            sliderCell:SetWidth(sliderW)
            sliderCell:SetHeight(SLIDER_CELL_H)
            sliderCell:SetPoint("TOPRIGHT", cell, "TOPRIGHT", 0,
                -math.floor((cell:GetHeight() - SLIDER_CELL_H) / 2))

            local curVal = row:GetCachedValue() or entry.value or entry.min or 0
            local result = zSF_BuildSlider(sliderCell,
                entry.min or 0, entry.max or 100, curVal,
                entry.step or 1, primary, secondary)
            WireSlider(row, entry, result)
        end
        return row, nil
    end

    if fraction >= 0.33 then
        -- Third: FlushRow adds label above; inline slider centred in cell.
        row:SetHeight(ZSF.ROW_HEIGHT)
        row._buildWidget = function(cell, frac)
            local sliderHost = CreateFrame("Frame", nil, cell)
            sliderHost:SetSize(cell:GetWidth(), PARTIAL_TOGGLE_H)
            sliderHost:SetPoint("CENTER", cell, "CENTER", 0, 0)
            local curVal = row:GetCachedValue() or entry.value or entry.min or 0
            local result = zSF_BuildSlider(sliderHost,
                entry.min or 0, entry.max or 100, curVal,
                entry.step or 1, primary, secondary, true)
            WireSlider(row, entry, result)
        end
        return row, nil
    end

    -- Quarter: inline slider centred in cell.
    row:SetHeight(ZSF.ROW_HEIGHT)
    row._buildWidget = function(cell, frac)
        local sliderHost = CreateFrame("Frame", nil, cell)
        sliderHost:SetSize(cell:GetWidth(), PARTIAL_TOGGLE_H)
        sliderHost:SetPoint("CENTER", cell, "CENTER", 0, 0)
        local curVal = row:GetCachedValue() or entry.value or entry.min or 0
        local result = zSF_BuildSlider(sliderHost,
            entry.min or 0, entry.max or 100, curVal,
            entry.step or 1, primary, secondary, true)
        WireSlider(row, entry, result)
    end

    return row, nil
end

-- ── Select (dropdown) ────────────────────────────────────────────────────────

--- Wire a dropdown result to its row data binding and menu accessors.
---@param row         Frame
---@param entry       table
---@param ddResult    table  Return value of zSF_BuildDropdown.
---@param onAfterPick fun(key: string)|nil  Called after a successful pick.
---@param onDecorate  fun(ddResult: table, key: string)|nil  Decorates the button on any value change.
local function WireDropdown(row, entry, ddResult, onAfterPick, onDecorate)
    row._dropdown = ddResult
    local _values  = entry.values
    local _order   = entry.order
    local _confirm = entry.confirm

    -- Resolve the display label for a given raw key value.
    local function LabelForKey(k)
        if not k then return "---" end
        local resolved = type(_values) == "function" and _values() or _values
        if resolved and resolved[k] then return tostring(resolved[k]) end
        return tostring(k)
    end

    -- Provide the popup menu with live accessors and a pick callback.
    if ddResult.SetMenuAccessors then
        ddResult.SetMenuAccessors(
            -- Current key getter.
            function() return row:GetCachedValue() end,
            -- Values + order getter.
            function()
                local resolved = type(_values) == "function" and _values() or _values
                return resolved or {}, _order
            end,
            -- Selection callback.
            function(key, label)
                local oldKey   = row:GetCachedValue()
                local oldLabel = LabelForKey(oldKey)
                MaybeConfirm(row._ctx, _confirm,
                    function()
                        row:SetCachedValue(key)
                        row:WriteValue()
                        ddResult.text:SetText(label)
                        if onAfterPick then onAfterPick(key) end
                        if onDecorate  then onDecorate(ddResult, key) end
                    end,
                    function()
                        ddResult.text:SetText(oldLabel)
                        if onDecorate  then onDecorate(ddResult, oldKey) end
                    end
                )
            end
        )
    end

    row:SetRefreshHandler(function(self)
        self:ReadValue()
        local v = self:GetCachedValue()
        ddResult.text:SetText(LabelForKey(v))
        if onDecorate then onDecorate(ddResult, v) end
    end)

    -- Resolve and display the correct label for the current value immediately.
    ddResult.text:SetText(LabelForKey(row:GetCachedValue()))
    if onDecorate then onDecorate(ddResult, row:GetCachedValue()) end
end

--- Helper: build a sub-frame DD centred vertically in a parent frame.
--- Build a vertically centred dropdown in a host frame.
---@param action  Frame
---@param w       number  Dropdown width.
---@param h       number  Dropdown height (DD_H).
---@param selText string|nil
---@param primary table
---@return table result  { frame, text, arrow }
local function BuildCentredDD(action, w, h, selText, primary)
    local ddCell = CreateFrame("Frame", nil, action)
    ddCell:SetWidth(w)
    ddCell:SetHeight(h)
    ddCell:SetPoint("CENTER", action, "CENTER", 0, 0)
    return zSF_BuildDropdown(ddCell, selText, primary)
end

local function Build_Select(entry, parent, adapter, trackerID, primary, secondary, colors)
    local ZSF_COLOR_LABEL = colors.label or ZSF.COLOR_LABEL
    local ZSF_COLOR_DESC  = colors.desc  or ZSF.COLOR_DESC
    local row = CreateFrame("Frame", nil, parent)
    ApplyControlMixin(row, entry, adapter, trackerID)

    local widthKey = ResolveWidth(entry, DEFAULT_WIDTHS.select)
    local fraction = WIDTH_FRACTIONS[widthKey] or 1.0

    if fraction >= 0.999 then
        -- Full-width: label+desc on left, dropdown centred in the action column.
        row:SetHeight(ZSF.ROW_HEIGHT)
        StampRowStripe(row, parent, colors)

        local _, action, aw = BuildFullWidthLabel(row, entry, secondary, colors, GetInteractableW(entry))

        local curVal  = row:GetCachedValue()
        local selText = curVal and tostring(curVal) or (entry.selected or "---")
        local result  = BuildCentredDD(action, aw, 20, selText, primary)
        WireDropdown(row, entry, result)

        return row, nil
    end

    if fraction >= 0.499 then
        -- Half-width: label on left half, dropdown on right half.
        row:SetHeight(ZSF.ROW_HEIGHT)
        row._buildWidget = function(cell, frac)
            local aw  = GetInteractableW(entry)

            local lbl = zSF_Text(cell, "GameFontNormalLarge")
            lbl:SetText(entry.name or "")
            lbl:SetTextColor(unpack(ZSF_COLOR_LABEL))
            LayoutHalfLabel(lbl, cell, cell:GetWidth() - aw - 8, entry, secondary, colors)

            local ddHost = CreateFrame("Frame", nil, cell)
            ddHost:SetWidth(aw)
            ddHost:SetHeight(20)
            ddHost:SetPoint("RIGHT", cell, "RIGHT", 0, 0)

            local curVal  = row:GetCachedValue()
            local selText = curVal and tostring(curVal) or (entry.selected or "---")
            local result  = zSF_BuildDropdown(ddHost, selText, primary)
            WireDropdown(row, entry, result)
        end
        return row, nil
    end

    if fraction >= 0.33 then
        -- Third: FlushRow adds label above; dropdown centred in cell.
        row:SetHeight(ZSF.ROW_HEIGHT)
        row._buildWidget = function(cell, frac)
            local ddHost = CreateFrame("Frame", nil, cell)
            ddHost:SetSize(cell:GetWidth(), PARTIAL_TOGGLE_H)
            ddHost:SetPoint("CENTER", cell, "CENTER", 0, 0)
            local curVal  = row:GetCachedValue()
            local selText = curVal and tostring(curVal) or (entry.selected or "---")
            local result  = zSF_BuildDropdown(ddHost, selText, primary)
            WireDropdown(row, entry, result)
        end
        return row, nil
    end

    -- Quarter: FlushRow adds label above; dropdown centred in cell.
    row:SetHeight(ZSF.ROW_HEIGHT)
    row._buildWidget = function(cell, frac)
        local ddHost = CreateFrame("Frame", nil, cell)
        ddHost:SetSize(cell:GetWidth(), PARTIAL_TOGGLE_H)
        ddHost:SetPoint("CENTER", cell, "CENTER", 0, 0)
        local curVal  = row:GetCachedValue()
        local selText = curVal and tostring(curVal) or (entry.selected or "---")
        local result  = zSF_BuildDropdown(ddHost, selText, primary)
        WireDropdown(row, entry, result)
    end

    return row, nil
end

-- ── Color ────────────────────────────────────────────────────────────────────
-- All widths supported.  Full: label+desc left, swatch in action column.
-- All widths: a small square swatch pinned to the right.
-- Quarter: _selfLabeled — square swatch pinned right, name fills left.
-- colorAlpha: alpha % sits left of the swatch on the same line.

local SWATCH_H_INLINE = 20  -- swatch square side (width = height)

--- Wire a plain RGB colour swatch to the row data binding.
--- Enables mouse interaction and wires the custom zSF colour picker.
---@param row      Frame
---@param entry    table
---@param swatch   Button
---@param swatchBg Texture
---@param primary  table|nil  { r,g,b } border colour; nil skips the border call.
local function WireColorSwatch(row, entry, swatch, swatchBg, primary)
    local _name    = entry.name or ""
    local _confirm = entry.confirm
    local function doReset()
        row._adapter.reset({ row:_ResolveKey() })
        row:Refresh()
        local v = row:GetCachedValue() or {}
        return v[1] or 1, v[2] or 1, v[3] or 1, nil
    end

    swatch:EnableMouse(true)
    swatch:SetScript("OnClick", function()
        local v = row:GetCachedValue() or {}
        local cr, cg, cb = v[1] or 1, v[2] or 1, v[3] or 1
        row._ctx:ShowColorPicker({
            r         = cr, g = cg, b = cb,
            title     = _name,
            onChange  = function(nr, ng, nb)
                row:SetCachedValue({ nr, ng, nb })
                -- Live-preview: skip DB write if a confirm is required on accept.
                if not _confirm then
                    row:WriteValue()
                end
                swatchBg:SetColorTexture(nr, ng, nb, 1)
                zSF_DisablePixelSnap(swatchBg)
            end,
            onAccept  = function(nr, ng, nb)
                MaybeConfirm(row._ctx, _confirm,
                    function()
                        row:SetCachedValue({ nr, ng, nb })
                        row:WriteValue()
                        swatchBg:SetColorTexture(nr, ng, nb, 1)
                        zSF_DisablePixelSnap(swatchBg)
                    end,
                    function()
                        -- Confirm cancelled: restore to the colour before the picker opened.
                        row:SetCachedValue({ cr, cg, cb })
                        row:WriteValue()
                        swatchBg:SetColorTexture(cr, cg, cb, 1)
                        zSF_DisablePixelSnap(swatchBg)
                    end
                )
            end,
            onCancel  = function(pr, pg, pb)
                row:SetCachedValue({ pr, pg, pb })
                row:WriteValue()
                swatchBg:SetColorTexture(pr, pg, pb, 1)
                zSF_DisablePixelSnap(swatchBg)
            end,
            resetFunction = doReset,
        })
    end)
    row:SetRefreshHandler(function(self)
        self:ReadValue()
        local v = self:GetCachedValue() or {}
        swatchBg:SetColorTexture(v[1] or 1, v[2] or 1, v[3] or 1, 1)
        zSF_DisablePixelSnap(swatchBg)
    end)
    zSF_DrawBorder(swatch, 0.30, 0.30, 0.30, 0.65)
end

--- Build a clickable colour swatch of a given size inside a parent frame.
---@param parent  Frame
---@param w       number  Pixel width.
---@param h       number  Pixel height.
---@param curVal  table
---@param entry   table
---@return Button swatch
---@return Texture swatchBg
--- Create a clickable colour swatch Button with a fill background.
---@param parent  Frame
---@param w       number  Pixel width.
---@param h       number  Pixel height.
---@param curVal  table   Initial { r,g,b } values.
---@param entry   table   Schema entry (fallback .r/.g/.b).
---@return Button  swatch
---@return Texture swatchBg
local function MakeSwatchButton(parent, w, h, curVal, entry)
    local swatch = CreateFrame("Button", nil, parent)
    swatch:SetSize(w, h)
    local swatchBg = zSF_FillBG(swatch,
        curVal[1] or entry.r or 1,
        curVal[2] or entry.g or 1,
        curVal[3] or entry.b or 1, 1)
    return swatch, swatchBg
end

local function Build_Color(entry, parent, adapter, trackerID, primary, secondary, colors)
    local ZSF_COLOR_LABEL = colors.label or ZSF.COLOR_LABEL
    local ZSF_COLOR_DESC  = colors.desc  or ZSF.COLOR_DESC
    local row = CreateFrame("Frame", nil, parent)
    ApplyControlMixin(row, entry, adapter, trackerID)

    local widthKey = ResolveWidth(entry, DEFAULT_WIDTHS.color)
    local fraction = WIDTH_FRACTIONS[widthKey] or 1.0

    if fraction >= 0.999 then
        -- Full-width: label+desc on left, swatch in action column.
        row:SetHeight(ZSF.ROW_HEIGHT)
        StampRowStripe(row, parent, colors)

        local _, action, aw = BuildFullWidthLabel(row, entry, secondary, colors)

        local curVal = row:GetCachedValue() or {}
        local swatch, swatchBg = MakeSwatchButton(action, SWATCH_H_INLINE, SWATCH_H_INLINE,
            curVal, entry)
        swatch:SetPoint("RIGHT", action, "RIGHT", 0, 0)
        WireColorSwatch(row, entry, swatch, swatchBg, primary)

        row._swatch = { bg = swatchBg }
        return row, nil
    end

    if fraction >= 0.499 then
        -- Half: label on left, swatch in action column on right — matches all other half widgets.
        row._selfLabeled = true
        row:SetHeight(ZSF.ROW_HEIGHT)
        row._buildWidget = function(cell, frac)
            local aw  = GetActionW()

            local lbl = zSF_Text(cell, "GameFontNormalLarge")
            lbl:SetText(entry.name or "")
            lbl:SetTextColor(unpack(ZSF_COLOR_LABEL))
            LayoutHalfLabel(lbl, cell, cell:GetWidth() - aw - 8, entry, secondary, colors)

            local curVal = row:GetCachedValue() or {}
            local swatch, swatchBg = MakeSwatchButton(cell, SWATCH_H_INLINE, SWATCH_H_INLINE, curVal, entry)
            swatch:SetPoint("RIGHT", cell, "RIGHT", 0, 0)
            WireColorSwatch(row, entry, swatch, swatchBg, primary)
            row._swatch = { bg = swatchBg }
        end
        return row, nil
    end

    if fraction >= 0.33 then
        -- Third: same inline layout as quarter — name left, swatch right.
        row._selfLabeled = true
        row:SetHeight(ZSF.ROW_HEIGHT)
        row._buildWidget = function(cell, frac)
            local curVal = row:GetCachedValue() or {}
            local swatch, swatchBg = MakeSwatchButton(cell, SWATCH_H_INLINE, SWATCH_H_INLINE, curVal, entry)
            swatch:SetPoint("RIGHT", cell, "RIGHT", 0, 0)
            WireColorSwatch(row, entry, swatch, swatchBg, primary)
            local lbl = zSF_Text(cell, "GameFontNormalLarge")
            lbl:SetText(entry.name or "")
            lbl:SetTextColor(unpack(ZSF_COLOR_LABEL))
            lbl:SetPoint("LEFT",  cell,   "LEFT",  0, 0)
            lbl:SetPoint("RIGHT", swatch, "LEFT", -6, 0)
            if ShouldShowTooltip(entry) then
                zSF_AddTooltip(lbl, entry.name or "", entry.desc, secondary)
            end
            row._swatch = { bg = swatchBg }
        end
        return row, nil
    end

    -- Quarter: widget renders its own label beside the swatch, so FlushRow
    -- must not inject a second one and must give the cell full row height.
    row._selfLabeled = true
    row:SetHeight(ZSF.ROW_HEIGHT)
    row._buildWidget = function(cell, frac)
        local curVal = row:GetCachedValue() or {}
        local swatch, swatchBg = MakeSwatchButton(cell, SWATCH_H_INLINE, SWATCH_H_INLINE, curVal, entry)
        swatch:SetPoint("RIGHT", cell, "RIGHT", 0, 0)
        WireColorSwatch(row, entry, swatch, swatchBg, primary)
        local lbl = zSF_Text(cell, "GameFontNormal")
        lbl:SetText(entry.name or "")
        lbl:SetTextColor(unpack(ZSF_COLOR_LABEL))
        lbl:SetPoint("LEFT",  cell,   "LEFT",  0, 0)
        lbl:SetPoint("RIGHT", swatch, "LEFT", -6, 0)
        if ShouldShowTooltip(entry) then
            zSF_AddTooltip(lbl, entry.name or "", entry.desc, secondary)
        end
        row._swatch = { bg = swatchBg }
    end
    return row, nil
end

-- ── ColorAlpha ───────────────────────────────────────────────────────────────
-- Same layout variants as Build_Color plus an alpha % label beside the swatch.
-- Third: alpha label hidden (no room in stacked cell).
-- Quarter: alpha label shown below the pill swatch.
-- Opens zSF custom colour picker with alpha support.

--- Build a swatch+alpha-label combo inside actionHost and wire full RGBA callbacks.
---@param row        Frame
---@param entry      table
---@param actionHost Frame   Parent frame for the swatch and alpha label.
---@param swatchW    number
---@param swatchH    number
---@param primary    table|nil
---@param colors     table  Effective panel colors.
---@return Button  swatch
---@return Texture swatchBg
---@return FontString alphaLabel
local function BuildAlphaSwatchBlock(row, entry, actionHost, swatchW, swatchH, primary, colors)
    local _name    = entry.name or ""
    local _confirm = entry.confirm
    local ZSF_COLOR_DESC = colors.desc or ZSF.COLOR_DESC
    local function doReset()
        row._adapter.reset({ row:_ResolveKey() })
        row:Refresh()
        local v = row:GetCachedValue() or {}
        return v[1] or 1, v[2] or 1, v[3] or 1, v[4] or 1
    end

    local curVal = row:GetCachedValue() or {}
    local cr0    = curVal[1] or entry.r or 1
    local cg0    = curVal[2] or entry.g or 1
    local cb0    = curVal[3] or entry.b or 1
    local ca0    = curVal[4] or entry.a or 1

    local swatch = CreateFrame("Button", nil, actionHost)
    swatch:SetSize(swatchW, swatchH)
    -- No anchor here; caller positions the swatch to match Build_Color exactly.
    swatch:EnableMouse(true)

    local swatchBg = zSF_FillBG(swatch, cr0, cg0, cb0, 1)
    zSF_DrawBorder(swatch, 0.30, 0.30, 0.30, 0.65)

    local alphaLabel = zSF_Text(actionHost, "GameFontHighlightSmall", "RIGHT")
    alphaLabel:SetText(string.format("%.0f%%", ca0 * 100))
    alphaLabel:SetTextColor(unpack(ZSF_COLOR_DESC))
    -- Sits inline to the left of the swatch square.
    alphaLabel:SetPoint("RIGHT", swatch, "LEFT", -4, 0)

    swatch:SetScript("OnClick", function()
        local v = row:GetCachedValue() or {}
        local cr, cg, cb, ca = v[1] or 1, v[2] or 1, v[3] or 1, v[4] or 1
        row._ctx:ShowColorPicker({
            r         = cr, g = cg, b = cb, a = ca,
            hasAlpha  = true,
            title     = _name,
            onChange  = function(nr, ng, nb, na)
                row:SetCachedValue({ nr, ng, nb, na })
                -- Live-preview: skip DB write if a confirm is required on accept.
                if not _confirm then
                    row:WriteValue()
                end
                swatchBg:SetColorTexture(nr, ng, nb, 1)
                zSF_DisablePixelSnap(swatchBg)
                alphaLabel:SetText(string.format("%.0f%%", na * 100))
            end,
            onAccept  = function(nr, ng, nb, na)
                MaybeConfirm(row._ctx, _confirm,
                    function()
                        row:SetCachedValue({ nr, ng, nb, na })
                        row:WriteValue()
                        swatchBg:SetColorTexture(nr, ng, nb, 1)
                        zSF_DisablePixelSnap(swatchBg)
                        alphaLabel:SetText(string.format("%.0f%%", na * 100))
                    end,
                    function()
                        -- Confirm cancelled: restore to the colour before the picker opened.
                        row:SetCachedValue({ cr, cg, cb, ca })
                        row:WriteValue()
                        swatchBg:SetColorTexture(cr, cg, cb, 1)
                        zSF_DisablePixelSnap(swatchBg)
                        alphaLabel:SetText(string.format("%.0f%%", ca * 100))
                    end
                )
            end,
            onCancel  = function(pr, pg, pb, pa)
                row:SetCachedValue({ pr, pg, pb, pa })
                row:WriteValue()
                swatchBg:SetColorTexture(pr, pg, pb, 1)
                zSF_DisablePixelSnap(swatchBg)
                alphaLabel:SetText(string.format("%.0f%%", pa * 100))
            end,
            resetFunction = doReset,
        })
    end)

    row:SetRefreshHandler(function(self)
        self:ReadValue()
        local v = self:GetCachedValue() or {}
        swatchBg:SetColorTexture(v[1] or 1, v[2] or 1, v[3] or 1, 1)
        zSF_DisablePixelSnap(swatchBg)
        alphaLabel:SetText(string.format("%.0f%%", (v[4] or 1) * 100))
    end)

    return swatch, swatchBg, alphaLabel
end

local function Build_ColorAlpha(entry, parent, adapter, trackerID, primary, secondary, colors)
    local ZSF_COLOR_LABEL = colors.label or ZSF.COLOR_LABEL
    local ZSF_COLOR_DESC  = colors.desc  or ZSF.COLOR_DESC
    local row = CreateFrame("Frame", nil, parent)
    ApplyControlMixin(row, entry, adapter, trackerID)

    local widthKey = ResolveWidth(entry, DEFAULT_WIDTHS.colorAlpha)
    local fraction = WIDTH_FRACTIONS[widthKey] or 1.0

    if fraction >= 0.999 then
        -- Full-width: label+desc on left, swatch+alpha in action column.
        row:SetHeight(ZSF.ROW_HEIGHT)
        StampRowStripe(row, parent, colors)

        local _, action, aw = BuildFullWidthLabel(row, entry, secondary, colors)

        local swatch, swatchBg, alphaLabel = BuildAlphaSwatchBlock(
            row, entry, action, SWATCH_H_INLINE, SWATCH_H_INLINE, primary, colors)
        swatch:SetPoint("RIGHT", action, "RIGHT", 0, 0)
        row._swatch = { bg = swatchBg, alphaText = alphaLabel }
        return row, nil
    end

    if fraction >= 0.499 then
        -- Half: label on left, swatch+alpha in action column on right — matches all other half widgets.
        row._selfLabeled = true
        row:SetHeight(ZSF.ROW_HEIGHT)
        row._buildWidget = function(cell, frac)
            local aw  = GetActionW()

            local lbl = zSF_Text(cell, "GameFontNormalLarge")
            lbl:SetText(entry.name or "")
            lbl:SetTextColor(unpack(ZSF_COLOR_LABEL))
            LayoutHalfLabel(lbl, cell, cell:GetWidth() - aw - 8, entry, secondary, colors)

            local swatch, swatchBg, alphaLabel = BuildAlphaSwatchBlock(
                row, entry, cell, SWATCH_H_INLINE, SWATCH_H_INLINE, primary, colors)
            swatch:SetPoint("RIGHT", cell, "RIGHT", 0, 0)
            row._swatch = { bg = swatchBg, alphaText = alphaLabel }
        end
        return row, nil
    end

    if fraction >= 0.33 then
        -- Third: same inline layout as quarter — name left, alpha % + swatch right.
        row._selfLabeled = true
        row:SetHeight(ZSF.ROW_HEIGHT)
        row._buildWidget = function(cell, frac)
            local swatch, swatchBg, alphaLabel = BuildAlphaSwatchBlock(
                row, entry, cell, SWATCH_H_INLINE, SWATCH_H_INLINE, primary, colors)
            swatch:SetPoint("RIGHT", cell, "RIGHT", 0, 0)
            local lbl = zSF_Text(cell, "GameFontNormalLarge")
            lbl:SetText(entry.name or "")
            lbl:SetTextColor(unpack(ZSF_COLOR_LABEL))
            lbl:SetPoint("LEFT",  cell,      "LEFT",  0, 0)
            lbl:SetPoint("RIGHT", alphaLabel, "LEFT", -4, 0)
            if ShouldShowTooltip(entry) then
                zSF_AddTooltip(lbl, entry.name or "", entry.desc, secondary)
            end
            row._swatch = { bg = swatchBg, alphaText = alphaLabel }
        end
        return row, nil
    end

    -- Quarter: widget renders its own label beside the swatch, so FlushRow
    -- must not inject a second one and must give the cell full row height.
    row._selfLabeled = true
    row:SetHeight(ZSF.ROW_HEIGHT)
    row._buildWidget = function(cell, frac)
        local swatch, swatchBg, alphaLabel = BuildAlphaSwatchBlock(
            row, entry, cell, SWATCH_H_INLINE, SWATCH_H_INLINE, primary, colors)
        swatch:SetPoint("RIGHT", cell, "RIGHT", 0, 0)
        local lbl = zSF_Text(cell, "GameFontNormal")
        lbl:SetText(entry.name or "")
        lbl:SetTextColor(unpack(ZSF_COLOR_LABEL))
        lbl:SetPoint("LEFT",  cell,      "LEFT",  0, 0)
        lbl:SetPoint("RIGHT", alphaLabel, "LEFT", -4, 0)
        if ShouldShowTooltip(entry) then
            zSF_AddTooltip(lbl, entry.name or "", entry.desc, secondary)
        end
        row._swatch = { bg = swatchBg, alphaText = alphaLabel }
    end
    return row, nil
end

-- ── Input (text entry) ───────────────────────────────────────────────────────
-- Full-width: label+desc on left, EditBox in the action column.
-- Half: label on left half, EditBox on right half.
-- Third/quarter: FlushRow adds label above, EditBox fills the cell.

local INPUT_H = 22  -- edit box height

--- Build a styled single-line EditBox inside parent, wire data binding on row.
---@param row    Frame
---@param entry  table   Schema entry; reads .maxLen, .default, .confirm.
---@param parent Frame   Host frame (sized by caller).
---@param w      number
---@param h      number
---@param colors table
---@param secondary table  { r, g, b } accent colour for the OK button.
---@return Frame   bgFrame
---@return EditBox eb
local function MakeEditBox(row, entry, parent, w, h, colors, secondary)
    local ZSF_COLOR_LABEL = colors.label or ZSF.COLOR_LABEL
    local _confirm = entry.confirm
    -- Parent is pre-sized by caller; fill it directly so the border drawn on
    -- parent (OVERLAY) is never covered by a child frame at the same level.
    zSF_FillBG(parent, 0.06, 0.06, 0.06, 1)

    local eb = CreateFrame("EditBox", nil, parent)
    eb:SetPoint("TOPLEFT", parent, "TOPLEFT", 4, -3)
    eb:SetPoint("BOTTOMRIGHT", parent, "BOTTOMRIGHT", -4, 3)
    eb:SetAutoFocus(false)
    eb:SetFontObject("GameFontHighlightSmall")
    eb:SetTextColor(unpack(ZSF_COLOR_LABEL))
    eb:SetMaxLetters(entry.maxLen or 256)
    eb:SetText(tostring(row:GetCachedValue() or entry.default or ""))

    -- Tracks the last committed value; OK button compares against this.
    local _committed      = tostring(row:GetCachedValue() or entry.default or "")
    local _pendingConfirm = false

    -- OK button sits inside the input box on the right, hidden until dirty.
    local sec       = secondary or ZSF.DEFAULT_SECONDARY
    local OK_BTN_W  = 34
    local okBtn     = zSF_BuildActionButton(parent, OKAY or "OK", nil, OK_BTN_W, sec)
    okBtn:SetHeight(h - 4)
    okBtn:ClearAllPoints()
    okBtn:SetPoint("RIGHT", parent, "RIGHT", -1, 0)
    okBtn:Hide()

    -- When OK is visible, shrink the EditBox right edge so the button is fully
    -- exposed and not covered by the EditBox's mouse-capture area.
    local function SetDirty(flag)
        okBtn:SetShown(flag)
        eb:ClearAllPoints()
        eb:SetPoint("TOPLEFT", parent, "TOPLEFT", 4, -3)
        if flag then
            eb:SetPoint("BOTTOMRIGHT", parent, "BOTTOMRIGHT", -(OK_BTN_W + 3), 3)
        else
            eb:SetPoint("BOTTOMRIGHT", parent, "BOTTOMRIGHT", -4, 3)
        end
    end

    local function Revert()
        eb:SetText(_committed)
        SetDirty(false)
    end

    local function Commit()
        local newVal = eb:GetText()
        if newVal == _committed then SetDirty(false) return end
        local function doWrite()
            row:SetCachedValue(newVal)
            row:WriteValue()
            _committed = newVal
            SetDirty(false)
            _pendingConfirm = false
        end
        if _confirm then
            _pendingConfirm = true
            row._ctx:ShowConfirm(_confirm, doWrite, function()
                -- Decline: leave text in place so the user can revise and retry.
                _pendingConfirm = false
            end)
        else
            doWrite()
        end
    end

    eb:SetScript("OnTextChanged", function(_, userInput)
        if userInput then SetDirty(eb:GetText() ~= _committed) end
    end)
    eb:SetScript("OnEnterPressed", function(self)
        Commit()
        if not _pendingConfirm then self:ClearFocus() end
    end)
    eb:SetScript("OnEscapePressed", function(self)
        Revert()
        self:ClearFocus()
    end)
    eb:SetScript("OnEditFocusGained", function(self) self:HighlightText() end)
    eb:SetScript("OnEditFocusLost", function()
        -- Let the click reach the OK button; don't revert if confirm is open.
        if _pendingConfirm or okBtn:IsMouseOver() then return end
        Revert()
    end)

    okBtn:SetScript("OnClick", Commit)

    row:SetRefreshHandler(function(self)
        self:ReadValue()
        _committed = tostring(self:GetCachedValue() or "")
        eb:SetText(_committed)
        SetDirty(false)
        _pendingConfirm = false
    end)

    return parent, eb
end

local function Build_Input(entry, parent, adapter, trackerID, primary, secondary, colors)
    local ZSF_COLOR_LABEL  = colors.label  or ZSF.COLOR_LABEL
    local ZSF_COLOR_DESC   = colors.desc   or ZSF.COLOR_DESC
    local ZSF_COLOR_BORDER = colors.border or ZSF.COLOR_BORDER
    local row = CreateFrame("Frame", nil, parent)
    ApplyControlMixin(row, entry, adapter, trackerID)

    local widthKey = ResolveWidth(entry, DEFAULT_WIDTHS.input)
    local fraction = WIDTH_FRACTIONS[widthKey] or 1.0

    if fraction >= 0.999 then
        -- Full-width: label+desc on left, EditBox in action column.
        row:SetHeight(ZSF.ROW_HEIGHT)
        StampRowStripe(row, parent, colors)

        local _, action, aw = BuildFullWidthLabel(row, entry, secondary, colors, GetInteractableW(entry))

        local ebBg = CreateFrame("Frame", nil, action)
        ebBg:SetSize(aw, INPUT_H)
        ebBg:SetPoint("CENTER", action, "CENTER", 0, 0)

        local _, eb = MakeEditBox(row, entry, ebBg, aw, INPUT_H, colors, secondary)
        zSF_DrawBorder(ebBg, secondary[1], secondary[2], secondary[3], 0.65)

        return row, nil
    end

    if fraction >= 0.499 then
        -- Half-width: label on left half, EditBox on right half.
        row:SetHeight(ZSF.ROW_HEIGHT)
        row._buildWidget = function(cell, frac)
            local aw  = GetInteractableW(entry)

            local lbl = zSF_Text(cell, "GameFontNormalLarge")
            lbl:SetText(entry.name or "")
            lbl:SetTextColor(unpack(ZSF_COLOR_LABEL))
            LayoutHalfLabel(lbl, cell, cell:GetWidth() - aw - 8, entry, secondary, colors)

            local ebHost = CreateFrame("Frame", nil, cell)
            ebHost:SetWidth(aw)
            ebHost:SetHeight(INPUT_H)
            ebHost:SetPoint("RIGHT", cell, "RIGHT", 0, 0)

            MakeEditBox(row, entry, ebHost, aw, INPUT_H, colors, secondary)
            zSF_DrawBorder(ebHost, secondary[1], secondary[2], secondary[3], 0.65)
        end
        return row, nil
    end

    if fraction >= 0.33 then
        -- Third: FlushRow adds label above; center INPUT_H-tall box in cell.
        row:SetHeight(ZSF.ROW_HEIGHT)
        row._buildWidget = function(cell, frac)
            local ebHost = CreateFrame("Frame", nil, cell)
            ebHost:SetSize(cell:GetWidth(), INPUT_H)
            ebHost:SetPoint("CENTER", cell, "CENTER", 0, 0)
            MakeEditBox(row, entry, ebHost, cell:GetWidth(), INPUT_H, colors, secondary)
            zSF_DrawBorder(ebHost, secondary[1], secondary[2], secondary[3], 0.65)
        end
        return row, nil
    end

    -- Quarter: FlushRow adds label above; center INPUT_H-tall box in cell.
    row:SetHeight(ZSF.ROW_HEIGHT)
    row._buildWidget = function(cell, frac)
        local ebHost = CreateFrame("Frame", nil, cell)
        ebHost:SetSize(cell:GetWidth(), INPUT_H)
        ebHost:SetPoint("CENTER", cell, "CENTER", 0, 0)
        MakeEditBox(row, entry, ebHost, cell:GetWidth(), INPUT_H, colors, secondary)
        zSF_DrawBorder(ebHost, secondary[1], secondary[2], secondary[3], 0.65)
    end

    return row, nil
end

-- ── Header ───────────────────────────────────────────────────────────────────

local function Build_Header(entry, parent, adapter, trackerID, primary, secondary)
    local row = CreateFrame("Frame", nil, parent)
    -- Total height = header content area + top buffer so section titles have breathing room.
    row:SetHeight(ZSF.HEADER_HEIGHT + ZSF.HEADER_PAD_TOP)
    -- Content subframe sits below the buffer zone; the header text is centred in it.
    local content = CreateFrame("Frame", nil, row)
    content:SetPoint("TOPLEFT",    row, "TOPLEFT",    0, -ZSF.HEADER_PAD_TOP)
    content:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", 0,  0)
    zSF_BuildSectionHeader(content, entry.name or "", GetSidePad(), primary, secondary)
    -- Headers have no data binding.
    return row, nil
end

-- ── Spacer ───────────────────────────────────────────────────────────────────

local function Build_Spacer(entry, parent, adapter, trackerID, primary, secondary)
    local showSep = entry.separator == true
    local padTop  = entry.paddingTop    or (showSep and 4 or 0)
    local padBot  = entry.paddingBottom or (showSep and 4 or 0)
    local totalH  = padTop + padBot + (showSep and 1 or 0)

    local row = CreateFrame("Frame", nil, parent)
    row:SetHeight(math.max(totalH, 0.001))

    if showSep then
        local PAD_X = GetSidePad()
        local line = row:CreateTexture(nil, "ARTWORK")
        line:SetTexture(zSF_AssetPath("1px.png"))
        line:SetVertexColor(secondary[1], secondary[2], secondary[3])
        line:SetAlpha(0.30)
        PixelUtil.SetHeight(line, 1, 1)
        PixelUtil.SetPoint(line, "LEFT",  row, "LEFT",  PAD_X + 6, -padTop)
        PixelUtil.SetPoint(line, "RIGHT", row, "RIGHT", -PAD_X,    -padTop)
    end

    return row, nil
end

-- ── Button ───────────────────────────────────────────────────────────────────

---@param entry     table
---@param parent    Frame
---@param adapter   zSF_Adapter
---@param trackerID number|nil
---@param primary   table
---@param secondary table
---@return Frame widget
---@return nil contentFrame
local function Build_Button(entry, parent, adapter, trackerID, primary, secondary)
    local _bctx      = zSettingsFrame._buildCtx  -- captured at build time; Button has no ControlMixin
    local _confirm   = entry.confirm
    local _resetKeys = entry.resetKeys
    local _func      = entry.func
    local row = CreateFrame("Frame", nil, parent)
    row:SetHeight(ZSF.ROW_HEIGHT)

    local function doAction()
        if _resetKeys then
            local keys = {}
            for _, bareKey in ipairs(_resetKeys) do
                if trackerID then
                    table.insert(keys, "t" .. trackerID .. bareKey)
                else
                    table.insert(keys, bareKey)
                end
            end
            adapter.reset(keys)
            if _bctx then _bctx:RefreshAll(true) end
        end
        if _func then
            _func()
        end
    end

    local function wireClick(btn)
        btn:SetScript("OnClick", function()
            if _confirm then
                _bctx:ShowConfirm(_confirm, doAction)
            else
                doAction()
            end
        end)
    end

    -- Automatically show the refresh icon when the button resets settings.
    -- An explicit entry.iconAtlas always takes priority.
    -- entry.showResetIcon = true forces it on; false forces it off; nil follows the default.
    local wantsReset = entry.showResetIcon
    local showReset = (wantsReset == true) or (wantsReset == nil and entry.resetKeys ~= nil)
    local iconAtlas = entry.iconAtlas or (showReset and "BattleBar-SwapPetIcon" or nil)

    -- Resolve icon colour: colorIconAtlas = false|"primary"|"secondary"|{r,g,b}|nil
    -- false = show atlas raw; nil defaults to secondary when an atlas is present.
    local iconColor
    if iconAtlas then
        local raw = entry.colorIconAtlas
        if raw == false then
            iconColor = false
        elseif raw == "primary" then
            iconColor = primary
        elseif raw == "secondary" then
            iconColor = secondary
        elseif type(raw) == "table" then
            iconColor = raw
        else
            iconColor = secondary
        end
    end

    -- Resolve entry.borderColor: "primary" | "secondary" | {r, g, b} — defaults to primary.
    local bc = entry.borderColor
    local borderColor
    if bc == "secondary" then
        borderColor = secondary
    elseif type(bc) == "table" then
        borderColor = bc
    else
        borderColor = nil  -- falls back to ZSF.COLOR_BORDER (grey)
    end

    -- Buttons always use _buildWidget so they pack into multi-column rows at any width.
    local BTN_H = 22   -- matches zSF_BuildActionButton's natural height
    local rotateIcon = iconAtlas and entry.rotateIcon or nil
    local flipIcon   = iconAtlas and entry.flipIcon   or nil
    row._buildWidget = function(cell, fraction)
        local btn = zSF_BuildActionButton(cell,
            entry.name or "Button",
            iconAtlas,
            nil,   -- width driven by cell anchors
            secondary,
            iconColor,
            borderColor,
            rotateIcon,
            flipIcon)
        btn:ClearAllPoints()
        local vpad = math.floor((ZSF.ROW_HEIGHT - BTN_H) / 2)
        btn:SetPoint("TOPLEFT",  cell, "TOPLEFT",  0, -vpad)
        btn:SetPoint("TOPRIGHT", cell, "TOPRIGHT", 0, -vpad)
        btn:SetHeight(BTN_H)
        wireClick(btn)
    end

    return row, nil
end

-- ── Filler (invisible spacer for row packing) ───────────────────────────────

---@param entry     table
---@param parent    Frame
---@param adapter   zSF_Adapter
---@param trackerID number|nil
---@param primary   table
---@param secondary table
---@return Frame widget
---@return nil contentFrame
local function Build_Filler(entry, parent, adapter, trackerID, primary, secondary)
    local row = CreateFrame("Frame", nil, parent)
    row:SetHeight(ZSF.ROW_HEIGHT)
    -- Completely transparent; occupies layout space only.
    return row, nil
end

-- ── Text (static label) ─────────────────────────────────────────────────────

---@param entry     table
---@param parent    Frame
---@param adapter   zSF_Adapter
---@param trackerID number|nil
---@param primary   table  { r, g, b }
---@param secondary table  { r, g, b }
---@param colors    table  color overrides { label, desc, ... }
---@return Frame widget
---@return nil     contentFrame
local function Build_Text(entry, parent, adapter, trackerID, primary, secondary, colors)
    local ZSF_COLOR_LABEL = colors.label or ZSF.COLOR_LABEL
    local ZSF_COLOR_DESC  = colors.desc  or ZSF.COLOR_DESC
    local row = CreateFrame("Frame", nil, parent)
    row:SetHeight(entry.height or ZSF.ROW_HEIGHT)

    local showDesc = ShouldShowDescInline(entry)
    local padX  = GetSidePad()
    local labelW   = zSF_GetContentWidth() - padX * 2

    local label = zSF_Text(row, entry.fontObject or "GameFontNormal")
    label:SetText(entry.name or "")
    label:SetTextColor(unpack(ZSF_COLOR_LABEL))
    label:SetPoint("TOPLEFT", row, "TOPLEFT", padX, showDesc and ROW_LABEL_DESC_Y or ROW_LABEL_Y)
    label:SetWidth(labelW)

    if entry.desc then
        if showDesc then
            local descFS = zSF_Text(row, "GameFontHighlightSmall")
            descFS:SetText(entry.desc)
            descFS:SetTextColor(unpack(ZSF_COLOR_DESC))
            descFS:SetPoint("TOPLEFT", label, "BOTTOMLEFT", 0, -2)
            descFS:SetWidth(labelW)
        elseif ShouldShowTooltip(entry) then
            zSF_AddTooltip(label, entry.name or "", entry.desc, secondary)
        end
    end

    return row, nil
end

-- ── Group (container for child widgets) ──────────────────────────────────────

local function Build_Group(entry, parent, adapter, trackerID, primary, secondary)
    local row = CreateFrame("Frame", nil, parent)
    row:SetHeight(1) -- will be sized by children
    return row, row
end

-- ── Textarea / Output ────────────────────────────────────────────────────────
-- A scrollable multiline EditBox.  Set entry.readonly = true (or use
-- widgetType = "output") for a non-editable copy-friendly display box.

---@param entry     table
---@param parent    Frame
---@param adapter   zSF_Adapter
---@param trackerID number|nil
---@param primary   table  { r, g, b }
---@param secondary table  { r, g, b }
---@return Frame   widget
---@return nil     contentFrame
local function Build_Textarea(entry, parent, adapter, trackerID, primary, secondary, colors)
    local ZSF_COLOR_LABEL  = colors.label  or ZSF.COLOR_LABEL
    local ZSF_COLOR_DESC   = colors.desc   or ZSF.COLOR_DESC
    local ZSF_COLOR_BORDER = colors.border or ZSF.COLOR_BORDER
    local readonly = entry.readonly or (entry.widgetType == "output")
    local lines    = entry.lines or 6
    local LINE_H   = 14  -- approximate line height for GameFontHighlightSmall
    local boxH     = lines * LINE_H + 8  -- 8 = top+bottom inner padding

    local hasLabel    = entry.name ~= nil
    local labelBlockH = hasLabel and (entry.desc and 28 or 16) or 0
    local topGap      = hasLabel and 6 or 0
    local BTN_H       = 22   -- matches zSF_BuildActionButton natural height
    local footerExtra = readonly and 0 or 24  -- gap(6) + btn(22) + bottom-margin(6) - base-pad(10)
    local rowH        = 10 + labelBlockH + topGap + boxH + 10 + footerExtra

    local row = CreateFrame("Frame", nil, parent)
    row:SetHeight(rowH)
    ApplyControlMixin(row, entry, adapter, trackerID)
    StampRowStripe(row, parent, colors)

    local PAD_X    = GetSidePad()
    local contentW = zSF_GetContentWidth()
    local boxW     = contentW - PAD_X * 2

    -- Optional label / desc above the box.
    if entry.name then
        local lbl = zSF_Text(row, "GameFontNormalLarge")
        lbl:SetText(entry.name)
        lbl:SetTextColor(unpack(ZSF_COLOR_LABEL))
        lbl:SetPoint("TOPLEFT", row, "TOPLEFT", PAD_X, -10)
        lbl:SetWidth(boxW)
        if entry.desc then
            local dsc = zSF_Text(row, "GameFontHighlightSmall")
            dsc:SetText(entry.desc)
            dsc:SetTextColor(unpack(ZSF_COLOR_DESC))
            dsc:SetPoint("TOPLEFT", lbl, "BOTTOMLEFT", 0, -2)
            dsc:SetWidth(boxW)
        end
    end

    -- Backdrop frame for the box area.
    local boxTop   = 10 + labelBlockH + topGap
    local boxFrame = CreateFrame("Frame", nil, row)
    boxFrame:SetSize(boxW, boxH)
    boxFrame:SetPoint("TOPLEFT", row, "TOPLEFT", PAD_X, -boxTop)
    zSF_FillBG(boxFrame, 0.06, 0.06, 0.06, 1)
    if readonly then
        zSF_DrawBorder(boxFrame, ZSF_COLOR_BORDER[1], ZSF_COLOR_BORDER[2], ZSF_COLOR_BORDER[3], 0.65)
    else
        zSF_DrawBorder(boxFrame, secondary[1], secondary[2], secondary[3], 0.65)
    end

    -- Plain ScrollFrame (mouse-wheel only; no scrollbar widget).
    local sf = CreateFrame("ScrollFrame", nil, boxFrame)
    sf:SetPoint("TOPLEFT",     boxFrame, "TOPLEFT",      3, -3)
    sf:SetPoint("BOTTOMRIGHT", boxFrame, "BOTTOMRIGHT", -3,  3)

    -- EditBox is the scroll child; anchored top-left with a fixed width so its
    -- height grows freely as content is added, giving the ScrollFrame a real
    -- scroll range (sf height is the viewport; eb height is the full content).
    local eb = CreateFrame("EditBox", nil, sf)
    eb:SetPoint("TOPLEFT", sf, "TOPLEFT", 0, 0)
    eb:SetWidth(boxW - 6)   -- sf is inset 3 px each side from boxFrame
    eb:SetMultiLine(true)
    eb:SetAutoFocus(false)
    eb:EnableMouse(true)
    eb:SetFontObject("GameFontHighlightSmall")
    eb:SetTextColor(unpack(readonly and ZSF_COLOR_DESC or ZSF_COLOR_LABEL))
    if entry.maxLetters and entry.maxLetters > 0 then
        eb:SetMaxLetters(entry.maxLetters)
    end
    sf:SetScrollChild(eb)

    -- Keep EditBox width in sync if the panel is ever resized.
    sf:SetScript("OnSizeChanged", function(self, w) eb:SetWidth(w) end)

    -- Clicking anywhere in the scroll area (including empty space) gives focus.
    sf:SetScript("OnMouseUp", function() eb:SetFocus() end)

    -- Auto-scroll to keep the cursor visible while typing or navigating.
    eb:SetScript("OnCursorChanged", function(_, _, y, _, cursorH)
        y = -y
        local offset = sf:GetVerticalScroll()
        if y < offset then
            sf:SetVerticalScroll(y)
        else
            y = y + cursorH - sf:GetHeight()
            if y > offset then sf:SetVerticalScroll(y) end
        end
    end)

    -- Mouse-wheel scroll on both the ScrollFrame and the EditBox layer.
    local function DoScroll(delta)
        local newP = math.max(0, math.min(sf:GetVerticalScrollRange(),
            sf:GetVerticalScroll() - delta * 20))
        sf:SetVerticalScroll(newP)
    end
    sf:EnableMouseWheel(true)
    sf:SetScript("OnMouseWheel", function(_, delta) DoScroll(delta) end)
    eb:SetScript("OnMouseWheel", function(_, delta) DoScroll(delta) end)

    -- Keep click hit-rects in sync with scroll position.
    sf:SetScript("OnVerticalScroll", function(self, offset)
        eb:SetHitRectInsets(0, 0, offset, eb:GetHeight() - offset - self:GetHeight())
    end)
    sf:SetScript("OnScrollRangeChanged", function(self, _, yrange)
        if yrange == 0 then
            eb:SetHitRectInsets(0, 0, 0, 0)
        else
            local off = self:GetVerticalScroll()
            eb:SetHitRectInsets(0, 0, off, eb:GetHeight() - off - self:GetHeight())
        end
    end)

    -- ── Readonly (output) wiring ──────────────────────────────────────────────
    if readonly then
        local _prog = false
        local _val  = ""

        local function SetContent(text)
            _prog = true
            eb:SetText(text or "")
            eb:SetCursorPosition(0)
            _prog = false
        end

        -- Revert any user edits immediately.
        eb:SetScript("OnTextChanged", function(_, userInput)
            if not _prog and userInput then SetContent(_val) end
        end)
        eb:SetScript("OnEnterPressed", function(self) self:ClearFocus() end)
        eb:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)

        row:SetRefreshHandler(function(self)
            self:ReadValue()
            _val = tostring(self:GetCachedValue() or "")
            SetContent(_val)
        end)

    -- ── Editable (textarea) wiring ────────────────────────────────────────────
    else
        -- _committed: last value written to DB; Undo reverts to it.
        -- _default:   entry default value; Reset-to-default loads it.
        local _committed      = ""
        local _pendingConfirm = false
        local _confirm        = entry.confirm
        local _default        = entry.default

        local function GetDefault()
            if _default ~= nil then return tostring(_default) end
            local fullKey = row:_ResolveKey()
            local v = row._adapter.getDefault(fullKey)
            return v ~= nil and tostring(v) or ""
        end

        -- Footer layout (left → right):  Reset | Undo | … | OK
        -- Reset: always visible; loads the entry default into the box.
        -- Undo:  dirty-only;    reverts the box to the last committed value.
        -- OK:    dirty-only;    commits the current box content.
        local BTN_W    = 65
        local BTN_GAP  = 4

        local btnReset = zSF_BuildActionButton(row, RESET or "Reset", nil, BTN_W, secondary)
        btnReset:SetPoint("BOTTOMLEFT", row, "BOTTOMLEFT", PAD_X, 6)

        local btnUndo = zSF_BuildActionButton(row, UNDO or "Undo", nil, BTN_W, secondary)
        btnUndo:SetPoint("LEFT", btnReset, "RIGHT", BTN_GAP, 0)
        btnUndo:Hide()

        local btnOK = zSF_BuildActionButton(row, OKAY or "OK", nil, BTN_W, secondary)
        btnOK:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", -PAD_X, 6)
        btnOK:Hide()

        local function SetDirty(flag)
            btnUndo:SetShown(flag)
            btnOK:SetShown(flag)
        end

        local function Revert()
            eb:SetText(_committed)
            eb:SetCursorPosition(0)
            SetDirty(false)
        end

        local function Commit()
            local newVal = eb:GetText()
            if newVal == _committed then SetDirty(false) return end
            local function doWrite()
                row:SetCachedValue(newVal)
                row:WriteValue()
                _committed = newVal
                SetDirty(false)
                _pendingConfirm = false
            end
            if _confirm then
                _pendingConfirm = true
                row._ctx:ShowConfirm(_confirm, doWrite, function()
                    -- Decline: leave text in place so the user can revise and retry.
                    _pendingConfirm = false
                end)
            else
                doWrite()
            end
        end

        eb:SetScript("OnTextChanged", function(_, userInput)
            if userInput then SetDirty(eb:GetText() ~= _committed) end
        end)
        eb:SetScript("OnEscapePressed", function(self)
            Revert()
            self:ClearFocus()
        end)
        eb:SetScript("OnEditFocusGained", function(self)
            self:SetTextColor(1, 1, 1, 1)
        end)
        eb:SetScript("OnEditFocusLost", function(self)
            -- No commit on blur; color returns to normal.
            self:SetTextColor(unpack(ZSF_COLOR_LABEL))
        end)

        btnReset:SetScript("OnClick", function()
            local def = GetDefault()
            eb:SetText(def)
            eb:SetCursorPosition(0)
            -- SetText is programmatic (userInput=false), so update dirty state manually.
            SetDirty(def ~= _committed)
        end)
        btnUndo:SetScript("OnClick", Revert)
        btnOK:SetScript("OnClick", Commit)

        -- Optional placeholder hint shown when the box is empty and unfocused.
        if entry.placeholder then
            local ph = zSF_Text(boxFrame, "GameFontHighlightSmall")
            ph:SetText(entry.placeholder)
            ph:SetTextColor(0.38, 0.38, 0.38, 1)
            ph:SetPoint("TOPLEFT", boxFrame, "TOPLEFT", 5, -5)
            ph:SetWidth(boxW - 10)
            local function SyncPh()
                ph:SetShown(eb:GetText() == "")
            end
            -- Hook so the scripts registered above are not overwritten.
            eb:HookScript("OnTextChanged",    function() SyncPh() end)
            eb:HookScript("OnEditFocusGained", function() ph:Hide() end)
            eb:HookScript("OnEditFocusLost",   function() SyncPh() end)
        end

        row:SetRefreshHandler(function(self)
            self:ReadValue()
            _committed = tostring(self:GetCachedValue() or "")
            eb:SetText(_committed)
            eb:SetCursorPosition(0)
            SetDirty(false)
            _pendingConfirm = false
        end)
    end

    return row, nil
end

-- ── OrderList ────────────────────────────────────────────────────────────────
-- Two display styles, chosen by entry.orderStyle:
--   (default) arrow  — up/down atlas arrow buttons per row.
--   "drag"           — grab-handle rows with live drag-to-reorder.

local OL_ITEM_H  = 24   -- height per reorderable item row
local OL_BTN_SZ  = 14   -- arrow button side (arrow style)
local OL_BTN_GAP = 3    -- gap between up and down arrows
local OL_HANDLE_W = 10  -- grip column width (drag style)
local OL_DOT_SZ   = 3   -- grip dot size (drag style)

--- Encapsulates the drag/drop state machine for a draggable order list.
--- Wires OnMouseDown on each item row, OnUpdate on the updater frame, and
--- OnMouseUp on the drag capture overlay.  Returns a handle so the builder
--- can call Redraw after loading a fresh order.
---@param itemsFrame  Frame        container holding the item rows
---@param itemCount   number       total reorderable items
---@param items       table        { [key] = "Display Label" }
---@param itemRows    OLDragRow[]  visual row descriptors built by the caller
---@param ghost       Frame        floating clone shown during drag
---@param ghostLabel  FontString   label inside the ghost frame
---@param insertLine  Frame        2px drop-position indicator
---@param dragCapture Frame        fullscreen overlay that catches OnMouseUp
---@param updater     Frame        per-frame driver for ghost movement
---@param order       table        live key-order array (mutated on commit)
---@param onCommit    fun(copy:table) called with final order on a successful drop
---@return { Redraw: fun() }
local function MakeDragStateMachine(itemsFrame, itemCount, items, itemRows,
                                    ghost, ghostLabel, insertLine,
                                    dragCapture, updater, order, onCommit)
    local dragging    = false
    local dragOrigIdx = nil
    local dragKey     = nil
    local insertIdx   = nil
    local savedOrder  = {}
    local workOrder   = {}

    local function GetCursorUIPos()
        local scale = UIParent:GetEffectiveScale()
        local cx, cy = GetCursorPosition()
        return cx / scale, cy / scale
    end

    local function GetInsertSlot(uiCY)
        local top = itemsFrame:GetTop()
        if not top then return insertIdx or 1 end
        local slot = math.floor((top - uiCY) / OL_ITEM_H) + 1
        return math.max(1, math.min(itemCount, slot))
    end

    local function CursorInItemsFrame(uiCX, uiCY)
        local l, b = itemsFrame:GetLeft(),   itemsFrame:GetBottom()
        local r, t = itemsFrame:GetRight(),  itemsFrame:GetTop()
        if not l then return false end
        return uiCX >= l and uiCX <= r and uiCY >= b and uiCY <= t
    end

    local function Redraw()
        local src = dragging and workOrder or order
        for i, ir in ipairs(itemRows) do
            local key = src[i]
            ir.label:SetText(items[key or ""] or key or "")
            -- Dim the placeholder slot for the item currently in the ghost.
            ir.frame:SetAlpha((dragging and key == dragKey) and 0.25 or 1.0)
        end
    end

    local function RebuildWorkOrder(slot)
        -- Remove the dragged item from savedOrder then splice it in at slot.
        local temp = {}
        for i = 1, itemCount do
            if i ~= dragOrigIdx then temp[#temp + 1] = savedOrder[i] end
        end
        table.insert(temp, math.min(slot, #temp + 1), savedOrder[dragOrigIdx])
        wipe(workOrder)
        for i = 1, #temp do workOrder[i] = temp[i] end
    end

    local function UpdateInsertLine(slot)
        local top   = itemsFrame:GetTop()
        local left  = itemsFrame:GetLeft()
        local right = itemsFrame:GetRight()
        if not top then insertLine:Hide() return end
        -- Line sits at the bottom edge of the target slot.
        local lineY = top - slot * OL_ITEM_H
        insertLine:ClearAllPoints()
        insertLine:SetPoint("TOPLEFT",  UIParent, "BOTTOMLEFT", left,  lineY)
        insertLine:SetPoint("TOPRIGHT", UIParent, "BOTTOMLEFT", right, lineY)
        insertLine:Show()
    end

    local function EndDrag(commit)
        if not dragging then return end
        dragging    = false
        dragOrigIdx = nil
        dragKey     = nil
        insertIdx   = nil
        ghost:Hide()
        insertLine:Hide()
        updater:Hide()
        dragCapture:Hide()
        if commit then
            wipe(order)
            for i = 1, itemCount do order[i] = workOrder[i] end
            local copy = {}
            for i = 1, #order do copy[i] = order[i] end
            onCommit(copy)
        else
            -- Snap back to the pre-drag order.
            wipe(order)
            for i = 1, itemCount do order[i] = savedOrder[i] end
        end
        Redraw()
    end

    dragCapture:SetScript("OnMouseUp", function(self, btn)
        if btn == "LeftButton" and dragging then
            local cx, cy = GetCursorUIPos()
            EndDrag(CursorInItemsFrame(cx, cy))
        end
    end)

    updater:SetScript("OnUpdate", function()
        if not dragging then return end
        local cx, cy = GetCursorUIPos()
        -- Keep the ghost centered on the cursor with a small rightward offset.
        ghost:ClearAllPoints()
        ghost:SetPoint("CENTER", UIParent, "BOTTOMLEFT", cx + 8, cy)
        -- Recalculate insert slot; rebuild workOrder only when it changes.
        local newSlot = GetInsertSlot(cy)
        if newSlot ~= insertIdx then
            insertIdx = newSlot
            RebuildWorkOrder(insertIdx)
            Redraw()
        end
        UpdateInsertLine(insertIdx)
    end)

    for i = 1, itemCount do
        local ir     = itemRows[i]
        local capIdx = i
        ir.frame:SetScript("OnMouseDown", function(self, btn)
            if btn ~= "LeftButton" or dragging then return end
            dragging    = true
            dragOrigIdx = capIdx
            dragKey     = order[capIdx]
            insertIdx   = capIdx
            wipe(savedOrder)
            wipe(workOrder)
            for j = 1, itemCount do
                savedOrder[j] = order[j]
                workOrder[j]  = order[j]
            end
            ghostLabel:SetText(items[order[capIdx] or ""] or order[capIdx] or "")
            ghost:SetWidth(itemsFrame:GetWidth() or 200)
            ghost:Show()
            dragCapture:Show()
            updater:Show()
            Redraw()
            UpdateInsertLine(insertIdx)
        end)
        -- WoW's implicit mouse capture routes OnMouseUp back to whichever frame
        -- received OnMouseDown, so the natural release must be handled here for
        -- true drag-and-drop (hold to drag, release to drop).
        ir.frame:SetScript("OnMouseUp", function(self, btn)
            if btn == "LeftButton" and dragging then
                local cx, cy = GetCursorUIPos()
                EndDrag(CursorInItemsFrame(cx, cy))
            end
        end)
    end

    return { Redraw = Redraw }
end

-- ── OrderList drag implementation ────────────────────────────────────────────
-- Rows show a 3-dot grip handle. Mouse-down starts a ghost drag; the list
-- reorders live as the cursor moves. Releasing outside snaps back.
--
-- Coordinate notes:
--   GetCursorPosition() → raw screen pixels.
--   Dividing by UIParent:GetEffectiveScale() gives UIParent coord space,
--   which matches GetTop()/GetLeft() on any frame.
--   SetPoint offsets from "BOTTOMLEFT" of UIParent are in the same space.
---@param entry     table
---@param parent    Frame
---@param adapter   zSF_Adapter
---@param trackerID number|nil
---@param primary   table  { r, g, b }
---@param secondary table  { r, g, b }
---@param colors    table  color overrides { label, desc, ... }
---@return Frame
---@return nil
local function Build_OrderList_Drag(entry, parent, adapter, trackerID, primary, secondary, colors)
    local ZSF_COLOR_LABEL = colors.label or ZSF.COLOR_LABEL
    local ZSF_COLOR_DESC  = colors.desc  or ZSF.COLOR_DESC
    local items = type(entry.items) == "function"
        and entry.items(trackerID) or (entry.items or {})
    local itemCount = 0
    for _ in pairs(items) do itemCount = itemCount + 1 end

    local NAME_H     = entry.name and (6 + WIDGET_LABEL_H + 2) or 0
    local DESC_EXTRA = (entry.name and entry.desc) and 18 or 0
    local HDR_H = NAME_H + DESC_EXTRA
    local rowH  = HDR_H + itemCount * OL_ITEM_H + 4

    local row = CreateFrame("Frame", nil, parent)
    row:SetHeight(rowH)
    ApplyControlMixin(row, entry, adapter, trackerID)

    if entry.name then
        local padX = GetSidePad()
        local lbl = zSF_Text(row, "GameFontNormalLarge", "LEFT")
        lbl:SetText(entry.name)
        lbl:SetTextColor(unpack(ZSF_COLOR_LABEL))
        lbl:SetPoint("TOPLEFT",  row, "TOPLEFT",  padX, -6)
        lbl:SetPoint("TOPRIGHT", row, "TOPRIGHT", -padX, -6)
        lbl:SetHeight(WIDGET_LABEL_H)
        if ShouldShowTooltip(entry) then
            zSF_AddTooltip(lbl, entry.name, entry.desc, secondary)
        end
        if ShouldShowDescInline(entry) then
            local descFS = zSF_Text(row, "GameFontHighlightSmall")
            descFS:SetText(entry.desc)
            descFS:SetTextColor(unpack(ZSF_COLOR_DESC))
            descFS:SetPoint("TOPLEFT",  row, "TOPLEFT",  padX, -(6 + WIDGET_LABEL_H + 4))
            descFS:SetPoint("TOPRIGHT", row, "TOPRIGHT", -padX, -(6 + WIDGET_LABEL_H + 4))
            descFS:SetJustifyH("LEFT")
        end
    end

    local itemsFrame = CreateFrame("Frame", nil, row)
    itemsFrame:SetHeight(itemCount * OL_ITEM_H)
    -- Keep drag rows centered at 1/3 of the widget width; recompute on resize.
    local function LayoutItemsFrame()
        local w = row:GetWidth()
        if w > 0 then
            local iw = math.floor(w / 3)
            itemsFrame:ClearAllPoints()
            itemsFrame:SetPoint("TOPLEFT", row, "TOPLEFT", math.floor((w - iw) / 2), -HDR_H)
            itemsFrame:SetWidth(iw)
        end
    end
    row:SetScript("OnSizeChanged", LayoutItemsFrame)
    LayoutItemsFrame()

    local PAD_X = GetSidePad()
    local LBL_X = PAD_X + OL_HANDLE_W + 4

    local order  = {}
    local _order = entry.order

    local function PullOrder()
        row:ReadValue()
        local saved = row:GetCachedValue()
        if type(saved) == "table" and #saved > 0 then
            wipe(order)
            for i, k in ipairs(saved) do order[i] = k end
        else
            wipe(order)
            local fallback = type(_order) == "table" and _order or nil
            if fallback then
                for i, k in ipairs(fallback) do order[i] = k end
            else
                for k in pairs(items) do order[#order + 1] = k end
            end
        end
    end

    ---@class OLDragRow
    ---@field frame  Frame
    ---@field label  FontString
    local itemRows = {}
    for i = 1, itemCount do
        local ir = CreateFrame("Frame", nil, itemsFrame)
        ir:SetPoint("TOPLEFT",  itemsFrame, "TOPLEFT",  0, -(i - 1) * OL_ITEM_H)
        ir:SetPoint("TOPRIGHT", itemsFrame, "TOPRIGHT", 0, -(i - 1) * OL_ITEM_H)
        ir:SetHeight(OL_ITEM_H)
        ir:EnableMouse(true)
        StampRowStripe(ir, itemsFrame, colors)

        -- 3-dot vertical grip indicator on the left.
        for d = 0, 2 do
            local dot = ir:CreateTexture(nil, "ARTWORK")
            dot:SetSize(OL_DOT_SZ, OL_DOT_SZ)
            dot:SetPoint("LEFT", ir, "LEFT", PAD_X + 2, (d - 1) * 5)
            dot:SetColorTexture(primary[1], primary[2], primary[3], 0.55)
        end

        local lbl = zSF_Text(ir, "GameFontNormal")
        lbl:SetPoint("LEFT",  ir, "LEFT",  LBL_X, 0)
        lbl:SetPoint("RIGHT", ir, "RIGHT", -PAD_X, 0)
        lbl:SetJustifyH("LEFT")

        itemRows[i] = { frame = ir, label = lbl }
    end

    -- Section-style border around the item rows region, matching the stripe inset.
    do
        local inset    = math.max(GetSidePad() - 8, 0)
        local borderFr = CreateFrame("Frame", nil, row)
        borderFr:SetFrameLevel(row:GetFrameLevel() + 10)
        borderFr:SetPoint("TOPLEFT",     itemsFrame, "TOPLEFT",     inset, 0)
        borderFr:SetPoint("BOTTOMRIGHT", itemsFrame, "BOTTOMRIGHT", -inset, 0)
        local sc = ZSF.COLOR_SECTION_BORDER
        zSF_DrawBorder(borderFr, sc[1], sc[2], sc[3], sc[4])
    end

    -- Ghost: floating visual clone of the dragged row.
    local ghost = CreateFrame("Frame", nil, UIParent)
    ghost:SetFrameStrata("FULLSCREEN_DIALOG")
    ghost:SetFrameLevel(600)
    ghost:SetHeight(OL_ITEM_H)
    ghost:SetAlpha(0.90)
    ghost:Hide()
    local ghostBg = ghost:CreateTexture(nil, "BACKGROUND")
    ghostBg:SetAllPoints()
    ghostBg:SetColorTexture(0.08, 0.08, 0.12, 0.92)
    local ghostAccent = ghost:CreateTexture(nil, "BORDER")
    ghostAccent:SetPoint("TOPLEFT",    ghost, "TOPLEFT",    0, 0)
    ghostAccent:SetPoint("BOTTOMLEFT", ghost, "BOTTOMLEFT", 0, 0)
    ghostAccent:SetWidth(2)
    ghostAccent:SetColorTexture(secondary[1], secondary[2], secondary[3], 0.9)
    local ghostLabel = ghost:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    ghostLabel:SetPoint("LEFT",  ghost, "LEFT",  LBL_X, 0)
    ghostLabel:SetPoint("RIGHT", ghost, "RIGHT", -PAD_X, 0)
    ghostLabel:SetJustifyH("LEFT")
    ghostLabel:SetTextColor(secondary[1], secondary[2], secondary[3], 1)

    -- Insert line: 2px rule showing where the item will land.
    local insertLine = CreateFrame("Frame", nil, UIParent)
    insertLine:SetFrameStrata("FULLSCREEN_DIALOG")
    insertLine:SetFrameLevel(601)
    insertLine:SetHeight(2)
    insertLine:Hide()
    local iLineTex = insertLine:CreateTexture(nil, "BACKGROUND")
    iLineTex:SetAllPoints()
    iLineTex:SetColorTexture(secondary[1], secondary[2], secondary[3], 0.85)

    -- Drag capture: full-screen TOOLTIP-strata overlay that catches OnMouseUp
    -- anywhere on screen while a drag is in progress.
    local dragCapture = CreateFrame("Frame", nil, UIParent)
    dragCapture:SetAllPoints(UIParent)
    dragCapture:SetFrameStrata("TOOLTIP")
    dragCapture:EnableMouse(true)
    dragCapture:Hide()

    -- Updater: drives ghost position and slot recalculation each frame.
    local updater = CreateFrame("Frame", nil, UIParent)
    updater:Hide()

    local drag = MakeDragStateMachine(
        itemsFrame, itemCount, items, itemRows,
        ghost, ghostLabel, insertLine,
        dragCapture, updater, order,
        function(newOrder) row:SetCachedValue(newOrder); row:WriteValue() end
    )

    PullOrder()
    drag.Redraw()

    row:SetRefreshHandler(function()
        PullOrder()
        drag.Redraw()
    end)

    return row, nil
end

-- ── OrderList (arrow style, default) ─────────────────────────────────────────
---@param entry     table   Schema entry.
---                           entry.items      : table { [key] = "Display Label", ... }
---                                              or function(trackerID) returning same.
---                           entry.order      : array { key, ... } for the initial
---                                              default ordering when no DB value exists.
---                           entry.orderStyle : "drag" to use drag-to-reorder instead
---                                              of arrow buttons (default = arrows).
---                           entry.get / entry.set: custom DB accessors.
---@param parent    Frame
---@param adapter   zSF_Adapter
---@param trackerID number|nil
---@param primary   table  { r, g, b }
---@param secondary table  { r, g, b }
---@return Frame   widget
---@return nil     contentFrame
local function Build_OrderList(entry, parent, adapter, trackerID, primary, secondary, colors)
    local ZSF_COLOR_DESC     = colors.desc     or ZSF.COLOR_DESC
    local ZSF_COLOR_DISABLED = colors.disabled or ZSF.COLOR_DISABLED
    if entry.orderStyle == "drag" then
        return Build_OrderList_Drag(entry, parent, adapter, trackerID, primary, secondary, colors)
    end
    -- Resolve items table (may be a lazy function).
    local items     = type(entry.items) == "function"
        and entry.items(trackerID) or (entry.items or {})
    local itemCount = 0
    for _ in pairs(items) do itemCount = itemCount + 1 end

    -- Total height: name label + item rows + small bottom pad.
    local ZSF_COLOR_LABEL = colors.label or ZSF.COLOR_LABEL
    local NAME_H     = entry.name and (6 + WIDGET_LABEL_H + 2) or 0
    local DESC_EXTRA = ShouldShowDescInline(entry) and 18 or 0
    local HDR_H = NAME_H + DESC_EXTRA
    local rowH  = HDR_H + itemCount * OL_ITEM_H + 4

    local row = CreateFrame("Frame", nil, parent)
    row:SetHeight(rowH)
    ApplyControlMixin(row, entry, adapter, trackerID)
    -- No StampRowStripe on the outer frame; the list manages its own internal stripes.

    if entry.name then
        local padX = GetSidePad()
        local lbl = zSF_Text(row, "GameFontNormalLarge", "LEFT")
        lbl:SetText(entry.name)
        lbl:SetTextColor(unpack(ZSF_COLOR_LABEL))
        lbl:SetPoint("TOPLEFT",  row, "TOPLEFT",  padX, -6)
        lbl:SetPoint("TOPRIGHT", row, "TOPRIGHT", -padX, -6)
        lbl:SetHeight(WIDGET_LABEL_H)
        if ShouldShowTooltip(entry) then
            zSF_AddTooltip(lbl, entry.name, entry.desc, secondary)
        end
        if ShouldShowDescInline(entry) then
            local descFS = zSF_Text(row, "GameFontHighlightSmall")
            descFS:SetText(entry.desc)
            descFS:SetTextColor(unpack(ZSF_COLOR_DESC))
            descFS:SetPoint("TOPLEFT",  row, "TOPLEFT",  padX, -(6 + WIDGET_LABEL_H + 4))
            descFS:SetPoint("TOPRIGHT", row, "TOPRIGHT", -padX, -(6 + WIDGET_LABEL_H + 4))
            descFS:SetJustifyH("LEFT")
        end
    end

    -- Items container; item rows stripe independently of the outer content parent.
    local itemsFrame = CreateFrame("Frame", nil, row)
    itemsFrame:SetPoint("TOPLEFT",  row, "TOPLEFT",  0, -HDR_H)
    itemsFrame:SetPoint("TOPRIGHT", row, "TOPRIGHT", 0, -HDR_H)
    itemsFrame:SetHeight(itemCount * OL_ITEM_H)

    local PAD_X = GetSidePad()

    -- Build one static frame per position (only labels/colors update on reorder).
    ---@class OLItemRow
    ---@field frame  Frame
    ---@field upBtn  Frame
    ---@field dnBtn  Frame
    ---@field upTex  Texture
    ---@field dnTex  Texture
    ---@field label  FontString
    local itemRows = {}
    for i = 1, itemCount do
        local ir = CreateFrame("Frame", nil, itemsFrame)
        ir:SetPoint("TOPLEFT",  itemsFrame, "TOPLEFT",  0, -(i - 1) * OL_ITEM_H)
        ir:SetPoint("TOPRIGHT", itemsFrame, "TOPRIGHT", 0, -(i - 1) * OL_ITEM_H)
        ir:SetHeight(OL_ITEM_H)
        StampRowStripe(ir, itemsFrame, colors)

        local upBtn = zSF_MakeVerticalArrow(ir, false, primary, secondary)
        upBtn:SetPoint("LEFT", ir, "LEFT", PAD_X, 0)

        local dnBtn = zSF_MakeVerticalArrow(ir, true, primary, secondary)
        dnBtn:SetPoint("LEFT", ir, "LEFT", PAD_X + OL_BTN_SZ + OL_BTN_GAP, 0)

        local lbl = zSF_Text(ir, "GameFontNormal")
        lbl:SetPoint("LEFT",  dnBtn, "RIGHT",  6,     0)
        lbl:SetPoint("RIGHT", ir,    "RIGHT", -PAD_X, 0)
        lbl:SetJustifyH("LEFT")

        itemRows[i] = { frame = ir, upBtn = upBtn, dnBtn = dnBtn,
                        upTex = upBtn.normalTex, dnTex = dnBtn.normalTex, label = lbl }
    end

    -- Section-style border around the item rows region, matching the stripe inset.
    do
        local inset    = math.max(GetSidePad() - 8, 0)
        local borderFr = CreateFrame("Frame", nil, row)
        borderFr:SetFrameLevel(row:GetFrameLevel() + 10)
        borderFr:SetPoint("TOPLEFT",     itemsFrame, "TOPLEFT",     inset, 0)
        borderFr:SetPoint("BOTTOMRIGHT", itemsFrame, "BOTTOMRIGHT", -inset, 0)
        local sc = ZSF.COLOR_SECTION_BORDER
        zSF_DrawBorder(borderFr, sc[1], sc[2], sc[3], sc[4])
    end

    -- Live ordered array of item keys.
    local order  = {}
    local _order = entry.order

    -- Populate order from DB, falling back to entry.order then arbitrary pairs.
    local function PullOrder()
        row:ReadValue()
        local saved = row:GetCachedValue()
        if type(saved) == "table" and #saved > 0 then
            wipe(order)
            for i, k in ipairs(saved) do order[i] = k end
        else
            wipe(order)
            local fallback = type(_order) == "table" and _order or nil
            if fallback then
                for i, k in ipairs(fallback) do order[i] = k end
            else
                for k in pairs(items) do order[#order + 1] = k end
            end
        end
    end

    -- Refresh item labels and button colors to reflect current order state.
    local function Redraw()
        for i, ir in ipairs(itemRows) do
            ir.label:SetText(items[order[i] or ""] or order[i] or "")
            local isFirst = (i == 1)
            local isLast  = (i == itemCount)
            local upC = isFirst and ZSF_COLOR_DISABLED or primary
            local dnC = isLast  and ZSF_COLOR_DISABLED or primary
            ir.upTex:SetVertexColor(upC[1], upC[2], upC[3], 1)
            ir.dnTex:SetVertexColor(dnC[1], dnC[2], dnC[3], 1)
        end
    end

    -- Swap two positions, persist the new order, and redraw.
    local function SwapItems(a, b)
        order[a], order[b] = order[b], order[a]
        local copy = {}
        for i = 1, #order do copy[i] = order[i] end
        row:SetCachedValue(copy)
        row:WriteValue()
        Redraw()
    end

    -- Wire per-position button scripts (static closures; position index is stable).
    -- Note: zSF_MakeVerticalArrow already handles OnEnter/OnLeave color for the
    -- general case; we override them here to also respect disabled state.
    for i = 1, itemCount do
        local ir = itemRows[i]
        ir.upBtn:SetScript("OnEnter", function()
            if i > 1 then
                ir.upTex:SetVertexColor(secondary[1], secondary[2], secondary[3], 1)
            end
        end)
        ir.upBtn:SetScript("OnLeave", function()
            local c = (i == 1) and ZSF_COLOR_DISABLED or primary
            ir.upTex:SetVertexColor(c[1], c[2], c[3], 1)
        end)
        ir.upBtn:SetScript("OnMouseDown", function()
            if i > 1 then ir.upTex:SetVertexColor(0.72, 0.72, 0.72, 1) end
        end)
        ir.upBtn:SetScript("OnMouseUp", function()
            if i > 1 then SwapItems(i, i - 1) end
            local c = (i == 1) and ZSF_COLOR_DISABLED or primary
            ir.upTex:SetVertexColor(c[1], c[2], c[3], 1)
        end)
        ir.dnBtn:SetScript("OnEnter", function()
            if i < itemCount then
                ir.dnTex:SetVertexColor(secondary[1], secondary[2], secondary[3], 1)
            end
        end)
        ir.dnBtn:SetScript("OnLeave", function()
            local c = (i == itemCount) and ZSF_COLOR_DISABLED or primary
            ir.dnTex:SetVertexColor(c[1], c[2], c[3], 1)
        end)
        ir.dnBtn:SetScript("OnMouseDown", function()
            if i < itemCount then ir.dnTex:SetVertexColor(0.72, 0.72, 0.72, 1) end
        end)
        ir.dnBtn:SetScript("OnMouseUp", function()
            if i < itemCount then SwapItems(i, i + 1) end
            local c = (i == itemCount) and ZSF_COLOR_DISABLED or primary
            ir.dnTex:SetVertexColor(c[1], c[2], c[3], 1)
        end)
    end

    PullOrder()
    Redraw()

    row:SetRefreshHandler(function()
        PullOrder()
        Redraw()
    end)

    return row, nil
end

-- ── Multiselect ───────────────────────────────────────────────────────────────
-- Full-width dropdown button that expands a scrollable checkbox list.
-- Schema: widgetType="multiselect", values={k=label}, get/set using {[k]=true} tables.
local function Build_Multiselect(entry, parent, adapter, trackerID, primary, secondary, colors)
    local ZSF_COLOR_LABEL = colors.label or ZSF.COLOR_LABEL
    local row = CreateFrame("Frame", nil, parent)
    ApplyControlMixin(row, entry, adapter, trackerID)
    row:SetHeight(ZSF.ROW_HEIGHT)
    StampRowStripe(row, parent, colors)

    local _, action, aw = BuildFullWidthLabel(row, entry, secondary, colors, GetInteractableW(entry))

    local btn, summaryText = zSF_MakeDropButton(action, aw, primary)
    btn:SetPoint("CENTER", action, "CENTER", 0, 0)

    local PR, PG, PB = primary[1], primary[2], primary[3]
    local SR, SG, SB = secondary[1], secondary[2], secondary[3]
    local _values     = entry.values
    local _order      = entry.order

    local function ComputeSummary(tbl)
        local resolved = type(_values) == "function" and _values() or _values
        if not resolved or not tbl then return "---" end
        local selCount, total, firstLabel = 0, 0, nil
        for k in pairs(resolved) do
            total = total + 1
            if tbl[k] then
                selCount = selCount + 1
                if not firstLabel then firstLabel = resolved[k] end
            end
        end
        if selCount == 0     then return "None" end
        if selCount == total then return "All" end
        if selCount == 1     then return tostring(firstLabel) end
        return selCount .. " selected"
    end

    local popup, inner, sf, popupCatcher
    local itemBtns  = {}
    local popupOpen = false
    local MS_ITEM_H = 22
    local MS_MAX_H  = 200
    local MS_PAD    = 4
    local CB_SZ     = 12

    local function ClosePopup()
        if popup then popup:Hide() end
        if popupCatcher then popupCatcher:Hide() end
        popupOpen = false
    end

    local function EnsurePopupFrame()
        if popup then return end

        popupCatcher = CreateFrame("Frame", nil, UIParent)
        popupCatcher:SetAllPoints(UIParent)
        popupCatcher:SetFrameStrata("FULLSCREEN_DIALOG")
        popupCatcher:SetFrameLevel(395)
        popupCatcher:EnableMouse(true)
        popupCatcher:Hide()
        popupCatcher:SetScript("OnMouseDown", ClosePopup)

        popup = CreateFrame("Frame", nil, UIParent)
        popup:SetFrameStrata("FULLSCREEN_DIALOG")
        popup:SetFrameLevel(400)
        popup:SetClampedToScreen(true)
        popup:EnableMouse(true)
        popup:SetWidth(math.max(aw, 120))
        popup:Hide()
        zSF_FillBG(popup, 0.06, 0.06, 0.06, 0.97)
        zSF_DrawBorder(popup, PR, PG, PB, 0.65)

        inner = CreateFrame("Frame", nil, popup)
        inner:SetWidth(popup:GetWidth())

        sf = CreateFrame("ScrollFrame", nil, popup)
        sf:SetPoint("TOPLEFT",     popup, "TOPLEFT",     0, -MS_PAD)
        sf:SetPoint("BOTTOMRIGHT", popup, "BOTTOMRIGHT", 0,  MS_PAD)
        sf:SetScrollChild(inner)
        sf:SetFrameLevel(popup:GetFrameLevel() + 1)
        sf:EnableMouseWheel(true)
        sf:SetScript("OnMouseWheel", function(_, d)
            local cur = sf:GetVerticalScroll()
            sf:SetVerticalScroll(math.max(0, math.min(sf:GetVerticalScrollRange(), cur - d * 20)))
        end)
    end

    local function OpenPopup()
        EnsurePopupFrame()
        if popupOpen then ClosePopup(); return end

        for _, ib in ipairs(itemBtns) do ib:Hide(); ib:SetParent(nil) end
        itemBtns = {}

        local resolved = type(_values) == "function" and _values() or _values
        if not resolved then return end

        local keyList = {}
        if _order then
            for _, k in ipairs(_order) do keyList[#keyList + 1] = k end
        else
            for k in pairs(resolved) do keyList[#keyList + 1] = k end
            table.sort(keyList, function(a, b) return tostring(a) < tostring(b) end)
        end

        local curTbl = row:GetCachedValue() or {}
        local yOff = 0
        for _, key in ipairs(keyList) do
            local displayText = resolved[key]
            if displayText then
                local ib = CreateFrame("Button", nil, inner)
                ib:SetHeight(MS_ITEM_H)
                ib:SetPoint("TOPLEFT",  inner, "TOPLEFT",  1, -yOff)
                ib:SetPoint("TOPRIGHT", inner, "TOPRIGHT", -1, -yOff)
                ib:SetFrameLevel(popup:GetFrameLevel() + 3)

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

                local cb = ib:CreateTexture(nil, "ARTWORK")
                cb:SetSize(CB_SZ, CB_SZ)
                cb:SetPoint("LEFT", ib, "LEFT", 8, 0)

                local function RefreshCB(checked)
                    if checked then
                        cb:SetColorTexture(SR, SG, SB, 1)
                    else
                        cb:SetColorTexture(0.25, 0.25, 0.25, 1)
                    end
                end
                RefreshCB(curTbl[key] == true)

                local lbl = ib:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
                lbl:SetText(tostring(displayText))
                lbl:SetPoint("LEFT",  ib, "LEFT",  8 + CB_SZ + 6, 0)
                lbl:SetPoint("RIGHT", ib, "RIGHT", -8, 0)
                lbl:SetJustifyH("LEFT")
                lbl:SetTextColor(unpack(ZSF_COLOR_LABEL))

                ib._key = key
                ib._rf  = RefreshCB

                ib:SetScript("OnEnter", function() hlTex:SetColorTexture(PR, PG, PB, 0.22) end)
                ib:SetScript("OnLeave", function() hlTex:SetColorTexture(PR, PG, PB, 0) end)
                ib:SetScript("OnClick", function()
                    if curTbl[ib._key] then
                        curTbl[ib._key] = nil
                    else
                        curTbl[ib._key] = true
                    end
                    ib._rf(curTbl[ib._key] == true)
                    row:SetCachedValue(curTbl)
                    row:WriteValue()
                    summaryText:SetText(ComputeSummary(curTbl))
                    row._ctx:RefreshAll()
                end)

                itemBtns[#itemBtns + 1] = ib
                yOff = yOff + MS_ITEM_H
            end
        end

        inner:SetHeight(yOff)
        local popH = math.min(yOff + MS_PAD * 2, MS_MAX_H)
        popup:SetHeight(popH)
        popup:ClearAllPoints()

        local _, btnY = btn:GetCenter()
        if btnY and (btnY - btn:GetHeight() / 2 - popH) < 10 then
            popup:SetPoint("BOTTOMLEFT", btn, "TOPLEFT", 0, 2)
        else
            popup:SetPoint("TOPLEFT", btn, "BOTTOMLEFT", 0, -2)
        end

        popup:Show()
        popupOpen = true
        popupCatcher:SetFrameLevel(396)
        popupCatcher:Show()
        popup:SetFrameLevel(400)
    end

    btn:SetScript("OnClick", OpenPopup)
    btn:SetScript("OnEnter", function() zSF_DrawBorder(btn, ZSF.COLOR_BORDER[1], ZSF.COLOR_BORDER[2], ZSF.COLOR_BORDER[3], 0.80) end)
    btn:SetScript("OnLeave", function() zSF_DrawBorder(btn) end)

    summaryText:SetText(ComputeSummary(row:GetCachedValue()))

    row:SetRefreshHandler(function(self)
        self:ReadValue()
        local t = self:GetCachedValue()
        summaryText:SetText(ComputeSummary(t))
        if popupOpen and popup then
            for _, ib in ipairs(itemBtns) do
                ib._rf(t and t[ib._key] == true or false)
            end
        end
    end)

    return row, nil
end

-- ── Keybind ───────────────────────────────────────────────────────────────────
-- Full-width button that captures a key combination when clicked.
-- Schema: widgetType="keybind", key, get/set using WoW binding strings ("CTRL-A").
local function Build_Keybind(entry, parent, adapter, trackerID, primary, secondary, colors)
    local ZSF_COLOR_LABEL    = colors.label    or ZSF.COLOR_LABEL
    local ZSF_COLOR_DISABLED = colors.disabled or ZSF.COLOR_DISABLED
    local row = CreateFrame("Frame", nil, parent)
    ApplyControlMixin(row, entry, adapter, trackerID)
    row:SetHeight(ZSF.ROW_HEIGHT)
    StampRowStripe(row, parent, colors)

    local _, action, aw = BuildFullWidthLabel(row, entry, secondary, colors)

    local kbBtn, kbText = zSF_MakeDropButton(action, aw, primary)
    kbBtn:SetPoint("CENTER", action, "CENTER", 0, 0)

    local PR, PG, PB = primary[1], primary[2], primary[3]
    local SR, SG, SB = secondary[1], secondary[2], secondary[3]
    local listening  = false

    local function DisplayBinding(val)
        if val and val ~= "" then
            kbText:SetText(val)
            kbText:SetTextColor(unpack(ZSF_COLOR_LABEL))
        else
            kbText:SetText("Not bound")
            kbText:SetTextColor(unpack(ZSF_COLOR_DISABLED))
        end
    end

    -- Invisible full-screen frame that swallows keyboard input while listening.
    local captureFrame = CreateFrame("Frame", nil, UIParent)
    captureFrame:SetFrameStrata("FULLSCREEN_DIALOG")
    captureFrame:SetFrameLevel(410)
    captureFrame:SetAllPoints(UIParent)
    captureFrame:EnableMouse(true)
    captureFrame:EnableKeyboard(false)
    captureFrame:SetPropagateKeyboardInput(false)
    captureFrame:Hide()

    local function StopListening()
        listening = false
        captureFrame:Hide()
        captureFrame:EnableKeyboard(false)
        zSF_DrawBorder(kbBtn)
    end

    captureFrame:SetScript("OnKeyDown", function(_, key)
        -- Ignore bare modifier-key presses; wait for the real key.
        if key == "LSHIFT" or key == "RSHIFT"
        or key == "LCTRL"  or key == "RCTRL"
        or key == "LALT"   or key == "RALT" then
            return
        end
        if key == "ESCAPE" then
            StopListening()
            DisplayBinding(row:GetCachedValue())
            return
        end
        local parts = {}
        if IsAltKeyDown()     then parts[#parts + 1] = "ALT"   end
        if IsControlKeyDown() then parts[#parts + 1] = "CTRL"  end
        if IsShiftKeyDown()   then parts[#parts + 1] = "SHIFT" end
        parts[#parts + 1] = key
        local binding = table.concat(parts, "-")
        StopListening()
        row:SetCachedValue(binding)
        row:WriteValue()
        DisplayBinding(binding)
        row._ctx:RefreshAll()
    end)

    captureFrame:SetScript("OnMouseDown", function()
        StopListening()
        DisplayBinding(row:GetCachedValue())
    end)

    kbBtn:SetScript("OnClick", function()
        if listening then
            StopListening()
            DisplayBinding(row:GetCachedValue())
            return
        end
        listening = true
        kbText:SetText("Press a key...")
        kbText:SetTextColor(SR, SG, SB, 1)
        zSF_DrawBorder(kbBtn, SR, SG, SB, 0.80)
        captureFrame:EnableKeyboard(true)
        captureFrame:Show()
    end)

    kbBtn:SetScript("OnEnter", function()
        if not listening then zSF_DrawBorder(kbBtn, ZSF.COLOR_BORDER[1], ZSF.COLOR_BORDER[2], ZSF.COLOR_BORDER[3], 0.80) end
    end)
    kbBtn:SetScript("OnLeave", function()
        if not listening then zSF_DrawBorder(kbBtn) end
    end)

    DisplayBinding(row:GetCachedValue())

    row:SetRefreshHandler(function(self)
        self:ReadValue()
        if not listening then
            DisplayBinding(self:GetCachedValue())
        end
    end)

    return row, nil
end

-- ── Font Select ───────────────────────────────────────────────────────────────
-- Dropdown where the button and every item label render in the actual font face.
-- Supports all width variants; layout matches Build_Select exactly.
-- Schema: widgetType="fontSelect", values=function, previewFetch=LSM_Fetch("font")
local function Build_FontSelect(entry, parent, adapter, trackerID, primary, secondary, colors)
    local ZSF_COLOR_LABEL = colors.label or ZSF.COLOR_LABEL
    local ZSF_COLOR_DESC  = colors.desc  or ZSF.COLOR_DESC
    local _previewFetch   = entry.previewFetch
    local row = CreateFrame("Frame", nil, parent)
    ApplyControlMixin(row, entry, adapter, trackerID)

    local widthKey = ResolveWidth(entry, DEFAULT_WIDTHS.fontSelect)
    local fraction = WIDTH_FRACTIONS[widthKey] or 1.0

    local function applyButtonFont(dr, key)
        local path = _previewFetch and _previewFetch(key)
        if path then
            local _, sz, fl = dr.text:GetFont()
            pcall(dr.text.SetFont, dr.text, path, sz or 12, fl or "")
        end
    end

    local function decorateFontItem(item, key)
        local path = _previewFetch and _previewFetch(key)
        if path then
            local _, sz, fl = item._lbl:GetFont()
            pcall(item._lbl.SetFont, item._lbl, path, sz or 12, fl or "")
        end
    end

    if fraction >= 0.999 then
        row:SetHeight(ZSF.ROW_HEIGHT)
        StampRowStripe(row, parent, colors)

        local _, action, aw = BuildFullWidthLabel(row, entry, secondary, colors, GetInteractableW(entry))

        local curVal  = row:GetCachedValue()
        local selText = curVal and tostring(curVal) or (entry.selected or "---")
        local result  = BuildCentredDD(action, aw, 20, selText, primary)
        WireDropdown(row, entry, result, nil, applyButtonFont)
        result.SetItemDecorator(decorateFontItem)

        return row, nil
    end

    if fraction >= 0.499 then
        row:SetHeight(ZSF.ROW_HEIGHT)
        row._buildWidget = function(cell, frac)
            local aw = GetInteractableW(entry)

            local lbl = zSF_Text(cell, "GameFontNormalLarge")
            lbl:SetText(entry.name or "")
            lbl:SetTextColor(unpack(ZSF_COLOR_LABEL))
            LayoutHalfLabel(lbl, cell, cell:GetWidth() - aw - 8, entry, secondary, colors)

            local ddHost = CreateFrame("Frame", nil, cell)
            ddHost:SetWidth(aw)
            ddHost:SetHeight(20)
            ddHost:SetPoint("RIGHT", cell, "RIGHT", 0, 0)

            local curVal  = row:GetCachedValue()
            local selText = curVal and tostring(curVal) or (entry.selected or "---")
            local result  = zSF_BuildDropdown(ddHost, selText, primary)
            WireDropdown(row, entry, result, nil, applyButtonFont)
            result.SetItemDecorator(decorateFontItem)
        end
        return row, nil
    end

    if fraction >= 0.33 then
        -- Third: FlushRow adds label above, dropdown fills cell.
        row:SetHeight(ZSF.ROW_HEIGHT)
        row._buildWidget = function(cell, frac)
            local curVal  = row:GetCachedValue()
            local selText = curVal and tostring(curVal) or (entry.selected or "---")
            local result  = zSF_BuildDropdown(cell, selText, primary)
            WireDropdown(row, entry, result, nil, applyButtonFont)
            result.SetItemDecorator(decorateFontItem)
        end
        return row, nil
    end

    -- Quarter: FlushRow adds label above, dropdown fills cell.
    row:SetHeight(ZSF.ROW_HEIGHT)
    row._buildWidget = function(cell, frac)
        local curVal  = row:GetCachedValue()
        local selText = curVal and tostring(curVal) or (entry.selected or "---")
        local result  = zSF_BuildDropdown(cell, selText, primary)
        WireDropdown(row, entry, result, nil, applyButtonFont)
        result.SetItemDecorator(decorateFontItem)
    end

    return row, nil
end

-- ── Sound ─────────────────────────────────────────────────────────────────────
-- Plays the selected sound on pick.  Each menu item has a speaker icon so the
-- user can audition sounds without committing.
-- Supports all width variants; layout matches Build_Select exactly.
-- Schema: widgetType="sound", values=function, previewFetch=LSM_Fetch("sound")
local function Build_Sound(entry, parent, adapter, trackerID, primary, secondary, colors)
    local ZSF_COLOR_LABEL = colors.label or ZSF.COLOR_LABEL
    local ZSF_COLOR_DESC  = colors.desc  or ZSF.COLOR_DESC
    local _previewFetch   = entry.previewFetch
    local row = CreateFrame("Frame", nil, parent)
    ApplyControlMixin(row, entry, adapter, trackerID)

    local widthKey = ResolveWidth(entry, DEFAULT_WIDTHS.sound)
    local fraction = WIDTH_FRACTIONS[widthKey] or 1.0

    local onAfterPick = _previewFetch and function(key)
        local path = _previewFetch(key)
        if path then PlaySoundFile(path, "Master") end
    end

    local function decorateSoundItem(item, key)
        item._lbl:ClearAllPoints()
        item._lbl:SetPoint("LEFT",  item, "LEFT",   8,   0)
        item._lbl:SetPoint("RIGHT", item, "RIGHT", -26,  0)

        local spk = CreateFrame("Button", nil, item)
        spk:SetSize(16, 16)
        spk:SetPoint("RIGHT", item, "RIGHT", -4, 0)

        local icon = spk:CreateTexture(nil, "ARTWORK")
        icon:SetTexture("Interface\\Common\\VoiceChat-Speaker")
        icon:SetAllPoints(spk)
        icon:SetDesaturated(true)
        icon:SetVertexColor(0.75, 0.75, 0.75, 1)

        local iconOn = spk:CreateTexture(nil, "HIGHLIGHT")
        iconOn:SetTexture("Interface\\Common\\VoiceChat-On")
        iconOn:SetAllPoints(spk)

        spk:SetScript("OnClick", function()
            local path = _previewFetch and _previewFetch(key)
            if path then PlaySoundFile(path, "Master") end
        end)
    end

    if fraction >= 0.999 then
        row:SetHeight(ZSF.ROW_HEIGHT)
        StampRowStripe(row, parent, colors)

        local _, action, aw = BuildFullWidthLabel(row, entry, secondary, colors, GetInteractableW(entry))

        local curVal  = row:GetCachedValue()
        local selText = curVal and tostring(curVal) or (entry.selected or "---")
        local result  = BuildCentredDD(action, aw, 20, selText, primary)
        WireDropdown(row, entry, result, onAfterPick)
        result.SetItemDecorator(decorateSoundItem)

        return row, nil
    end

    if fraction >= 0.499 then
        row:SetHeight(ZSF.ROW_HEIGHT)
        row._buildWidget = function(cell, frac)
            local aw = GetInteractableW(entry)

            local lbl = zSF_Text(cell, "GameFontNormalLarge")
            lbl:SetText(entry.name or "")
            lbl:SetTextColor(unpack(ZSF_COLOR_LABEL))
            LayoutHalfLabel(lbl, cell, cell:GetWidth() - aw - 8, entry, secondary, colors)

            local ddHost = CreateFrame("Frame", nil, cell)
            ddHost:SetWidth(aw)
            ddHost:SetHeight(20)
            ddHost:SetPoint("RIGHT", cell, "RIGHT", 0, 0)

            local curVal  = row:GetCachedValue()
            local selText = curVal and tostring(curVal) or (entry.selected or "---")
            local result  = zSF_BuildDropdown(ddHost, selText, primary)
            WireDropdown(row, entry, result, onAfterPick)
            result.SetItemDecorator(decorateSoundItem)
        end
        return row, nil
    end

    if fraction >= 0.33 then
        -- Third: FlushRow adds label above, dropdown fills cell.
        row:SetHeight(ZSF.ROW_HEIGHT)
        row._buildWidget = function(cell, frac)
            local curVal  = row:GetCachedValue()
            local selText = curVal and tostring(curVal) or (entry.selected or "---")
            local result  = zSF_BuildDropdown(cell, selText, primary)
            WireDropdown(row, entry, result, onAfterPick)
            result.SetItemDecorator(decorateSoundItem)
        end
        return row, nil
    end

    -- Quarter: FlushRow adds label above, dropdown fills cell.
    row:SetHeight(ZSF.ROW_HEIGHT)
    row._buildWidget = function(cell, frac)
        local curVal  = row:GetCachedValue()
        local selText = curVal and tostring(curVal) or (entry.selected or "---")
        local result  = zSF_BuildDropdown(cell, selText, primary)
        WireDropdown(row, entry, result, onAfterPick)
        result.SetItemDecorator(decorateSoundItem)
    end

    return row, nil
end

-- ── LSM-backed texture / bar / border Select ──────────────────────────────────
-- Same row layout as Build_Select but with a texture swatch on the button and
-- in each item.  Supports all width variants.
-- Schema: widgetType="bgSelect"/"barSelect"/"borderSelect",
--         values=function, previewFetch=LSM_Fetch("background"/"statusbar"/"border")
local function Build_LSMSelect(entry, parent, adapter, trackerID, primary, secondary, colors)
    local ZSF_COLOR_LABEL = colors.label or ZSF.COLOR_LABEL
    local ZSF_COLOR_DESC  = colors.desc  or ZSF.COLOR_DESC
    local _previewFetch   = entry.previewFetch
    local row = CreateFrame("Frame", nil, parent)
    ApplyControlMixin(row, entry, adapter, trackerID)

    local widthKey = ResolveWidth(entry, DEFAULT_WIDTHS.bgSelect)
    local fraction = WIDTH_FRACTIONS[widthKey] or 1.0

    local btnSwatch = nil
    local function applyButtonSwatch(dr, key)
        local path = _previewFetch and _previewFetch(key)
        if not btnSwatch then
            btnSwatch = dr.frame:CreateTexture(nil, "ARTWORK")
            btnSwatch:SetSize(40, 10)
            btnSwatch:SetPoint("RIGHT", dr.frame, "RIGHT", -22, 0)
            dr.text:ClearAllPoints()
            dr.text:SetPoint("LEFT",  dr.frame, "LEFT",  6, 0)
            dr.text:SetPoint("RIGHT", dr.frame, "RIGHT", -66, 0)
        end
        if path then btnSwatch:SetTexture(path); btnSwatch:Show()
        else         btnSwatch:Hide() end
    end

    local function decorateLSMItem(item, key)
        local path = _previewFetch and _previewFetch(key)
        if not path then return end
        item._lbl:ClearAllPoints()
        item._lbl:SetPoint("LEFT",  item, "LEFT",   8,   0)
        item._lbl:SetPoint("RIGHT", item, "RIGHT", -62,  0)

        local bar = item:CreateTexture(nil, "ARTWORK")
        bar:SetSize(52, 10)
        bar:SetPoint("RIGHT", item, "RIGHT", -4, 0)
        bar:SetTexture(path)
    end

    if fraction >= 0.999 then
        row:SetHeight(ZSF.ROW_HEIGHT)
        StampRowStripe(row, parent, colors)

        local _, action, aw = BuildFullWidthLabel(row, entry, secondary, colors, GetInteractableW(entry))

        local curVal  = row:GetCachedValue()
        local selText = curVal and tostring(curVal) or (entry.selected or "---")
        local result  = BuildCentredDD(action, aw, 20, selText, primary)
        WireDropdown(row, entry, result, nil, applyButtonSwatch)
        result.SetItemDecorator(decorateLSMItem)

        return row, nil
    end

    if fraction >= 0.499 then
        row:SetHeight(ZSF.ROW_HEIGHT)
        row._buildWidget = function(cell, frac)
            local aw = GetInteractableW(entry)

            local lbl = zSF_Text(cell, "GameFontNormalLarge")
            lbl:SetText(entry.name or "")
            lbl:SetTextColor(unpack(ZSF_COLOR_LABEL))
            LayoutHalfLabel(lbl, cell, cell:GetWidth() - aw - 8, entry, secondary, colors)

            local ddHost = CreateFrame("Frame", nil, cell)
            ddHost:SetWidth(aw)
            ddHost:SetHeight(20)
            ddHost:SetPoint("RIGHT", cell, "RIGHT", 0, 0)

            local curVal  = row:GetCachedValue()
            local selText = curVal and tostring(curVal) or (entry.selected or "---")
            local result  = zSF_BuildDropdown(ddHost, selText, primary)
            WireDropdown(row, entry, result, nil, applyButtonSwatch)
            result.SetItemDecorator(decorateLSMItem)
        end
        return row, nil
    end

    if fraction >= 0.33 then
        -- Third: FlushRow adds label above, dropdown fills cell.
        row:SetHeight(ZSF.ROW_HEIGHT)
        row._buildWidget = function(cell, frac)
            local curVal  = row:GetCachedValue()
            local selText = curVal and tostring(curVal) or (entry.selected or "---")
            local result  = zSF_BuildDropdown(cell, selText, primary)
            WireDropdown(row, entry, result, nil, applyButtonSwatch)
            result.SetItemDecorator(decorateLSMItem)
        end
        return row, nil
    end

    -- Quarter: FlushRow adds label above, dropdown fills cell.
    row:SetHeight(ZSF.ROW_HEIGHT)
    row._buildWidget = function(cell, frac)
        local curVal  = row:GetCachedValue()
        local selText = curVal and tostring(curVal) or (entry.selected or "---")
        local result  = zSF_BuildDropdown(cell, selText, primary)
        WireDropdown(row, entry, result, nil, applyButtonSwatch)
        result.SetItemDecorator(decorateLSMItem)
    end

    return row, nil
end

-- ── BUILDERS ────────────────────────────────────────────────────────────────

local BUILDERS = {
    toggle       = Build_Toggle,
    range        = Build_Range,
    select       = Build_Select,
    color        = Build_Color,
    colorAlpha   = Build_ColorAlpha,
    input        = Build_Input,
    filler       = Build_Filler,
    header       = Build_Header,
    spacer       = Build_Spacer,
    button       = Build_Button,
    text         = Build_Text,
    group        = Build_Group,
    textarea     = Build_Textarea,
    output       = Build_Textarea,  -- readonly variant; entry.readonly is forced true at runtime
    orderList    = Build_OrderList,
    multiselect  = Build_Multiselect,
    keybind      = Build_Keybind,
    -- LSM-backed types: values + previewFetch supplied by the schema, never by zSF.
    fontSelect   = Build_FontSelect,
    sound        = Build_Sound,
    bgSelect     = Build_LSMSelect,
    barSelect    = Build_LSMSelect,
    borderSelect = Build_LSMSelect,
    -- tab          = Build_Tab,
}

-- ── Row auto-packing ─────────────────────────────────────────────────────────
-- Widgets with width "half" or "third" are packed side-by-side until the row
-- fills up, then flushed.

---@param rowBuf      table[]   Array of { widget=Frame, fraction=number, entry=table }
---@param parent      Frame     The scrollContent parent (or pack-group container).
---@param yOffset     number    Current vertical cursor.
---@param primary     table
---@param secondary   table
---@param items       table|nil Layout record list to append the flushed container to.
---@param captureRows table|nil If provided, the created rowContainer is appended here.
---@return number yOffset       Updated cursor after the flushed row.
local function FlushRow(rowBuf, parent, yOffset, primary, secondary, items, captureRows, colors)
    local ZSF_COLOR_COL_DIVIDER = colors.colDivider or ZSF.COLOR_COL_DIVIDER
    local ZSF_COLOR_LABEL       = colors.label      or ZSF.COLOR_LABEL
    if #rowBuf == 0 then return yOffset end

    -- Full-width single entry (edge case): place directly and record.
    if #rowBuf == 1 and rowBuf[1].fraction >= 0.999 then
        local item = rowBuf[1]
        local w    = item.widget
        w:SetPoint("TOPLEFT",  parent, "TOPLEFT",  0, yOffset)
        w:SetPoint("TOPRIGHT", parent, "TOPRIGHT", 0, yOffset)
        yOffset = yOffset - w:GetHeight() - ZSF.ROW_GAP_Y
        if items then
            local swFn  = item.entry.showWhen
            local swTID = w._trackerID
            table.insert(items, {
                frame      = w,
                getVisible = swFn and function() return swFn(swTID) end or nil,
            })
        end
        -- Track for stripe repair when placed inside a pack group.
        if captureRows then table.insert(captureRows, w) end
        -- Resolve any deferred build (e.g. a range or select at full width
        -- that ended up in a pack group due to showWhen collapsing).
        if w._buildWidget then
            local PAD_X    = GetSidePad()
            local contentW = zSF_GetContentWidth() - PAD_X * 2
            local isBtn    = item.entry.widgetType == "button"
            if not isBtn then
                local lbl = zSF_Text(w, "GameFontNormal", "LEFT")
                lbl:SetText(item.entry.name or "")
                lbl:SetTextColor(unpack(ZSF_COLOR_LABEL))
                lbl:SetWidth(contentW)
                lbl:SetHeight(WIDGET_LABEL_H)
                local topOff = math.floor((w:GetHeight() - LABELED_BLOCK_H) / 2)
                lbl:SetPoint("TOPLEFT", w, "TOPLEFT", PAD_X, -topOff)
                if ShouldShowTooltip(item.entry) then
                    zSF_AddTooltip(lbl, item.entry.name or "", item.entry.desc, secondary)
                end
            end
            local cell = CreateFrame("Frame", nil, w)
            cell:SetWidth(contentW)
            if isBtn then
                cell:SetHeight(w:GetHeight())
                cell:SetPoint("TOPLEFT", w, "TOPLEFT", PAD_X, 0)
            else
                local topOff = math.floor((w:GetHeight() - LABELED_BLOCK_H) / 2)
                cell:SetHeight(PARTIAL_TOGGLE_H)
                cell:SetPoint("TOPLEFT", w, "TOPLEFT", PAD_X, -(topOff + WIDGET_LABEL_H + 2))
            end
            w._buildWidget(cell, 1.0)
            w._buildWidget = nil
        end
        return yOffset
    end

    -- Multi-column row: stamp stripe across the whole logical row height first,
    -- then place each cell inside a shared row container so the stripe spans all columns.
    local contentW = zSF_GetContentWidth()
    local PAD_X    = GetSidePad()
    local avail    = contentW - PAD_X * 2
    local gap      = ZSF.CONTENT_PAD_X  -- inter-column gap

    local n      = #rowBuf
    local maxH   = 0

    for _, item in ipairs(rowBuf) do
        if item.widget:GetHeight() > maxH then
            maxH = item.widget:GetHeight()
        end
    end

    -- Row stripe: single bg container that spans full width.
    local rowContainer = CreateFrame("Frame", nil, parent)
    rowContainer:SetPoint("TOPLEFT",  parent, "TOPLEFT",  0, yOffset)
    rowContainer:SetPoint("TOPRIGHT", parent, "TOPRIGHT", 0, yOffset)
    rowContainer:SetHeight(maxH)
    StampRowStripe(rowContainer, parent, colors)
    if captureRows then table.insert(captureRows, rowContainer) end

    -- Record container for dynamic relayout. Visible when at least one column
    -- is not blocked by showWhen; invisible when all columns are hidden.
    if items then
        local containerGetVisible
        local cnHasShowWhen = false
        for _, it in ipairs(rowBuf) do
            if it.entry.showWhen then cnHasShowWhen = true; break end
        end
        if cnHasShowWhen then
            local capturedCols = {}
            for _, it in ipairs(rowBuf) do
                table.insert(capturedCols, {
                    showWhen  = it.entry.showWhen,
                    trackerID = it.widget._trackerID,
                })
            end
            containerGetVisible = function()
                for _, col in ipairs(capturedCols) do
                    if not col.showWhen or col.showWhen(col.trackerID) then return true end
                end
                return false
            end
        end
        table.insert(items, { frame = rowContainer, getVisible = containerGetVisible })
    end

    -- Compute each cell's pixel width from its actual schema fraction, not by equal
    -- division, so half+quarter+quarter doesn't collapse to thirds.
    --
    -- We need to know the total filled fraction up-front: a partial row (e.g. a
    -- single half-width widget alone on a row) must reserve the same inter-column
    -- gaps as a fully-packed row of the same cell size.  Without this, the lone
    -- half-width cell would receive 0.5*avail pixels while a paired half receives
    -- 0.5*(avail-gap) — making the solo widget visibly wider.
    local totalFraction = 0
    for _, item in ipairs(rowBuf) do totalFraction = totalFraction + item.fraction end
    -- How many cells would fill this row at these fractions?  Clamp to at least n
    -- so a genuinely full row (totalFraction ≈ 1) doesn't lose any gaps.
    local expectedN    = math.max(n, (totalFraction > 0) and math.floor(n / totalFraction + 0.5) or n)

    local halfGap      = math.floor(gap / 2)
    local totalContent = avail - gap * (expectedN - 1)
    local cellWidths   = {}
    local xOffsets     = {}
    local xCur         = PAD_X
    for i, item in ipairs(rowBuf) do
        local cw = math.floor(item.fraction * totalContent)
        cellWidths[i] = cw
        xOffsets[i]   = xCur
        xCur = xCur + cw + gap
    end

    for i, item in ipairs(rowBuf) do
        local xOff  = xOffsets[i]
        local cellW = cellWidths[i]
        local w     = item.widget
        w:ClearAllPoints()
        w:SetParent(rowContainer)
        w:SetPoint("TOPLEFT", rowContainer, "TOPLEFT", xOff, 0)
        w:SetWidth(cellW)
        w:SetHeight(maxH)

        -- 1px vertical divider before each column after the first.
        -- If the preceding widget has divider = false, the divider is still
        -- created (preserving layout spacing) but rendered fully transparent.
        if i > 1 then
            local prevEntry = rowBuf[i - 1].entry
            local div = rowContainer:CreateTexture(nil, "OVERLAY")
            div:SetColorTexture(
                ZSF_COLOR_COL_DIVIDER[1], ZSF_COLOR_COL_DIVIDER[2],
                ZSF_COLOR_COL_DIVIDER[3], ZSF_COLOR_COL_DIVIDER[4])
            -- Centre the 1px line in the gap between the two cells.
            local divX = xOff - halfGap
            PixelUtil.SetPoint(div, "TOPLEFT",    rowContainer, "TOPLEFT",    divX, 0)
            PixelUtil.SetPoint(div, "BOTTOMLEFT", rowContainer, "BOTTOMLEFT", divX, 0)
            PixelUtil.SetWidth(div, 1, 1)
            if prevEntry.divider == false then
                div:SetAlpha(0)
            end
        end

        -- If the widget deferred its control build, call it now with a cell frame.
        if w._buildWidget then
            -- _selfLabeled has two effects here (must remain in sync):
            --   1. Suppresses the auto-injected name label row above the cell.
            --   2. Sets the cell to full row height instead of the shrunk
            --      height reserved for content below a label row.
            -- Builders that render their own label at third/quarter width MUST
            -- set row._selfLabeled = true, or they get a duplicate label and a
            -- vertically-clipped cell.
            local isPackedButton = item.entry.widgetType == "button"
            local isHalfWidth    = item.fraction >= 0.499
            local isSelfLabeled  = w._selfLabeled

            -- Cells after the first are adjacent to a 1px divider; inset their
            -- content by ZSF.COL_INNER_PAD so widgets don't render flush against it.
            local innerPad = (i > 1) and ZSF.COL_INNER_PAD or 0

            -- Third/quarter widgets that don't self-label get a name row above.
            -- Compute the top offset to vertically center the [label+gap+widget] block.
            local topOff = (not isPackedButton and not isHalfWidth and not isSelfLabeled)
                and math.floor((maxH - LABELED_BLOCK_H) / 2)
                or 0

            if not isPackedButton and not isHalfWidth and not isSelfLabeled then
                local lbl = zSF_Text(w, "GameFontNormal", "LEFT")
                lbl:SetText(item.entry.name or "")
                lbl:SetTextColor(unpack(ZSF_COLOR_LABEL))
                lbl:SetWidth(cellW - innerPad)
                lbl:SetHeight(WIDGET_LABEL_H)
                lbl:SetPoint("TOPLEFT", w, "TOPLEFT", innerPad, -topOff)

                if ShouldShowTooltip(item.entry) then
                    zSF_AddTooltip(lbl, item.entry.name or "", item.entry.desc, secondary)
                end
            end

            local cell = CreateFrame("Frame", nil, w)
            cell:SetWidth(cellW - innerPad)
            if isPackedButton or isHalfWidth or isSelfLabeled then
                -- Cell fills the whole row height; widget handles internal positioning.
                cell:SetHeight(maxH)
                cell:SetPoint("TOPLEFT", w, "TOPLEFT", innerPad, 0)
            else
                cell:SetHeight(PARTIAL_TOGGLE_H)
                cell:SetPoint("TOPLEFT", w, "TOPLEFT", innerPad, -(topOff + WIDGET_LABEL_H + 2))
            end
            w._buildWidget(cell, item.fraction)
            w._buildWidget = nil -- one-shot
        end
    end

    -- If the row doesn't fill the full content width, add a phantom divider
    -- at the trailing edge of the last cell (alpha 0) so the row container
    -- has consistent geometry — the same spacing that exists between cells.
    if totalFraction < 0.999 then
        local lastX = xOffsets[n] + cellWidths[n]
        local divX  = lastX + halfGap
        local div = rowContainer:CreateTexture(nil, "OVERLAY")
        div:SetColorTexture(
            ZSF_COLOR_COL_DIVIDER[1], ZSF_COLOR_COL_DIVIDER[2],
            ZSF_COLOR_COL_DIVIDER[3], ZSF_COLOR_COL_DIVIDER[4])
        PixelUtil.SetPoint(div, "TOPLEFT",    rowContainer, "TOPLEFT",    divX, 0)
        PixelUtil.SetPoint(div, "BOTTOMLEFT", rowContainer, "BOTTOMLEFT", divX, 0)
        PixelUtil.SetWidth(div, 1, 1)
        div:SetAlpha(0)
    end

    yOffset = yOffset - maxH - ZSF.ROW_GAP_Y
    return yOffset
end

-- ── Pack groups ───────────────────────────────────────────────────────────────
-- Partial-width widgets between full-width barriers belong to a "pack group".
-- When any member's showWhen changes, the group is rebuilt so visible members
-- reflow to fill gaps left by hidden ones.

--- Walk all layout items in order and repaint alternating stripe colors.
--- Handles both regular rows and pack-group sub-rows so the global sequence is
--- consistent regardless of which rows showWhen has hidden.
---@param parent Frame  The content frame whose _zsfLayout to traverse.
local function RepairStripes(parent)
    local layout = parent and parent._zsfLayout
    if not layout then return end
    local ctxColors  = parent._zsfCtx and parent._zsfCtx._colors or {}
    local stripeEven = ctxColors.rowStripeEven or ZSF.COLOR_ROW_STRIPE_EVEN
    local stripeOdd  = ctxColors.rowStripeOdd  or ZSF.COLOR_ROW_STRIPE_ODD
    local idx = 0
    for _, item in ipairs(layout.items) do
        if item._pg then
            -- Pack group: stripe each live sub-row in order.
            for _, rowFrame in ipairs(item._pg.liveRows) do
                if rowFrame._zsfStripeBg then
                    idx = idx + 1
                    local col = (idx % 2 == 0) and stripeEven or stripeOdd
                    rowFrame._zsfStripeBg:SetColorTexture(col[1], col[2], col[3], col[4])
                end
            end
        elseif item.frame._zsfStripeBg and item.frame:IsShown() then
            idx = idx + 1
            local col = (idx % 2 == 0) and stripeEven or stripeOdd
            item.frame._zsfStripeBg:SetColorTexture(col[1], col[2], col[3], col[4])
        end
    end
end

--- Resize and reposition each section's faint border frame to match the current
--- item layout.  Must be called after all items have anchored to parent.
---@param layout table  The _zsfLayout attached to the content frame.
---@param parent Frame  The content frame.
local function PositionSectionBorders(layout, parent)
    if not layout.sections or #layout.sections == 0 then return end
    for _, sec in ipairs(layout.sections) do
        local secTop, secLastY, secLastH
        for i = sec.rowsStartIdx, sec.endIdx do
            local item = layout.items[i]
            local visible = not item.getVisible or item.getVisible()
            if visible then
                local _, _, _, _, yOff = item.frame:GetPoint(1)
                if yOff then
                    if not secTop then secTop = yOff end
                    secLastY = yOff
                    secLastH = item.frame:GetHeight()
                end
            end
        end
        if secTop then
            local height = math.max(secTop - (secLastY - secLastH), 0.001)
            local inset  = layout.sectionInset or (ZSF.CONTENT_PAD_X - 8)
            sec.borderFrame:ClearAllPoints()
            sec.borderFrame:SetPoint("TOPLEFT",  parent, "TOPLEFT",   inset, secTop)
            sec.borderFrame:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -inset, secTop)
            sec.borderFrame:SetHeight(height)
            sec.borderFrame:Show()
        else
            sec.borderFrame:Hide()
        end
    end
end

--- Rebuild visible widgets for a pack group and repack them into rows inside
--- the group container.  No-op when the visible member set hasn't changed.
--- Widget frames from a prior generation are hidden and removed from the
--- refresh list; new ones are built fresh by the registered builders.
---@param pg table  Pack group descriptor created by zSF_BuildFromSchema.
local function PackGroup_Layout(pg)
    -- Compute which members are visible right now.
    local vis = {}
    for i, m in ipairs(pg.members) do
        if not m.entry.showWhen or m.entry.showWhen(m.trackerID) then
            vis[#vis + 1] = i
        end
    end
    local sig = table.concat(vis, ",")
    if sig == (pg._lastSig or "") then return end
    pg._lastSig = sig

    -- Retire previous generation: hide frames and evict them from the
    -- refresh list so stale Refresh() calls don't re-trigger relayout.
    for _, f in ipairs(pg.liveFrames) do
        f:Hide()
        f._visibleWhen = nil
        f._layoutFrame = nil
        if f._ctx and f._ctx._widgets then
            for j = #f._ctx._widgets, 1, -1 do
                if f._ctx._widgets[j] == f then
                    table.remove(f._ctx._widgets, j)
                    break
                end
            end
        end
    end
    pg.liveFrames = {}
    pg.liveRows   = {}

    if #vis == 0 then
        pg.container:SetHeight(0.001)
        return
    end

    -- Restore the panel build context so builders can register widgets and
    -- zSF_GetContentWidth() returns the correct frame width (not 0).
    local prevCtx = zSettingsFrame and zSettingsFrame._buildCtx
    if zSettingsFrame then zSettingsFrame._buildCtx = pg._ctx end
    local colors = pg._eSnap or {}

    local rowBuf  = {}
    local rowFill = 0.0
    local yOff    = 0

    local function flushPG()
        if #rowBuf == 0 then return end
        for _, it in ipairs(rowBuf) do
            table.insert(pg.liveFrames, it.widget)
        end
        local capturedRows = {}
        yOff = FlushRow(rowBuf, pg.container, yOff, pg.primary, pg.secondary, nil, capturedRows, colors)
        for _, rc in ipairs(capturedRows) do
            table.insert(pg.liveRows,   rc)
            table.insert(pg.liveFrames, rc)
        end
        rowBuf  = {}
        rowFill = 0.0
    end

    for _, idx in ipairs(vis) do
        local m = pg.members[idx]
        local builder = BUILDERS[m.entry.widgetType]
        if builder then
            local widget = builder(m.entry, pg.container, pg.adapter, m.trackerID,
                                   pg.primary, pg.secondary, colors)
            -- Route showWhen-triggered relayout to the scroll-content parent.
            if m.entry.showWhen then
                widget._layoutFrame = pg.parent
            end
            if rowFill + m.fraction > 1.001 then
                flushPG()
            end
            table.insert(rowBuf, { widget = widget, fraction = m.fraction, entry = m.entry })
            rowFill = rowFill + m.fraction
        end
    end
    flushPG()

    if zSettingsFrame then zSettingsFrame._buildCtx = prevCtx end

    -- FlushRow always appends ZSF.ROW_GAP_Y after the last row; strip it so
    -- the container wraps tightly.
    local totalH = math.max(math.abs(yOff) - ZSF.ROW_GAP_Y, 0.001)
    pg.container:SetHeight(totalH)
end

-- ── Dynamic relayout ─────────────────────────────────────────────────────────

--- Re-flow all rows in a content frame based on each row's current visibility.
--- Called by ControlMixin:Refresh when a showWhen condition changes so that
--- hidden rows collapse and visible rows shift up to fill the gap.  Pack groups
--- are repacked first so reflowed widgets fill any gaps left by hidden members.
---@param parent Frame  The content frame whose _zsfLayout to traverse.
function zSF_RelayoutContentFrame(parent)
    local layout = parent and parent._zsfLayout
    if not layout then return end

    -- Repack any pack groups first so container heights are current.
    for _, item in ipairs(layout.items) do
        if item._pg then
            PackGroup_Layout(item._pg)
        end
    end

    local yOffset = layout.yStart
    for _, item in ipairs(layout.items) do
        local visible = not item.getVisible or item.getVisible()
        if visible then
            item.frame:ClearAllPoints()
            item.frame:SetPoint("TOPLEFT",  parent, "TOPLEFT",  0, yOffset)
            item.frame:SetPoint("TOPRIGHT", parent, "TOPRIGHT", 0, yOffset)
            item.frame:Show()
            yOffset = yOffset - item.frame:GetHeight() - ZSF.ROW_GAP_Y
        else
            item.frame:Hide()
        end
    end

    -- Re-stripe all visible rows (including pack-group sub-rows) so the
    -- odd/even pattern stays correct after any visibility changes.
    RepairStripes(parent)

    -- Resize section border frames to match the reflowed layout.
    PositionSectionBorders(layout, parent)

    local totalH = math.max(math.abs(yOffset) + ZSF.CONTENT_PAD_TOP, 1)
    parent:SetHeight(totalH)
    parent.zsfHeight = totalH
    -- Notify the scroll frame if this panel is currently on screen.
    if parent:IsShown() and parent._zsfCtx then
        parent._zsfCtx._setScrollContentHeight(totalH)
    end
end

-- ── Schema traversal ─────────────────────────────────────────────────────────

--- Walk a schema table and build widget frames for each entry.
--- Handles row packing, section headers, spacers, and data binding.
--- Partial-width entries are grouped into pack groups so that when any
--- member's showWhen changes the whole group reflows to fill the gap.
---@param schemaTable table        Array of zSF_SchemaEntry tables.
---@param parent      Frame        Parent frame (typically scrollContent).
---@param adapter     zSF_Adapter
---@param trackerID   number|nil   Tracker index or nil for global.
---@param primary     table        { r, g, b }
---@param secondary   table        { r, g, b }
---@return number totalHeight
function zSF_BuildFromSchema(schemaTable, parent, adapter, trackerID, primary, secondary)
    local _bctx = zSettingsFrame and zSettingsFrame._buildCtx
    local _c    = (_bctx and _bctx._colors) or {}
    local colors = {
        label         = _c.label         or ZSF.COLOR_LABEL,
        desc          = _c.desc          or ZSF.COLOR_DESC,
        disabled      = _c.disabled      or ZSF.COLOR_DISABLED,
        disabledAlpha = _c.disabledAlpha or ZSF.DISABLED_ALPHA,
        border        = _c.border        or ZSF.COLOR_BORDER,
        colDivider    = _c.colDivider    or ZSF.COLOR_COL_DIVIDER,
        rowStripeOdd           = _c.rowStripeOdd           or ZSF.COLOR_ROW_STRIPE_ODD,
        rowStripeEven          = _c.rowStripeEven          or ZSF.COLOR_ROW_STRIPE_EVEN,
        toggleIndicatorColor   = _c.toggleIndicatorColor,
    }
    WIDGET_ROW_GAP = ZSF.CONTENT_PAD_X

    local yOffset = -ZSF.CONTENT_PAD_TOP
    local curPG   = nil   -- current open pack group, or nil
    local items   = {}    -- ordered layout records for dynamic showWhen reflow

    -- Seal the current pack group (if any): run initial layout, add to items,
    -- advance yOffset.  Modifies yOffset as an upvalue.
    local function sealPackGroup()
        if not curPG then return end
        PackGroup_Layout(curPG)
        local pg = curPG
        table.insert(items, {
            frame      = pg.container,
            getVisible = function()
                for _, m in ipairs(pg.members) do
                    if not m.entry.showWhen or m.entry.showWhen(m.trackerID) then
                        return true
                    end
                end
                return false
            end,
            _pg = pg,
        })
        yOffset = yOffset - pg.container:GetHeight() - ZSF.ROW_GAP_Y
        -- When no member has _visibleWhen set (or all are evicted), nothing in
        -- ctx._widgets will trigger a relayout when a showWhen condition changes.
        -- Register a watcher that fires zSF_RelayoutContentFrame whenever the
        -- visible set differs from what was last built.  Handles both the
        -- all-hidden→some-visible and some-hidden→more-visible cases.
        if pg._ctx and pg._ctx._widgets then
            local watcher = { _pg = pg }
            function watcher:Refresh()
                local p = self._pg
                local vis = {}
                for i, m in ipairs(p.members) do
                    if not m.entry.showWhen or m.entry.showWhen(m.trackerID) then
                        vis[#vis + 1] = i
                    end
                end
                local sig = table.concat(vis, ",")
                if sig ~= (p._lastSig or "") then
                    zSF_RelayoutContentFrame(p.parent)
                end
            end
            table.insert(pg._ctx._widgets, watcher)
        end
        curPG = nil
    end

    for _, entry in ipairs(schemaTable) do
        local wType   = entry.widgetType
        local builder = BUILDERS[wType]
        if builder then
            local entryTrackerID = entry.trackerID or trackerID
            local widthKey = ResolveWidth(entry, DEFAULT_WIDTHS[wType] or "full")
            local fraction = WIDTH_FRACTIONS[widthKey] or 1.0

            -- Spacers, headers, and container widgets are structural barriers that
            -- seal the pack group.  All other widgets — even full-width ones — join
            -- the group so showWhen can collapse them alongside partial-width siblings.
            local isBarrier = (wType == "spacer" or wType == "header"
                               or wType == "group" or wType == "tab")
            if isBarrier then
                -- Structural barrier: seal any open pack group and place directly.
                sealPackGroup()

                local widget, contentFrame = builder(entry, parent, adapter, entryTrackerID,
                                                      primary, secondary, colors)
                -- Place full-width widget immediately.
                widget:SetPoint("TOPLEFT",  parent, "TOPLEFT",  0, yOffset)
                widget:SetPoint("TOPRIGHT", parent, "TOPRIGHT", 0, yOffset)
                -- Spacers contribute only their own height; skip the row gap so they stay flush.
                if wType == "spacer" then
                    local h = widget:GetHeight()
                    if h > 0.01 then
                        yOffset = yOffset - h
                    end
                else
                    yOffset = yOffset - widget:GetHeight() - ZSF.ROW_GAP_Y
                end
                -- Record for relayout.
                do
                    local swFn  = entry.showWhen
                    local swTID = entryTrackerID
                    table.insert(items, {
                        frame         = widget,
                        getVisible    = swFn and function() return swFn(swTID) end or nil,
                        _isHeader     = (wType == "header") or nil,
                        _breakSection = (wType == "spacer" and entry.breakSection == true) or nil,
                    })
                end
                if entry.showWhen then
                    widget._layoutFrame = parent
                end

                -- If the builder deferred control construction via _buildWidget
                -- (e.g. range, select), resolve it now.  Full-width widgets skip
                -- FlushRow so _buildWidget would otherwise never be called.
                if widget._buildWidget then
                    StampRowStripe(widget, parent, colors)
                    local PAD_X    = GetSidePad()
                    local contentW = zSF_GetContentWidth() - PAD_X * 2
                    local isBtn    = entry.widgetType == "button"
                    if not isBtn then
                        local lbl = zSF_Text(widget, "GameFontNormal", "LEFT")
                        lbl:SetText(entry.name or "")
                        local ZSF_COLOR_LABEL = colors.label or ZSF.COLOR_LABEL
                        lbl:SetTextColor(unpack(ZSF_COLOR_LABEL))
                        lbl:SetWidth(contentW)
                        lbl:SetHeight(WIDGET_LABEL_H)
                        lbl:SetPoint("TOPLEFT", widget, "TOPLEFT", PAD_X, -6)
                        if ShouldShowTooltip(entry) then
                            zSF_AddTooltip(lbl, entry.name or "", entry.desc, secondary)
                        end
                    end
                    local cell = CreateFrame("Frame", nil, widget)
                    cell:SetWidth(contentW)
                    if isBtn then
                        cell:SetHeight(widget:GetHeight())
                        cell:SetPoint("TOPLEFT", widget, "TOPLEFT", PAD_X, 0)
                    else
                        cell:SetPoint("TOPLEFT", widget, "TOPLEFT", PAD_X,
                            -(6 + WIDGET_LABEL_H + 2))
                    end
                    widget._buildWidget(cell, 1.0)
                    widget._buildWidget = nil
                end

                -- Recurse into children (for group/tab containers).
                if entry.children and contentFrame then
                    zSF_BuildFromSchema(entry.children, contentFrame, adapter,
                                        entryTrackerID, primary, secondary)
                end
            else
                -- All content widgets (any width) accumulate into the current pack group
                -- so showWhen can collapse full-width entries alongside partial-width ones.
                -- Builders are deferred to PackGroup_Layout so the visible
                -- set can reflow dynamically when showWhen conditions change.
                if not curPG then
                    local container = CreateFrame("Frame", nil, parent)
                    container:SetPoint("TOPLEFT",  parent, "TOPLEFT",  0, yOffset)
                    container:SetPoint("TOPRIGHT", parent, "TOPRIGHT", 0, yOffset)
                    container:SetHeight(0.001)
                    -- Capture the current build context and color state so
                    -- PackGroup_Layout can restore them when rebuilding at
                    -- runtime (after _buildCtx has been cleared by the panel).
                    local snapCtx = zSettingsFrame and zSettingsFrame._buildCtx
                    curPG = {
                        parent     = parent,
                        container  = container,
                        adapter    = adapter,
                        primary    = primary,
                        secondary  = secondary,
                        members    = {},
                        liveFrames = {},
                        liveRows   = {},
                        _lastSig   = nil,
                        _ctx       = snapCtx,
                        _eSnap     = colors,
                    }
                end
                table.insert(curPG.members, {
                    entry     = entry,
                    fraction  = fraction,
                    trackerID = entryTrackerID,
                })
            end
        end -- if builder
    end

    -- Seal any trailing pack group.
    sealPackGroup()

    -- Group items into sections (each section starts at a header item) and
    -- create a near-invisible 1px border frame for each.
    local sections = {}
    do
        local curSec = nil
        for i, item in ipairs(items) do
            if item._isHeader then
                local bf = CreateFrame("Frame", nil, parent)
                -- Must sit above row frames and their widget children (which WoW bumps to
                -- parent+1), otherwise the border textures are buried under the stripes.
                bf:SetFrameLevel(parent:GetFrameLevel() + 10)
                curSec = {
                    borderFrame   = bf,
                    startIdx      = i,
                    rowsStartIdx  = i + 1,
                    endIdx        = i,
                }
                local sc = ZSF.COLOR_SECTION_BORDER
                zSF_DrawBorder(curSec.borderFrame, sc[1], sc[2], sc[3], sc[4])
                table.insert(sections, curSec)
            elseif item._breakSection then
                -- spacer with breakSection=true closes the current section so
                -- neither the spacer row nor anything after it belongs to it.
                curSec = nil
            elseif curSec then
                curSec.endIdx = i
            end
        end
    end

    -- Attach layout metadata so zSF_RelayoutContentFrame can reflow this parent.
    parent._zsfLayout = { items = items, yStart = -ZSF.CONTENT_PAD_TOP, sections = sections, sectionInset = math.max(GetSidePad() - 8, 0) }

    -- Fix stripe colors so the global odd/even sequence is correct across both
    -- full-width rows and pack-group sub-rows.
    RepairStripes(parent)

    -- Position section border frames to match the initial layout.
    PositionSectionBorders(parent._zsfLayout, parent)

    return math.abs(yOffset) + ZSF.CONTENT_PAD_TOP
end
