local ADDON_NAME, ns = ...

ns = ns or {}

---------------------------------------------------------------------------
-- Design Tokens (Midnight Violet on charcoal)
---------------------------------------------------------------------------
-- The token system is layered into three semantic groups:
--   * surface.* -- deep-to-shallow charcoal canvas + cards
--   * accent.*  -- midnight violet for active/focus/primary accents
--   * text.*    -- foreground hierarchy (primary -> muted)
--   * state.*   -- danger / success / warning (sparingly)
-- Each token is a Lua array {r, g, b, a} so the legacy setColor() helpers
-- continue to work without touching any call-site.

local function rgba(r, g, b, a)
    return { r / 255, g / 255, b / 255, a or 1 }
end

local tokens = {
    surface = {
        canvas    = rgba(24, 26, 35, 1),    -- window background
        raised    = rgba(31, 33, 41, 1),    -- main content area
        card      = rgba(38, 40, 52, 1),    -- section cards
        cardHover = rgba(46, 48, 61, 1),
        inset     = rgba(19, 21, 30, 1),    -- slider track, dropdown closed, edit boxes
    },
    border = {
        subtle = rgba(53, 55, 70, 1),       -- 1px hairline default
        strong = rgba(68, 70, 87, 1),       -- focus, dropdown open
    },
    accent = {
        primary = rgba(124, 79, 224, 1),    -- #7C4FE0 -- active tab, slider fill, focus ring
        bright  = rgba(155, 106, 255, 1),   -- #9B6AFF -- hover/section title
        dim     = rgba(74, 46, 138, 1),     -- #4A2E8A -- pressed, secondary indicator
        glow    = rgba(124, 79, 224, 0.14), -- subtle tint behind active tab
    },
    text = {
        primary   = rgba(242, 241, 247, 1),
        secondary = rgba(183, 181, 195, 1),
        muted     = rgba(123, 121, 134, 1),
        disabled  = rgba(77, 75, 87, 1),
    },
    state = {
        danger  = rgba(228, 88, 88, 1),
        success = rgba(93, 210, 124, 1),
        warning = rgba(232, 177, 74, 1),
    },
}

ns.Tokens = tokens

---------------------------------------------------------------------------
-- Spacing Scale
---------------------------------------------------------------------------
-- Locked 4-based scale that every layout / setPoint offset draws from.
-- No more sprinkled magic numbers (-2/-8/-10) across Main.lua.

local spacing = {
    xxs = 2,
    xs  = 4,
    sm  = 8,
    md  = 12,
    lg  = 16,
    xl  = 24,
    xxl = 32,
}
ns.Spacing = spacing

---------------------------------------------------------------------------
-- Icon Registry
---------------------------------------------------------------------------
-- Monochrome 16x16 TGA glyphs authored under Assets/. They render white
-- and get tinted at runtime via SetVertexColor so we can reuse the same
-- texture asset for muted/active/hover states. Using semantic keys lets
-- call-sites stay readable: ns.Icons.tab.general instead of a path string.

local ICON_BASE = "Interface\\AddOns\\" .. ADDON_NAME .. "\\Assets\\"

local icons = {
    -- Tab glyphs are the new richer tab icons authored at higher pixel
    -- density (2322B vs the 1042B legacy mono glyphs). Each file is named
    -- after the tab it belongs to so the registry stays self-documenting.
    tab = {
        general    = ICON_BASE .. "general.tga",
        bar        = ICON_BASE .. "resource_bar.tga",
        text       = ICON_BASE .. "resource_text.tga",
        secondary  = ICON_BASE .. "secondary_resource.tga",
        visibility = ICON_BASE .. "visibility.tga",
    },
    play  = ICON_BASE .. "icon_play.tga",
    pause = ICON_BASE .. "icon_pause.tga",
    lock  = ICON_BASE .. "icon_lock.tga",
    info  = ICON_BASE .. "icon_info.tga",
    -- "eye" alias reuses the legacy mono visibility glyph for the preview
    -- hide/show toggle button. We keep the small monochrome glyph here so
    -- the toolbar in the preview's top-right stays visually quiet -- the
    -- richer Visibility tab icon is reserved for the tab strip.
    eye   = ICON_BASE .. "icon_visibility.tga",
}
ns.Icons = icons

---------------------------------------------------------------------------
-- Layout Constants
---------------------------------------------------------------------------

local CONTROL_ROW_GAP       = 10             -- between controls in same card
-- Flat sections have no card border to provide visual separation, so
-- the gap between them needs to be larger than it was for framed cards
-- (was 12). 22px reads as a deliberate breath rather than an accident.
local PANEL_GROUP_GAP       = 22             -- between sections in a column
local PANEL_GRID_GAP        = spacing.md     -- 12 -- between left/right columns
local TWO_COLUMN_MIN_WIDTH  = 840

ns.CONTROL_ROW_GAP      = CONTROL_ROW_GAP
ns.PANEL_GROUP_GAP       = PANEL_GROUP_GAP

---------------------------------------------------------------------------
-- Widget Counters
---------------------------------------------------------------------------

local sliderCounter   = 0
local checkboxCounter = 0

---------------------------------------------------------------------------
-- Shared UI State  (populated by Main.lua, referenced by factories)
---------------------------------------------------------------------------

ns.ui = ns.ui or {}
local ui = ns.ui

---------------------------------------------------------------------------
-- Primitive Helpers
---------------------------------------------------------------------------

local function roundToInt(value)
    if value >= 0 then
        return math.floor(value + 0.5)
    end
    return math.ceil(value - 0.5)
end
ns.RoundToInt = roundToInt

local function setColor(texture, rgba)
    texture:SetColorTexture(rgba[1], rgba[2], rgba[3], rgba[4] or 1)
end
ns.SetColor = setColor

local function styleLabel(fs, rgba)
    fs:SetTextColor(rgba[1], rgba[2], rgba[3], rgba[4] or 1)
    fs:SetShadowColor(0, 0, 0, 0)
end
ns.StyleLabel = styleLabel

---------------------------------------------------------------------------
-- Typography
---------------------------------------------------------------------------
-- Three Figtree weights (Bold / Medium / Regular) mapped to a small set of
-- semantic styles so call-sites stop thinking in "what font + size" and
-- start thinking in "what role does this text play?". Matches material /
-- modern UI conventions:
--
--   Bold     -> identity + emphasis (window title only, sparingly).
--   Medium  -> hierarchy + actions (headings, tab labels, dropdown
--               selected text, button labels). Anything that frames or
--               commands.
--   Regular -> body + dense content (control labels, descriptions, help
--               text, dropdown rows, tooltips, captions). The default.
--
-- Sizes follow a 4-point modular scale snapped to integer pixels (so glyph
-- hinting stays crisp at 100% UI scale). Each style binds a family + size +
-- color token so applying it is a single call.
--
-- The `family` field stores the LSM media name; resolveFont() converts it
-- to an actual TTF path on demand. Profiles can override the design font
-- (cfg.font) for specific bar text, but the panel itself is fixed to
-- Figtree -- design system, not user content.

local typography = {
    family = {
        bold    = "Figtree Bold",
        medium  = "Figtree Medium",
        regular = "Figtree Regular",
    },
    -- 4-step modular scale. xs/sm read as captions, base is body default,
    -- md is the headline/control-label step, xl is the brand title.
    size = {
        xs   = 10,  -- micro caption (hex tooltip, finest meta)
        sm   = 11,  -- subtitle, help text under cards
        base = 12,  -- DEFAULT body / control label / dropdown row
        md   = 13,  -- card title, tab label, primary button
        xl   = 17,  -- window wordmark
    },
}

-- Style presets. `family`, `size`, and `color` are required; `flags` is
-- an optional WoW SetFont flags string (rarely needed -- Figtree weights
-- already encode boldness, so OUTLINE/SHADOW are the only useful flags).
typography.style = {
    -- Window chrome
    title       = { family = "bold",    size = typography.size.xl,   color = "primary"   },
    subtitle    = { family = "regular", size = typography.size.sm,   color = "muted"     },
    -- Card / section
    cardTitle   = { family = "medium",  size = typography.size.md,   color = "accentBright" },
    sectionTitle = { family = "bold",    size = typography.size.md,   color = "accentBright" },
    sectionIntro = { family = "regular", size = typography.size.base, color = "secondary" },
    helpText    = { family = "regular", size = typography.size.sm,   color = "muted"     },
    -- Tabs
    tabLabel    = { family = "medium",  size = typography.size.md,   color = "muted"     },
    -- Controls
    controlLabel = { family = "regular", size = typography.size.base, color = "secondary" },
    controlValue = { family = "medium",  size = typography.size.base, color = "primary"   },
    -- Buttons
    buttonLabel = { family = "medium",  size = typography.size.base, color = "secondary" },
    buttonLabelLarge = { family = "medium",  size = typography.size.md,   color = "secondary" },
    -- Misc
    caption     = { family = "regular", size = typography.size.xs,   color = "muted"     },
    statusPill  = { family = "medium",  size = typography.size.sm,   color = "secondary" },
    pillButton  = { family = "medium",  size = typography.size.base, color = "secondary" },
}

ns.Typography = typography

local function colorTokenFor(name)
    if name == "primary" then return tokens.text.primary end
    if name == "secondary" then return tokens.text.secondary end
    if name == "muted" then return tokens.text.muted end
    if name == "disabled" then return tokens.text.disabled end
    if name == "accent" then return tokens.accent.primary end
    if name == "accentBright" then return tokens.accent.bright end
    if name == "danger" then return tokens.state.danger end
    if name == "success" then return tokens.state.success end
    if name == "warning" then return tokens.state.warning end
    return tokens.text.primary
end

ns.ResolveTextColorToken = colorTokenFor

---------------------------------------------------------------------------
-- LibSharedMedia Helpers
---------------------------------------------------------------------------

local function getLSM()
    return LibStub and LibStub("LibSharedMedia-3.0", true)
end
ns.GetLSM = getLSM

local function listMediaNames(mediaType, fallbackNames)
    local lsm = getLSM()
    if not lsm then
        return fallbackNames
    end
    local listed = lsm:List(mediaType) or {}
    if #listed == 0 then
        return fallbackNames
    end
    table.sort(listed)
    return listed
end
ns.ListMediaNames = listMediaNames

local function resolveStatusBarTexture(name)
    local lsm = getLSM()
    if lsm then
        local path = lsm:Fetch("statusbar", name, true)
        if path then
            return path
        end
        if name == "Smooth" then
            path = lsm:Fetch("statusbar", "RA Smooth", true)
            if path then
                return path
            end
        end
    end
    return "Interface\\TargetingFrame\\UI-StatusBar"
end
ns.ResolveStatusBarTexture = resolveStatusBarTexture

local function resolveFont(name)
    local lsm = getLSM()
    if lsm then
        local path = lsm:Fetch("font", name, true)
        if path then
            return path
        end
    end
    return STANDARD_TEXT_FONT
end
ns.ResolveFont = resolveFont

-- WoW errors hard if SetFont is given a path that the client cannot resolve
-- (e.g. user deleted a font file but LSM still has the metadata). Wrap so
-- we degrade gracefully to STANDARD_TEXT_FONT instead of crashing the UI.
local function safeSetFont(fontString, path, size, flags)
    if not fontString or not fontString.SetFont then return end
    local ok = pcall(fontString.SetFont, fontString, path, size, flags or "")
    if not ok then
        pcall(fontString.SetFont, fontString, STANDARD_TEXT_FONT, size, flags or "")
    end
end
ns.SafeSetFont = safeSetFont

-- Apply a named typography style to a FontString. Pass `colorOverride`
-- (rgba table) to keep the family/size/flags but tint the text -- handy
-- for hover/active state swaps so call-sites don't have to remember the
-- preset's family/size.
local function applyTypography(fontString, styleName, colorOverride)
    if not fontString then return fontString end
    local style = typography.style[styleName]
    if not style then
        -- Defensive fallback: missing preset names should still render
        -- something readable rather than throwing during a refresh pass.
        style = typography.style.controlLabel
    end
    local family = typography.family[style.family] or typography.family.regular
    safeSetFont(fontString, resolveFont(family), style.size, style.flags or "")
    local rgba = colorOverride or colorTokenFor(style.color)
    if rgba then
        styleLabel(fontString, rgba)
    end
    fontString._typographyStyle = styleName
    return fontString
end
ns.ApplyTypography = applyTypography

-- Variant: keep current text color, only update family/size. Useful for
-- editboxes where SetFontObject already supplied a color and we just want
-- to swap the typeface.
local function applyTypographyFontOnly(fontString, styleName)
    if not fontString then return fontString end
    local style = typography.style[styleName] or typography.style.controlLabel
    local family = typography.family[style.family] or typography.family.regular
    safeSetFont(fontString, resolveFont(family), style.size, style.flags or "")
    return fontString
end
ns.ApplyTypographyFontOnly = applyTypographyFontOnly

local function normalizeTexturePath(path)
    if type(path) == "string" and path ~= "" then
        return path
    end
    return "Interface\\Buttons\\WHITE8x8"
end
ns.NormalizeTexturePath = normalizeTexturePath

---------------------------------------------------------------------------
-- Profile Access Helpers  (thin wrappers -- Main.lua sets ns.db)
---------------------------------------------------------------------------

local function profile()
    if not ns.db or not ns.db.profile then
        return {}
    end
    return ns.db.profile
end
ns.Profile = profile

local function canEditConfig()
    if InCombatLockdown() then
        ns:Print("Configuration changes are blocked during combat.")
        return false
    end
    return true
end
ns.CanEditConfig = canEditConfig

local function updateProfileSetting(key, value)
    if not canEditConfig() then
        return false
    end
    profile()[key] = value
    return true
end
ns.UpdateProfileSetting = updateProfileSetting

---------------------------------------------------------------------------
-- Callback Stubs  (Main.lua wires the real implementations)
---------------------------------------------------------------------------

ns.ApplyPreviewLayout      = ns.ApplyPreviewLayout      or function() end
ns.ApplyRuntimeSettings    = ns.ApplyRuntimeSettings     or function() end
ns.SyncOffsetSliders       = ns.SyncOffsetSliders        or function() end

---------------------------------------------------------------------------
-- createPanel
---------------------------------------------------------------------------

local function createPanel(parent, layerColor, inset)
    local frame = CreateFrame("Frame", nil, parent)
    frame.bg = frame:CreateTexture(nil, "BACKGROUND")
    frame.bg:SetAllPoints()
    setColor(frame.bg, layerColor)

    frame.border = CreateFrame("Frame", nil, frame, "BackdropTemplate")
    frame.border:SetAllPoints()
    frame.border:SetBackdrop({
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        edgeSize = inset or 1,
    })
    frame.border:SetBackdropBorderColor(tokens.border.subtle[1], tokens.border.subtle[2], tokens.border.subtle[3], 1)

    return frame
end
ns.CreatePanel = createPanel

---------------------------------------------------------------------------
-- createCard  (premium section primitive)
---------------------------------------------------------------------------
-- Replaces the old "sunkenGroup" treatment. Visual seams per card drop from
-- four (border + header + separator + content gap) to two (border + header).
--
--   * Background:    surface.card
--   * Border:        1px border.subtle hairline
--   * Title:         13px Figtree Medium, accent.bright (cardTitle preset)
--   * Optional icon: 14x14 leading glyph, tinted accent.bright
--   * Inner padding: 16/16/14/16 (top/right/bottom/left)
--   * Top highlight: 1px rgba(255,255,255,0.04) hairline for a touch of depth
--
-- The function keeps the same `group.content` API the rest of Main.lua
-- expects, so existing placeControl/seedStack/finishGroup helpers continue
-- to work without a single change.

local CARD_PADDING_X     = 16
local CARD_PADDING_TOP   = 14
local CARD_PADDING_BOT   = 14
local CARD_HEADER_HEIGHT = 16
local CARD_HEADER_GAP    = 12  -- between header text and first control
local CARD_TOP_CHROME    = CARD_PADDING_TOP + CARD_HEADER_HEIGHT + CARD_HEADER_GAP

ns.CardTopChrome = CARD_TOP_CHROME

-- The Canvas + Inspector rebuild made cards *visually* look like form
-- boxes -- two seams (border + header) per group, big paddings, and a
-- separate header bar -- which felt heavy in a single-column inspector
-- panel. The new "section" rendering keeps the same `content` API but
-- drops the full card/backdrop and renders the title as a compact
-- bordered header chip. That cuts visual weight while keeping each
-- section easy to scan. createCard now renders sections by
-- default; opt back into the legacy framed look by passing
-- `opts.framed = true`.
local SECTION_PADDING_X     = 0
local SECTION_HEADER_HEIGHT = 14
local SECTION_HEADER_GAP    = 18
local SECTION_TOP_PAD       = 6
ns.SectionTopChrome = SECTION_TOP_PAD + SECTION_HEADER_HEIGHT + SECTION_HEADER_GAP

local function createCard(parent, title, opts)
    opts = opts or {}
    local framed = opts.framed == true
    if framed then
        local bgColor = tokens.surface.card
        local bgAlpha = 1
        local borderColor = tokens.border.subtle
        local borderAlpha = 1
        local titleColor = tokens.accent.bright
        local highlightAlpha = 0.035

        local card = CreateFrame("Frame", nil, parent, "BackdropTemplate")
        card:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8x8",
            edgeFile = "Interface\\Buttons\\WHITE8x8",
            edgeSize = 1,
        })
        card:SetBackdropColor(bgColor[1], bgColor[2], bgColor[3], bgAlpha)
        card:SetBackdropBorderColor(borderColor[1], borderColor[2], borderColor[3], borderAlpha)

        local topHighlight = card:CreateTexture(nil, "BORDER")
        topHighlight:SetPoint("TOPLEFT", card, "TOPLEFT", 1, -1)
        topHighlight:SetPoint("TOPRIGHT", card, "TOPRIGHT", -1, -1)
        topHighlight:SetHeight(1)
        topHighlight:SetColorTexture(1, 1, 1, highlightAlpha)
        card.topHighlight = topHighlight

        local headerLeft = CARD_PADDING_X
        if opts.icon then
            local icon = card:CreateTexture(nil, "OVERLAY")
            icon:SetSize(14, 14)
            icon:SetPoint("TOPLEFT", card, "TOPLEFT", CARD_PADDING_X, -CARD_PADDING_TOP - 1)
            icon:SetTexture(opts.icon)
            icon:SetVertexColor(titleColor[1], titleColor[2], titleColor[3], titleColor[4] or 1)
            card.icon = icon
            headerLeft = CARD_PADDING_X + 14 + 8
        end

        local header = card:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        header:SetPoint("TOPLEFT", card, "TOPLEFT", headerLeft, -CARD_PADDING_TOP)
        header:SetText(title or "")
        applyTypography(header, "cardTitle")
        card.header = header

        local content = CreateFrame("Frame", nil, card)
        content:SetPoint("TOPLEFT", card, "TOPLEFT", CARD_PADDING_X, -(CARD_PADDING_TOP + CARD_HEADER_HEIGHT + CARD_HEADER_GAP))
        content:SetPoint("TOPRIGHT", card, "TOPRIGHT", -CARD_PADDING_X, -(CARD_PADDING_TOP + CARD_HEADER_HEIGHT + CARD_HEADER_GAP))
        content:SetHeight(1)

        card.content = content
        card._isFramed = true
        return card
    end

    -- Flat section rendering (default).
    local card = CreateFrame("Frame", nil, parent)
    card._isFramed = false
    card._title = title or ""

    local titleColor = tokens.accent.bright
    local headerBg = card:CreateTexture(nil, "BACKGROUND")
    headerBg:SetPoint("TOPLEFT", card, "TOPLEFT", 0, -1)
    headerBg:SetHeight(25)
    headerBg:SetColorTexture(tokens.accent.glow[1], tokens.accent.glow[2], tokens.accent.glow[3], 0.12)
    card.headerBg = headerBg

    local headerEdge = card:CreateTexture(nil, "BORDER")
    headerEdge:SetPoint("BOTTOMLEFT", headerBg, "BOTTOMLEFT", 0, 0)
    headerEdge:SetPoint("BOTTOMRIGHT", headerBg, "BOTTOMRIGHT", 0, 0)
    headerEdge:SetHeight(1)
    headerEdge:SetColorTexture(tokens.border.subtle[1], tokens.border.subtle[2], tokens.border.subtle[3], 0.55)
    card.headerEdge = headerEdge

    local headerTopEdge = card:CreateTexture(nil, "BORDER")
    headerTopEdge:SetPoint("TOPLEFT", headerBg, "TOPLEFT", 0, 0)
    headerTopEdge:SetPoint("TOPRIGHT", headerBg, "TOPRIGHT", 0, 0)
    headerTopEdge:SetHeight(1)
    headerTopEdge:SetColorTexture(tokens.border.subtle[1], tokens.border.subtle[2], tokens.border.subtle[3], 0.35)
    card.headerTopEdge = headerTopEdge

    local headerRightEdge = card:CreateTexture(nil, "BORDER")
    headerRightEdge:SetPoint("TOPRIGHT", headerBg, "TOPRIGHT", 0, -1)
    headerRightEdge:SetPoint("BOTTOMRIGHT", headerBg, "BOTTOMRIGHT", 0, 1)
    headerRightEdge:SetWidth(1)
    headerRightEdge:SetColorTexture(tokens.border.subtle[1], tokens.border.subtle[2], tokens.border.subtle[3], 0.45)
    card.headerRightEdge = headerRightEdge

    local headerAccent = card:CreateTexture(nil, "BORDER")
    headerAccent:SetPoint("TOPLEFT", headerBg, "TOPLEFT", 0, -4)
    headerAccent:SetPoint("BOTTOMLEFT", headerBg, "BOTTOMLEFT", 0, 4)
    headerAccent:SetWidth(2)
    headerAccent:SetColorTexture(tokens.accent.dim[1], tokens.accent.dim[2], tokens.accent.dim[3], 0.9)
    card.headerAccent = headerAccent

    -- Chevron toggle (left of icon/title). 12x12 invisible button with a
    -- "►" / "▼" font glyph. Click toggles content visibility, persists
    -- collapsed state in db.profile.uiCollapsedSections, and triggers a
    -- panel-grid relayout so siblings re-pack into the freed space.
    local CHEVRON_SIZE = 14            -- was 12
    local CHEVRON_GAP  = 8             -- was 6
    local chevron = CreateFrame("Button", nil, card)
    chevron:SetSize(CHEVRON_SIZE + 4, CHEVRON_SIZE + 4)
    -- Header text sits at y = -SECTION_TOP_PAD with SECTION_HEADER_HEIGHT
    -- ~14, so its visual center y ~= -(SECTION_TOP_PAD + 7). The chevron
    -- button is 18px tall and the texture centers in it; setting button
    -- TOPLEFT y = -4 puts the button center near the header center so
    -- the glyph aligns horizontally with the title.
    chevron:SetPoint("TOPLEFT", card, "TOPLEFT", SECTION_PADDING_X + 4, -4)
    chevron:RegisterForClicks("AnyUp")
    -- Transparent hit-target texture so OnClick reliably fires on the
    -- entire 18x18 area, not just where the small chevron glyph sits.
    local chevHit = chevron:CreateTexture(nil, "BACKGROUND")
    chevHit:SetAllPoints(chevron)
    chevHit:SetColorTexture(0, 0, 0, 0)
    -- chevron_right.tga points right at 0 rad. Rotate -pi/2 (clockwise
    -- 90deg) so it points DOWN when the section is expanded; reset to
    -- 0 (pointing right) when collapsed. Matches the dropdown arrow at
    -- createPremiumDropDown which uses the same asset.
    local chevTex = chevron:CreateTexture(nil, "OVERLAY")
    chevTex:SetSize(11, 11)            -- was 10
    chevTex:SetPoint("CENTER", chevron, "CENTER", 0, 0)
    chevTex:SetTexture(ICON_BASE .. "chevron_right.tga")
    chevTex:SetVertexColor(tokens.accent.bright[1], tokens.accent.bright[2], tokens.accent.bright[3], 0.85)
    chevTex:SetRotation(-math.pi / 2)
    card.chevron = chevron
    card.chevronTex = chevTex

    local headerLeft = SECTION_PADDING_X + CHEVRON_SIZE + CHEVRON_GAP + 6
    if opts.icon then
        local icon = card:CreateTexture(nil, "OVERLAY")
        icon:SetSize(12, 12)
        icon:SetPoint("TOPLEFT", card, "TOPLEFT", headerLeft, -SECTION_TOP_PAD)
        icon:SetTexture(opts.icon)
        icon:SetVertexColor(titleColor[1], titleColor[2], titleColor[3], titleColor[4] or 1)
        card.icon = icon
        headerLeft = headerLeft + 12 + 6
    end

    local header = card:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    header:SetPoint("TOPLEFT", card, "TOPLEFT", headerLeft, -SECTION_TOP_PAD)
    header:SetText(string.upper(title or ""))
    applyTypography(header, "sectionTitle")
    card.header = header
    local headerWidth = (header.GetStringWidth and header:GetStringWidth()) or 0
    if headerWidth <= 0 then
        headerWidth = string.len(title or "") * 7
    end
    headerBg:SetWidth(math.max(132, headerLeft + headerWidth + 16))

    local content = CreateFrame("Frame", nil, card)
    content:SetPoint("TOPLEFT", card, "TOPLEFT", SECTION_PADDING_X, -(SECTION_TOP_PAD + SECTION_HEADER_HEIGHT + SECTION_HEADER_GAP))
    content:SetPoint("TOPRIGHT", card, "TOPRIGHT", -SECTION_PADDING_X, -(SECTION_TOP_PAD + SECTION_HEADER_HEIGHT + SECTION_HEADER_GAP))
    content:SetHeight(1)

    card.content = content

    -- Header is also a click target over the compact chip/title area, so
    -- the user can collapse without hunting for the tiny chevron glyph.
    -- Sit below the chevron in frame level so the chevron's own OnClick
    -- still fires when clicked directly.
    local headerHit = CreateFrame("Button", nil, card)
    headerHit:SetFrameLevel(card:GetFrameLevel())
    headerHit:SetPoint("TOPLEFT", headerBg, "TOPLEFT", 0, 0)
    headerHit:SetPoint("BOTTOMRIGHT", headerBg, "BOTTOMRIGHT", 0, 0)
    headerHit:RegisterForClicks("AnyUp")
    -- Transparent hit texture so the entire header strip is clickable.
    local headerHitTex = headerHit:CreateTexture(nil, "BACKGROUND")
    headerHitTex:SetAllPoints(headerHit)
    headerHitTex:SetColorTexture(0, 0, 0, 0)
    card.headerHit = headerHit
    chevron:SetFrameLevel(headerHit:GetFrameLevel() + 1)

    -- The collapsed-state map lives on the profile; key off the section
    -- title since that's what users see in the UI. Title collisions
    -- across tabs are unlikely (and would just share collapse state,
    -- which is fine).
    local function getCollapseStore()
        if not (ns and ns.db and ns.db.profile) then return nil end
        ns.db.profile.uiCollapsedSections = ns.db.profile.uiCollapsedSections or {}
        return ns.db.profile.uiCollapsedSections
    end

    function card:IsCollapsed()
        local store = getCollapseStore()
        if not store then return false end
        return store[self._title] == true
    end

    function card:ApplyCollapsedState()
        local collapsed = self:IsCollapsed()
        self._collapsed = collapsed
        if self.content then
            self.content:SetShown(not collapsed)
        end
        if self.chevronTex then
            -- 0 rad = points right (collapsed); -pi/2 = points down
            -- (expanded). chevron_right.tga's native orientation is right.
            self.chevronTex:SetRotation(collapsed and 0 or -math.pi / 2)
        end
        if collapsed then
            -- Compact "header only" height so collapsed sections take
            -- minimal vertical space but still show the title row.
            local collapsedHeight = SECTION_TOP_PAD + SECTION_HEADER_HEIGHT + 6
            self:SetHeight(collapsedHeight)
            self._computedHeight = collapsedHeight
        else
            -- Restore the expanded height that finishGroup stamped. If
            -- finishGroup hasn't run yet (very first OnShow before any
            -- controls are placed), leave the current height alone --
            -- the upcoming finishGroup pass will stamp the real value
            -- and a follow-up RefreshPanelGridLayouts will repaint.
            local expanded = self._expandedHeight
            if expanded then
                self:SetHeight(expanded)
                self._computedHeight = expanded
            end
        end
    end

    function card:Toggle()
        local store = getCollapseStore()
        if not store then return end
        local nowCollapsed = not (store[self._title] == true)
        store[self._title] = nowCollapsed or nil
        self:ApplyCollapsedState()
        if ns.RefreshPanelGridLayouts then
            ns.RefreshPanelGridLayouts()
        end
    end

    local function onHeaderClick()
        card:Toggle()
    end
    chevron:SetScript("OnClick", onHeaderClick)
    headerHit:SetScript("OnClick", onHeaderClick)

    -- Hover affordance on the header strip.
    local function setHeaderHover(isHovered)
        if isHovered then
            if card.headerBg then
                card.headerBg:SetColorTexture(tokens.accent.glow[1], tokens.accent.glow[2], tokens.accent.glow[3], 0.22)
            end
            if card.headerAccent then
                card.headerAccent:SetColorTexture(tokens.accent.primary[1], tokens.accent.primary[2], tokens.accent.primary[3], 1)
            end
            if card.headerEdge then
                card.headerEdge:SetColorTexture(tokens.accent.dim[1], tokens.accent.dim[2], tokens.accent.dim[3], 0.9)
            end
            if card.headerTopEdge then
                card.headerTopEdge:SetColorTexture(tokens.accent.dim[1], tokens.accent.dim[2], tokens.accent.dim[3], 0.55)
            end
            if card.headerRightEdge then
                card.headerRightEdge:SetColorTexture(tokens.accent.dim[1], tokens.accent.dim[2], tokens.accent.dim[3], 0.65)
            end
            header:SetTextColor(tokens.accent.primary[1], tokens.accent.primary[2], tokens.accent.primary[3], 1)
            chevTex:SetVertexColor(tokens.accent.primary[1], tokens.accent.primary[2], tokens.accent.primary[3], 1)
        else
            if card.headerBg then
                card.headerBg:SetColorTexture(tokens.accent.glow[1], tokens.accent.glow[2], tokens.accent.glow[3], 0.12)
            end
            if card.headerAccent then
                card.headerAccent:SetColorTexture(tokens.accent.dim[1], tokens.accent.dim[2], tokens.accent.dim[3], 0.9)
            end
            if card.headerEdge then
                card.headerEdge:SetColorTexture(tokens.border.subtle[1], tokens.border.subtle[2], tokens.border.subtle[3], 0.55)
            end
            if card.headerTopEdge then
                card.headerTopEdge:SetColorTexture(tokens.border.subtle[1], tokens.border.subtle[2], tokens.border.subtle[3], 0.35)
            end
            if card.headerRightEdge then
                card.headerRightEdge:SetColorTexture(tokens.border.subtle[1], tokens.border.subtle[2], tokens.border.subtle[3], 0.45)
            end
            applyTypography(header, "sectionTitle")
            chevTex:SetVertexColor(tokens.accent.bright[1], tokens.accent.bright[2], tokens.accent.bright[3], 0.85)
        end
    end
    headerHit:SetScript("OnEnter", function() setHeaderHover(true) end)
    headerHit:SetScript("OnLeave", function() setHeaderHover(false) end)
    chevron:SetScript("OnEnter", function() setHeaderHover(true) end)
    chevron:SetScript("OnLeave", function() setHeaderHover(false) end)

    -- Defer initial collapse-state application until after the section
    -- has had its content placed and finishGroup has stamped a height.
    -- Section content lives in card.content; placeControl/seedStack run
    -- on parent frames that have already been built when createCard
    -- returns, so we apply state via a one-shot OnShow.
    card:HookScript("OnShow", function(self)
        if self._collapseInitialized then return end
        self._collapseInitialized = true
        self:ApplyCollapsedState()
    end)

    return card
end
ns.CreateCard = createCard

-- Explicit alias for code that wants to declare its intent as "flat
-- section" rather than "card". Identical behavior to the default
-- createCard, just self-documenting at call-sites.
ns.CreateSection = createCard

---------------------------------------------------------------------------
-- createPremiumDropDown
---------------------------------------------------------------------------

local function createPremiumDropDown(parent, width, itemsProvider, currentValueGetter, onSelect, mediaType)
    local dropdown = CreateFrame("Button", nil, parent, "BackdropTemplate")
    dropdown:SetSize(width, 28)
    dropdown:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        edgeSize = 1,
    })
    dropdown:SetBackdropColor(tokens.surface.inset[1], tokens.surface.inset[2], tokens.surface.inset[3], 1)
    dropdown:SetBackdropBorderColor(tokens.border.subtle[1], tokens.border.subtle[2], tokens.border.subtle[3], 1)

    local text = dropdown:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    text:SetPoint("LEFT", dropdown, "LEFT", 8, 0)
    text:SetPoint("RIGHT", dropdown, "RIGHT", -24, 0)
    text:SetJustifyH("LEFT")
    text:SetWordWrap(false)
    if text.SetNonSpaceWrap then
        text:SetNonSpaceWrap(false)
    end
    if text.SetMaxLines then
        text:SetMaxLines(1)
    end
    -- Dropdown selected value -- medium weight reads as "the active
    -- choice" without competing with card titles.
    applyTypography(text, "controlValue")
    dropdown.text = text

    local selectedPreview = dropdown:CreateTexture(nil, "ARTWORK")
    selectedPreview:SetSize(72, 12)
    selectedPreview:SetPoint("LEFT", dropdown, "LEFT", 8, 0)
    selectedPreview:Hide()
    dropdown.selectedPreview = selectedPreview

    local arrow = dropdown:CreateTexture(nil, "OVERLAY")
    arrow:SetPoint("RIGHT", dropdown, "RIGHT", -6, 0)
    arrow:SetSize(12, 12)
    -- chevron_right.tga rotated clockwise so it points down to indicate
    -- "click to expand". SetVertexColor lets refreshArrowState tint it the
    -- same way it used to tint a flat WHITE8x8 swatch.
    arrow:SetTexture(ICON_BASE .. "chevron_right.tga")
    arrow:SetRotation(-math.pi / 2)
    arrow:SetVertexColor(tokens.text.muted[1], tokens.text.muted[2], tokens.text.muted[3], 1)
    dropdown.arrow = arrow

    local menu = CreateFrame("Frame", nil, UIParent, "BackdropTemplate")
    menu:SetFrameStrata("TOOLTIP")
    menu:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        edgeSize = 1,
    })
    menu:SetBackdropColor(tokens.surface.card[1], tokens.surface.card[2], tokens.surface.card[3], 1)
    menu:SetBackdropBorderColor(tokens.border.subtle[1], tokens.border.subtle[2], tokens.border.subtle[3], 1)
    menu:Hide()
    dropdown.menu = menu

    local enableSearch = mediaType == "font" or mediaType == "statusbar"
    local rowHeight = mediaType == "statusbar" and 24 or 20
    local maxHeight = 240
    local filterText = ""

    local searchBox
    local searchPlaceholder
    if enableSearch then
        searchBox = CreateFrame("EditBox", nil, menu, "BackdropTemplate")
        searchBox:SetPoint("TOPLEFT", menu, "TOPLEFT", 4, -4)
        searchBox:SetPoint("TOPRIGHT", menu, "TOPRIGHT", -4, -4)
        searchBox:SetHeight(22)
        searchBox:SetAutoFocus(false)
        searchBox:SetFontObject(GameFontHighlightSmall)
        -- EditBoxes inherit color from FontObject, so we only need to
        -- swap the typeface here -- color stays whatever the FontObject
        -- declared (a bright highlight).
        applyTypographyFontOnly(searchBox, "controlLabel")
        searchBox:SetTextInsets(24, 8, 0, 0)
        searchBox:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8x8",
            edgeFile = "Interface\\Buttons\\WHITE8x8",
            edgeSize = 1,
        })
        searchBox:SetBackdropColor(tokens.surface.canvas[1], tokens.surface.canvas[2], tokens.surface.canvas[3], 1)
        searchBox:SetBackdropBorderColor(tokens.border.subtle[1], tokens.border.subtle[2], tokens.border.subtle[3], 1)

        local searchIcon = searchBox:CreateTexture(nil, "OVERLAY")
        searchIcon:SetPoint("LEFT", searchBox, "LEFT", 8, 0)
        searchIcon:SetSize(10, 10)
        searchIcon:SetTexture("Interface\\COMMON\\UI-Searchbox-Icon")

        searchPlaceholder = searchBox:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        searchPlaceholder:SetPoint("LEFT", searchBox, "LEFT", 24, 0)
        applyTypography(searchPlaceholder, "controlLabel", tokens.text.muted)
        if mediaType == "font" then
            searchPlaceholder:SetText("Search fonts...")
        else
            searchPlaceholder:SetText("Search textures...")
        end
    end

    local scroll = CreateFrame("ScrollFrame", nil, menu, "UIPanelScrollFrameTemplate")
    if enableSearch and searchBox then
        scroll:SetPoint("TOPLEFT", searchBox, "BOTTOMLEFT", 0, -4)
        scroll:SetPoint("TOPRIGHT", searchBox, "BOTTOMRIGHT", 0, -4)
    else
        scroll:SetPoint("TOPLEFT", menu, "TOPLEFT", 4, -4)
        scroll:SetPoint("TOPRIGHT", menu, "TOPRIGHT", -4, -4)
    end
    scroll:SetPoint("BOTTOMRIGHT", menu, "BOTTOMRIGHT", -24, 4)
    menu.scroll = scroll

    local menuScrollBar = scroll.ScrollBar or _G[scroll:GetName() and (scroll:GetName() .. "ScrollBar")]
    if menuScrollBar then
        if menuScrollBar.Top then menuScrollBar.Top:Hide() end
        if menuScrollBar.Bottom then menuScrollBar.Bottom:Hide() end
        if menuScrollBar.Middle then menuScrollBar.Middle:Hide() end
        if menuScrollBar.ScrollUpButton then
            menuScrollBar.ScrollUpButton:Hide()
            menuScrollBar.ScrollUpButton:SetAlpha(0)
            menuScrollBar.ScrollUpButton:SetSize(1, 1)
        end
        if menuScrollBar.ScrollDownButton then
            menuScrollBar.ScrollDownButton:Hide()
            menuScrollBar.ScrollDownButton:SetAlpha(0)
            menuScrollBar.ScrollDownButton:SetSize(1, 1)
        end
        local menuThumb = menuScrollBar:GetThumbTexture()
        if menuThumb then
            menuThumb:SetTexture()
            menuThumb:SetColorTexture(tokens.accent.dim[1], tokens.accent.dim[2], tokens.accent.dim[3], 0.95)
            menuThumb:SetWidth(8)
        end
    end

    local function getCurrentDropdownWidth()
        local measuredWidth = dropdown:GetWidth() or width or 280
        return math.max(140, measuredWidth)
    end

    local content = CreateFrame("Frame", nil, scroll)
    content:SetSize(getCurrentDropdownWidth() - 28, 1)
    scroll:SetScrollChild(content)
    menu.content = content
    menu.rows = {}

    local function styleTexturePreview(previewTexture, textureName)
        local texPath = normalizeTexturePath(resolveStatusBarTexture(textureName))
        previewTexture:SetTexture(texPath)
        if not previewTexture:GetTexture() then
            texPath = "Interface\\Buttons\\WHITE8x8"
            previewTexture:SetTexture(texPath)
        end
        previewTexture:SetTexCoord(0, 1, 0.25, 0.75)
        previewTexture:SetVertexColor(1, 1, 1, 1)
        return texPath
    end

    local function refreshText()
        local current = currentValueGetter() or ""
        dropdown.text:SetText(current)
        dropdown.text:ClearAllPoints()
        dropdown.text:SetPoint("RIGHT", dropdown, "RIGHT", -24, 0)
        if mediaType == "statusbar" then
            dropdown.selectedPreview:Show()
            styleTexturePreview(dropdown.selectedPreview, current)
            dropdown.text:SetPoint("LEFT", dropdown.selectedPreview, "RIGHT", 8, 0)
            applyTypographyFontOnly(dropdown.text, "controlValue")
        else
            dropdown.selectedPreview:Hide()
            dropdown.text:SetPoint("LEFT", dropdown, "LEFT", 8, 0)
            if mediaType == "font" then
                -- Font picker previews the chosen typeface in its own face
                -- so the user can read the actual letterforms before
                -- applying. We keep that behavior; only the size matches
                -- the rest of the panel typography.
                safeSetFont(dropdown.text, resolveFont(current), typography.size.base, "")
            else
                applyTypographyFontOnly(dropdown.text, "controlValue")
            end
        end
    end

    local function hideMenu()
        menu:Hide()
    end

    local function refreshArrowState()
        if dropdown._isInteractive == false then
            arrow:SetVertexColor(tokens.text.disabled[1], tokens.text.disabled[2], tokens.text.disabled[3], 1)
            dropdown:SetBackdropBorderColor(tokens.border.subtle[1], tokens.border.subtle[2], tokens.border.subtle[3], 0.45)
            return
        end
        if menu:IsShown() then
            arrow:SetVertexColor(tokens.accent.bright[1], tokens.accent.bright[2], tokens.accent.bright[3], 1)
            dropdown:SetBackdropBorderColor(tokens.accent.primary[1], tokens.accent.primary[2], tokens.accent.primary[3], 1)
        elseif dropdown:IsMouseOver() then
            arrow:SetVertexColor(tokens.text.secondary[1], tokens.text.secondary[2], tokens.text.secondary[3], 1)
            dropdown:SetBackdropBorderColor(tokens.border.strong[1], tokens.border.strong[2], tokens.border.strong[3], 1)
        else
            arrow:SetVertexColor(tokens.text.muted[1], tokens.text.muted[2], tokens.text.muted[3], 1)
            dropdown:SetBackdropBorderColor(tokens.border.subtle[1], tokens.border.subtle[2], tokens.border.subtle[3], 1)
        end
    end

    local function rebuildRows(newFilterText)
        if type(newFilterText) == "string" then
            filterText = newFilterText
        end
        local allItems = itemsProvider() or {}
        local dropdownWidth = getCurrentDropdownWidth()
        local contentWidth = dropdownWidth - 28
        local items = {}
        local normalizedFilter = string.lower(filterText or "")
        for _, item in ipairs(allItems) do
            local textItem = tostring(item)
            if normalizedFilter == "" or string.find(string.lower(textItem), normalizedFilter, 1, true) then
                table.insert(items, textItem)
            end
        end

        for i, item in ipairs(items) do
            local row = menu.rows[i]
            if not row then
                row = CreateFrame("Button", nil, content)
                row:SetHeight(rowHeight)
                row.bg = row:CreateTexture(nil, "BACKGROUND")
                row.bg:SetAllPoints()

                row.preview = row:CreateTexture(nil, "ARTWORK")
                row.preview:SetPoint("LEFT", row, "LEFT", 6, 0)

                row.label = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
                row.label:SetPoint("RIGHT", row, "RIGHT", -6, 0)
                row.label:SetJustifyH("LEFT")
                row.label:SetWordWrap(false)
                if row.label.SetNonSpaceWrap then
                    row.label:SetNonSpaceWrap(false)
                end
                if row.label.SetMaxLines then
                    row.label:SetMaxLines(1)
                end
                applyTypography(row.label, "controlLabel", tokens.text.primary)

                row:SetScript("OnEnter", function(self)
                    setColor(self.bg, tokens.surface.cardHover)
                end)
                row:SetScript("OnLeave", function(self)
                    if self.isCurrent then
                        setColor(self.bg, tokens.surface.cardHover)
                    else
                        setColor(self.bg, tokens.surface.card)
                    end
                end)

                menu.rows[i] = row
            end

            row.itemName = item
            row:SetPoint("TOPLEFT", content, "TOPLEFT", 0, -((i - 1) * rowHeight))
            row:SetPoint("TOPRIGHT", content, "TOPRIGHT", 0, -((i - 1) * rowHeight))
            row:SetHeight(rowHeight)
            row:Show()

            row.label:ClearAllPoints()
            row.label:SetPoint("RIGHT", row, "RIGHT", -6, 0)
            if mediaType == "font" then
                row.preview:Hide()
                row.label:SetPoint("LEFT", row, "LEFT", 8, 0)
                -- Font rows are intentionally drawn in their *own* face
                -- so the user can see the typeface they're picking.
                safeSetFont(row.label, resolveFont(item), typography.size.base, "")
                row.label:SetText(item)
            elseif mediaType == "statusbar" then
                row.preview:Show()
                row.preview:SetSize(84, 12)
                row.preview:SetPoint("LEFT", row, "LEFT", 8, 0)
                row.label:SetPoint("LEFT", row.preview, "RIGHT", 8, 0)
                applyTypographyFontOnly(row.label, "controlLabel")
                styleTexturePreview(row.preview, item)
                row.label:SetText(item)
            else
                row.preview:Hide()
                row.label:SetPoint("LEFT", row, "LEFT", 8, 0)
                applyTypographyFontOnly(row.label, "controlLabel")
                row.label:SetText(item)
            end

            row.isCurrent = (item == currentValueGetter())
            setColor(row.bg, row.isCurrent and tokens.surface.cardHover or tokens.surface.card)

            row:SetScript("OnClick", function(self)
                local changed = onSelect(self.itemName)
                if changed == false then
                    refreshText()
                    rebuildRows(filterText)
                    return
                end
                refreshText()
                rebuildRows(filterText)
                ns.ApplyPreviewLayout()
                ns.ApplyRuntimeSettings()
                hideMenu()
            end)
        end

        for i = #items + 1, #menu.rows do
            menu.rows[i]:Hide()
        end

        local contentHeight = math.max(1, #items * rowHeight)
        content:SetSize(contentWidth, contentHeight)
        local topPadding = enableSearch and 34 or 8
        local finalHeight = math.min(maxHeight, contentHeight + topPadding)
        menu:SetSize(dropdownWidth, finalHeight)
        if scroll.SetVerticalScroll then
            scroll:SetVerticalScroll(0)
        end
    end

    if enableSearch and searchBox then
        searchBox:SetScript("OnEditFocusGained", function()
            if searchPlaceholder then
                searchPlaceholder:Hide()
            end
        end)
        searchBox:SetScript("OnEditFocusLost", function()
            if searchPlaceholder and searchBox:GetText() == "" then
                searchPlaceholder:Show()
            end
        end)
        searchBox:SetScript("OnTextChanged", function(self)
            local textValue = self:GetText() or ""
            if searchPlaceholder then
                searchPlaceholder:SetShown(textValue == "")
            end
            rebuildRows(textValue)
        end)
        searchBox:SetScript("OnEscapePressed", function(self)
            self:SetText("")
            self:ClearFocus()
        end)
    end

    dropdown:SetScript("OnClick", function(self)
        if self._isInteractive == false then
            return
        end
        if menu:IsShown() then
            hideMenu()
            refreshArrowState()
            return
        end

        for _, dd in ipairs(ui.dropdowns or {}) do
            if dd ~= self and dd.menu and dd.menu:IsShown() then
                dd.menu:Hide()
            end
        end

        filterText = ""
        if searchBox then
            searchBox:SetText("")
            if searchPlaceholder then
                searchPlaceholder:Show()
            end
        end
        rebuildRows("")
        menu:ClearAllPoints()
        menu:SetPoint("TOPLEFT", self, "BOTTOMLEFT", 0, -2)
        menu:Show()
        if searchBox and C_Timer and C_Timer.After then
            C_Timer.After(0, function()
                if menu:IsShown() then
                    searchBox:SetFocus()
                end
            end)
        end
        refreshArrowState()
    end)
    dropdown:SetScript("OnEnter", function()
        refreshArrowState()
    end)
    dropdown:SetScript("OnLeave", function()
        refreshArrowState()
    end)

    local function setEnabledState(isEnabled)
        dropdown._isInteractive = isEnabled == true
        dropdown:SetAlpha(dropdown._isInteractive and 1 or 0.45)
        dropdown:EnableMouse(dropdown._isInteractive)
        if dropdown._isInteractive == false and menu:IsShown() then
            hideMenu()
        end
        refreshArrowState()
    end

    if not ui.globalDropdownCloser then
        ui.globalDropdownCloser = CreateFrame("Frame", nil, UIParent)
        ui.globalDropdownCloser:SetAllPoints(UIParent)
        ui.globalDropdownCloser:EnableMouse(true)
        ui.globalDropdownCloser:SetFrameStrata("DIALOG")
        ui.globalDropdownCloser:SetScript("OnMouseDown", function()
            for _, dd in ipairs(ui.dropdowns or {}) do
                if dd.menu and dd.menu:IsShown() then
                    dd.menu:Hide()
                end
            end
        end)
        ui.globalDropdownCloser:Hide()
    end

    menu:SetScript("OnShow", function()
        if ui.globalDropdownCloser then
            ui.globalDropdownCloser:Show()
            ui.globalDropdownCloser:SetFrameLevel(menu:GetFrameLevel() - 1)
        end
        refreshArrowState()
    end)
    menu:SetScript("OnHide", function()
        if searchBox then
            searchBox:ClearFocus()
        end
        local anyShown = false
        for _, dd in ipairs(ui.dropdowns or {}) do
            if dd.menu and dd.menu:IsShown() then
                anyShown = true
                break
            end
        end
        if ui.globalDropdownCloser and not anyShown then
            ui.globalDropdownCloser:Hide()
        end
        refreshArrowState()
    end)

    ui.dropdowns = ui.dropdowns or {}
    table.insert(ui.dropdowns, dropdown)

    dropdown.RefreshText = refreshText
    dropdown.RefreshRows = rebuildRows
    dropdown.SetEnabledState = setEnabledState
    setEnabledState(true)
    refreshText()
    return dropdown
end
ns.CreatePremiumDropDown = createPremiumDropDown

---------------------------------------------------------------------------
-- createSlider
---------------------------------------------------------------------------

local function createSlider(parent, label, minVal, maxVal, valueGetter, onChanged, precision)
    local row = CreateFrame("Frame", nil, parent)
    row:SetSize(340, 48)

    local text = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    text:SetPoint("TOPLEFT", row, "TOPLEFT", 0, 0)
    text:SetPoint("TOPRIGHT", row, "TOPRIGHT", -2, 0)
    text:SetJustifyH("LEFT")
    text:SetText(label)
    -- All "field above control" labels use the same controlLabel preset
    -- (secondary tint) so sliders, labeled dropdowns, and text inputs read
    -- with one consistent voice.
    applyTypography(text, "controlLabel")

    sliderCounter = sliderCounter + 1
    local sliderName = ADDON_NAME:gsub("%s+", "") .. "Slider" .. sliderCounter

    -- Cap the slider track at a comfortable width that keeps the whole row
    -- aligned with the 340px dropdown/color-picker rhythm, regardless of the
    -- inspector's actual column width. Anchoring valueInput at a fixed
    -- offset from the row's LEFT (rather than from the RIGHT) means the
    -- track stretches to ~SLIDER_TRACK_MAX and the value box sits just
    -- after it, instead of pushing the value box to the far right and
    -- letting the track stretch into a 600px ribbon. Value-input width
    -- (44) + 8px gap = 52px reserved past the cap for the box.
    --
    -- Adaptive fallback: if the row's container is narrower than the
    -- cap + value-box chrome (e.g. the 280px MoverPanel), pin valueInput
    -- to the row's TOPRIGHT instead so the value box stays inside the
    -- row. Without this, the cap would push the value box off the right
    -- edge of narrow containers and bleed past the panel border.
    local SLIDER_TRACK_MAX = 288
    local SLIDER_NARROW_THRESHOLD = SLIDER_TRACK_MAX + 80
    local parentW = (parent and parent.GetWidth and parent:GetWidth()) or 0
    local valueInput = CreateFrame("EditBox", nil, row, "BackdropTemplate")
    valueInput:SetSize(44, 20)
    if parentW > 0 and parentW < SLIDER_NARROW_THRESHOLD then
        valueInput:SetPoint("TOPRIGHT", row, "TOPRIGHT", -2, -22)
    else
        valueInput:SetPoint("TOPLEFT", row, "TOPLEFT", SLIDER_TRACK_MAX + 8, -22)
    end
    valueInput:SetAutoFocus(false)
    valueInput:SetFontObject(GameFontHighlightSmall)
    -- Numeric value is the "active" piece of the row -- medium weight.
    applyTypographyFontOnly(valueInput, "controlValue")
    valueInput:SetJustifyH("RIGHT")
    valueInput:SetTextInsets(4, 4, 0, 0)
    valueInput:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        edgeSize = 1,
    })
    valueInput:SetBackdropColor(tokens.surface.inset[1], tokens.surface.inset[2], tokens.surface.inset[3], 1)
    valueInput:SetBackdropBorderColor(tokens.border.subtle[1], tokens.border.subtle[2], tokens.border.subtle[3], 1)

    -- Track: 10px slate channel with a 1px subtle border so the slider reads
    -- as a defined widget against the lighter card surface, not a hairline.
    -- Sits 22px below the row top -- same offset every "field above control"
    -- primitive uses (slider, labeled dropdown, text input) so a vertical
    -- stack of mixed control types lines up.
    local track = CreateFrame("Frame", nil, row, "BackdropTemplate")
    track:SetPoint("TOPLEFT", row, "TOPLEFT", 0, -22)
    track:SetPoint("RIGHT", valueInput, "LEFT", -8, 0)
    track:SetHeight(10)
    track:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        edgeSize = 1,
    })
    track:SetBackdropColor(tokens.surface.inset[1], tokens.surface.inset[2], tokens.surface.inset[3], 1)
    track:SetBackdropBorderColor(tokens.border.subtle[1], tokens.border.subtle[2], tokens.border.subtle[3], 1)

    local fill = track:CreateTexture(nil, "ARTWORK")
    fill:SetPoint("LEFT", track, "LEFT", 0, 0)
    fill:SetHeight(10)
    fill:SetColorTexture(tokens.accent.primary[1], tokens.accent.primary[2], tokens.accent.primary[3], 1)

    local slider = CreateFrame("Slider", sliderName, row)
    slider:SetPoint("TOPLEFT", track, "TOPLEFT", 0, 0)
    slider:SetPoint("BOTTOMRIGHT", track, "BOTTOMRIGHT", 0, 0)
    if slider.SetHitRectInsets then
        slider:SetHitRectInsets(0, 0, -6, -6)
    end
    slider:SetOrientation("HORIZONTAL")
    slider:SetMinMaxValues(minVal, maxVal)
    local decimals = math.max(0, tonumber(precision) or 0)
    local step = 1 / (10 ^ decimals)
    slider:SetValueStep(step)
    slider:SetObeyStepOnDrag(true)

    local function normalizeSliderValue(rawValue)
        local numericValue = tonumber(rawValue) or 0
        if decimals <= 0 then
            return roundToInt(numericValue)
        end
        local pow = 10 ^ decimals
        return math.floor((numericValue * pow) + 0.5) / pow
    end

    local thumb = slider:CreateTexture(nil, "OVERLAY")
    thumb:SetSize(20, 20)
    thumb:SetColorTexture(tokens.accent.bright[1], tokens.accent.bright[2], tokens.accent.bright[3], 1)
    slider:SetThumbTexture(thumb)

    slider:SetValue(valueGetter())

    local function syncInputAndFill(rawValue)
        local numericValue = normalizeSliderValue(rawValue)
        if not valueInput:HasFocus() then
            if decimals > 0 then
                valueInput:SetText(string.format("%." .. decimals .. "f", numericValue))
            else
                valueInput:SetText(tostring(numericValue))
            end
        end
        local span = (maxVal - minVal)
        local pct = span == 0 and 0 or ((numericValue - minVal) / span)
        pct = math.max(0, math.min(1, pct))
        fill:SetWidth(math.max(0, (track:GetWidth() or 0) * pct))
    end

    local function updateSliderVisual(isInteractive, isHovered)
        if not isInteractive then
            thumb:SetColorTexture(tokens.text.disabled[1], tokens.text.disabled[2], tokens.text.disabled[3], 1)
            valueInput:SetBackdropBorderColor(tokens.border.subtle[1], tokens.border.subtle[2], tokens.border.subtle[3], 0.5)
            return
        end
        if isHovered then
            thumb:SetColorTexture(tokens.accent.bright[1], tokens.accent.bright[2], tokens.accent.bright[3], 1)
            valueInput:SetBackdropBorderColor(tokens.accent.primary[1], tokens.accent.primary[2], tokens.accent.primary[3], 1)
        else
            thumb:SetColorTexture(tokens.accent.primary[1], tokens.accent.primary[2], tokens.accent.primary[3], 1)
            valueInput:SetBackdropBorderColor(tokens.border.subtle[1], tokens.border.subtle[2], tokens.border.subtle[3], 1)
        end
    end

    local function setSliderEnabledState(isEnabled)
        row._isInteractive = isEnabled == true
        row:SetAlpha(row._isInteractive and 1 or 0.45)
        slider:EnableMouse(row._isInteractive)
        if slider.SetEnabled then
            slider:SetEnabled(row._isInteractive)
        end
        if row._isInteractive then
            if valueInput.Enable then
                valueInput:Enable()
            end
        else
            if valueInput.ClearFocus then
                valueInput:ClearFocus()
            end
            if valueInput.Disable then
                valueInput:Disable()
            end
        end
        updateSliderVisual(row._isInteractive, row._isHovered == true)
    end

    slider:SetScript("OnValueChanged", function(self, value)
        if row._isInteractive == false then
            return
        end
        if self._reverting or self._suppressSyncCallback then
            syncInputAndFill(value)
            return
        end

        local nextValue = normalizeSliderValue(value)
        if value ~= nextValue then
            self._suppressSyncCallback = true
            self:SetValue(nextValue)
            self._suppressSyncCallback = nil
        end
        local changed = onChanged(nextValue)
        if changed == false then
            self._reverting = true
            self:SetValue(valueGetter())
            self._reverting = false
            syncInputAndFill(self:GetValue())
            return
        end

        syncInputAndFill(nextValue)
        ns.ApplyPreviewLayout()
        ns.SyncOffsetSliders()
        ns.ApplyRuntimeSettings()
    end)

    valueInput:SetScript("OnEnterPressed", function(self)
        if row._isInteractive == false then
            self:ClearFocus()
            return
        end
        local parsed = tonumber(self:GetText() or "")
        if not parsed then
            syncInputAndFill(slider:GetValue())
            self:ClearFocus()
            return
        end
        local nextValue = normalizeSliderValue(math.max(minVal, math.min(maxVal, parsed)))
        slider:SetValue(nextValue)
        syncInputAndFill(slider:GetValue())
        self:ClearFocus()
    end)
    valueInput:SetScript("OnEscapePressed", function(self)
        syncInputAndFill(slider:GetValue())
        self:ClearFocus()
    end)
    valueInput:SetScript("OnEditFocusLost", function()
        syncInputAndFill(slider:GetValue())
    end)
    row:EnableMouse(true)
    row:SetScript("OnEnter", function()
        row._isHovered = true
        updateSliderVisual(row._isInteractive ~= false, true)
    end)
    row:SetScript("OnLeave", function()
        row._isHovered = false
        updateSliderVisual(row._isInteractive ~= false, false)
    end)
    valueInput:SetScript("OnEditFocusGained", function()
        row._isHovered = true
        updateSliderVisual(row._isInteractive ~= false, true)
    end)
    track:SetScript("OnSizeChanged", function()
        syncInputAndFill(slider:GetValue())
    end)

    syncInputAndFill(slider:GetValue())
    setSliderEnabledState(true)
    row.slider = slider
    row.input = valueInput
    row.SetEnabledState = setSliderEnabledState
    -- Cards inside a hidden tab may be constructed while track:GetWidth()
    -- is still 0, in which case the initial syncInputAndFill runs against
    -- a zero-width track. Expose a Refresh hook so the tab strip can
    -- re-sync the EditBox text once the page actually becomes visible.
    row.Refresh = function()
        syncInputAndFill(slider:GetValue())
    end
    return row
end
ns.CreateSlider = createSlider

---------------------------------------------------------------------------
-- createCheckbox
---------------------------------------------------------------------------

local function createCheckbox(parent, label, key, opts)
    opts = opts or {}
    checkboxCounter = checkboxCounter + 1
    local container = CreateFrame("Frame", nil, parent)
    container:SetSize(220, 24)
    container:EnableMouse(true)

    local checkbox = CreateFrame("Button", ADDON_NAME:gsub("%s+", "") .. "Checkbox" .. checkboxCounter, container, "BackdropTemplate")
    checkbox:SetSize(16, 16)
    checkbox:SetPoint("LEFT", container, "LEFT", 0, 0)
    if not checkbox.SetBackdrop then
        Mixin(checkbox, BackdropTemplateMixin)
    end
    checkbox:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        edgeSize = 1,
    })
    checkbox:SetBackdropColor(tokens.surface.inset[1], tokens.surface.inset[2], tokens.surface.inset[3], 1)
    checkbox:SetBackdropBorderColor(tokens.border.subtle[1], tokens.border.subtle[2], tokens.border.subtle[3], 1)

    -- Filled accent square + tiny inner check-mark (a thin "L" drawn from
    -- two textures so we don't depend on a font glyph lining up).
    local checkFill = checkbox:CreateTexture(nil, "ARTWORK")
    checkFill:SetPoint("TOPLEFT", checkbox, "TOPLEFT", 1, -1)
    checkFill:SetPoint("BOTTOMRIGHT", checkbox, "BOTTOMRIGHT", -1, 1)
    checkFill:SetColorTexture(tokens.accent.primary[1], tokens.accent.primary[2], tokens.accent.primary[3], 1)
    checkFill:Hide()

    local checkMarkA = checkbox:CreateTexture(nil, "OVERLAY")
    checkMarkA:SetSize(2, 5)
    checkMarkA:SetPoint("CENTER", checkbox, "CENTER", -2, -1)
    checkMarkA:SetColorTexture(1, 1, 1, 1)
    checkMarkA:SetRotation(math.pi / 4)
    checkMarkA:Hide()
    local checkMarkB = checkbox:CreateTexture(nil, "OVERLAY")
    checkMarkB:SetSize(2, 9)
    checkMarkB:SetPoint("CENTER", checkbox, "CENTER", 1, 1)
    checkMarkB:SetColorTexture(1, 1, 1, 1)
    checkMarkB:SetRotation(-math.pi / 4)
    checkMarkB:Hide()

    local labelText = container:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    labelText:SetPoint("LEFT", checkbox, "RIGHT", 8, 0)
    labelText:SetText(label)
    applyTypography(labelText, "controlLabel")

    local function setCheckedVisual(isChecked)
        local on = isChecked == true
        checkFill:SetShown(on)
        checkMarkA:SetShown(on)
        checkMarkB:SetShown(on)
        checkbox.isChecked = on
    end

    local function readCheckedValue()
        local value = profile()[key]
        if value == nil and opts.defaultValue ~= nil then
            return opts.defaultValue == true
        end
        return value ~= false
    end

    local function setHoverVisual(isHovered)
        if isHovered then
            checkbox:SetBackdropBorderColor(tokens.accent.primary[1], tokens.accent.primary[2], tokens.accent.primary[3], 1)
            styleLabel(labelText, tokens.text.primary)
        else
            checkbox:SetBackdropBorderColor(tokens.border.subtle[1], tokens.border.subtle[2], tokens.border.subtle[3], 1)
            styleLabel(labelText, tokens.text.secondary)
        end
    end

    checkbox:SetScript("OnClick", function(self)
        local isChecked = not self.isChecked
        setCheckedVisual(isChecked)

        if not updateProfileSetting(key, isChecked) then
            setCheckedVisual(readCheckedValue())
            return
        end

        ns.ApplyPreviewLayout()
        ns.ApplyRuntimeSettings()
    end)
    checkbox:SetScript("OnEnter", function()
        setHoverVisual(true)
    end)
    checkbox:SetScript("OnLeave", function()
        setHoverVisual(false)
    end)
    container:SetScript("OnEnter", function()
        setHoverVisual(true)
    end)
    container:SetScript("OnLeave", function()
        setHoverVisual(false)
    end)
    container:SetScript("OnMouseUp", function(_, button)
        if button == "LeftButton" then
            if checkbox:IsMouseOver() then
                return
            end
            checkbox:Click()
        end
    end)

    function container:GetChecked()
        return checkbox.isChecked == true
    end

    function container:SetChecked(isChecked)
        setCheckedVisual(isChecked == true)
    end

    function container:Refresh()
        setCheckedVisual(readCheckedValue())
    end

    container.button = checkbox
    container.fill = checkFill
    container.label = labelText
    container:Refresh()
    setHoverVisual(false)
    return container
end
ns.CreateCheckbox = createCheckbox

---------------------------------------------------------------------------
-- createColorPickerControl
---------------------------------------------------------------------------

local function createColorPickerControl(parent, label, colorGetter, onColorChanged, asInlineRow)
    -- Two render modes:
    --   * asInlineRow == true: bare row (label + swatch, no chrome). Used
    --     in dense sub-grids like the Secondary Resource color overrides
    --     where multiple pickers stack vertically and per-row backdrops
    --     would feel like nested boxes.
    --   * asInlineRow == false (default): wrapped in a small bordered
    --     "chip" -- a 1px border + soft surface.card fill -- so each
    --     color row reads as a self-contained editable swatch. Sliders,
    --     dropdowns, and checkboxes stay flat; only color
    --     pickers get this chrome because they're discrete pickers without
    --     any other visual containment.
    local rowHeight = asInlineRow and 26 or 36
    local swatchHeight = asInlineRow and 18 or 22

    local holder
    if asInlineRow then
        holder = CreateFrame("Frame", nil, parent)
    else
        holder = CreateFrame("Frame", nil, parent, "BackdropTemplate")
        holder:SetBackdrop({
            bgFile   = "Interface\\Buttons\\WHITE8x8",
            edgeFile = "Interface\\Buttons\\WHITE8x8",
            edgeSize = 1,
        })
        holder:SetBackdropColor(tokens.surface.card[1], tokens.surface.card[2], tokens.surface.card[3], 0.6)
        holder:SetBackdropBorderColor(tokens.border.subtle[1], tokens.border.subtle[2], tokens.border.subtle[3], 1)
    end
    holder:SetSize(340, rowHeight)

    -- Inner padding for the chip so the label doesn't butt up against
    -- the border. Inline rows skip the padding (no border to butt
    -- against).
    local labelLeft = asInlineRow and 0 or 12
    local swatchRight = asInlineRow and 0 or -10

    local labelText = holder:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    labelText:SetPoint("LEFT", holder, "LEFT", labelLeft, 0)
    labelText:SetPoint("RIGHT", holder, "RIGHT", -56, 0)
    labelText:SetJustifyH("LEFT")
    labelText:SetText(label)
    applyTypography(labelText, "controlLabel")

    local button = CreateFrame("Button", nil, holder, "BackdropTemplate")
    button:SetSize(40, swatchHeight)
    button:SetPoint("RIGHT", holder, "RIGHT", swatchRight, 0)
    button:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        edgeSize = 1,
    })
    button:SetBackdropColor(tokens.surface.inset[1], tokens.surface.inset[2], tokens.surface.inset[3], 1)
    button:SetBackdropBorderColor(tokens.border.subtle[1], tokens.border.subtle[2], tokens.border.subtle[3], 1)

    local swatch = button:CreateTexture(nil, "ARTWORK")
    swatch:SetPoint("TOPLEFT", button, "TOPLEFT", 2, -2)
    swatch:SetPoint("BOTTOMRIGHT", button, "BOTTOMRIGHT", -2, 2)

    -- Hover hex tooltip: anchored next to the swatch, hidden by default.
    local hexTooltip = holder:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    hexTooltip:SetPoint("RIGHT", button, "LEFT", -6, 0)
    -- Hex code is meta-information that floats next to the swatch; keep
    -- it tiny + muted so the swatch itself stays the focal point.
    applyTypography(hexTooltip, "caption")
    hexTooltip:Hide()

    local function refreshHex()
        local c = colorGetter() or { r = 1, g = 1, b = 1, a = 1 }
        local r = math.floor((c.r or 1) * 255 + 0.5)
        local g = math.floor((c.g or 1) * 255 + 0.5)
        local b = math.floor((c.b or 1) * 255 + 0.5)
        hexTooltip:SetText(string.format("#%02X%02X%02X", r, g, b))
    end

    local function setButtonHoverVisual(isHovered)
        if isHovered then
            button:SetBackdropBorderColor(tokens.accent.primary[1], tokens.accent.primary[2], tokens.accent.primary[3], 1)
            -- For chip-style pickers, also light up the chip's outer
            -- border so the entire row reads as the hover target, not
            -- just the swatch in the corner.
            if not asInlineRow and holder.SetBackdropBorderColor then
                holder:SetBackdropBorderColor(tokens.accent.primary[1], tokens.accent.primary[2], tokens.accent.primary[3], 1)
            end
            refreshHex()
            hexTooltip:Show()
            styleLabel(labelText, tokens.text.primary)
        else
            button:SetBackdropBorderColor(tokens.border.subtle[1], tokens.border.subtle[2], tokens.border.subtle[3], 1)
            if not asInlineRow and holder.SetBackdropBorderColor then
                holder:SetBackdropBorderColor(tokens.border.subtle[1], tokens.border.subtle[2], tokens.border.subtle[3], 1)
            end
            hexTooltip:Hide()
            styleLabel(labelText, tokens.text.secondary)
        end
    end

    local function refreshSwatch()
        local c = colorGetter() or { r = 1, g = 1, b = 1, a = 1 }
        swatch:SetColorTexture(c.r or 1, c.g or 1, c.b or 1, c.a or 1)
    end

    button:SetScript("OnClick", function()
        if not ColorPickerFrame then
            return
        end

        local current = colorGetter() or { r = 1, g = 1, b = 1, a = 1 }
        local function applyColor(r, g, b, a)
            onColorChanged(r, g, b, a)
            refreshSwatch()
            ns.ApplyPreviewLayout()
            ns.ApplyRuntimeSettings()
        end

        local function ShowColorPicker(r, g, b, a, callback)
            local info = {
                r = r, g = g, b = b, opacity = a,
                hasOpacity = true,
                swatchFunc = function()
                    local newR, newG, newB = ColorPickerFrame:GetColorRGB()
                    local newA = ColorPickerFrame:GetColorAlpha()
                    callback(newR, newG, newB, newA)
                end,
                opacityFunc = function()
                    local newR, newG, newB = ColorPickerFrame:GetColorRGB()
                    local newA = ColorPickerFrame:GetColorAlpha()
                    callback(newR, newG, newB, newA)
                end,
                cancelFunc = function()
                    callback(r, g, b, a)
                end,
            }
            if ColorPickerFrame.SetupColorPickerAndShow then
                ColorPickerFrame:SetupColorPickerAndShow(info)
            else
                ColorPickerFrame.hasOpacity = true
                ColorPickerFrame.opacity = 1 - a
                ColorPickerFrame.previousValues = { r, g, b, a }
                local function fallbackApply()
                    local newR, newG, newB = ColorPickerFrame:GetColorRGB()
                    local newA = 1 - ((OpacitySliderFrame and OpacitySliderFrame:GetValue()) or 0)
                    callback(newR, newG, newB, newA)
                end
                ColorPickerFrame.func = fallbackApply
                ColorPickerFrame.opacityFunc = fallbackApply
                ColorPickerFrame.cancelFunc = function()
                    callback(r, g, b, a)
                end
                ColorPickerFrame:SetColorRGB(r, g, b)
                ColorPickerFrame:Hide()
                ColorPickerFrame:Show()
            end
        end

        ShowColorPicker(current.r or 1, current.g or 1, current.b or 1, current.a or 1, applyColor)
    end)
    button:SetScript("OnEnter", function()
        setButtonHoverVisual(true)
    end)
    button:SetScript("OnLeave", function()
        setButtonHoverVisual(false)
    end)

    -- Make the entire chip a hover/click target so users can mouse over
    -- the label text or empty space inside the chip and still see the
    -- accent highlight + click through to the picker. Inline rows skip
    -- this since they're meant to feel like flat list rows.
    if not asInlineRow then
        holder:EnableMouse(true)
        holder:SetScript("OnEnter", function()
            setButtonHoverVisual(true)
        end)
        holder:SetScript("OnLeave", function()
            setButtonHoverVisual(false)
        end)
        holder:SetScript("OnMouseUp", function(_, mouseButton)
            if mouseButton == "LeftButton" and button:GetScript("OnClick") then
                button:GetScript("OnClick")(button)
            end
        end)
    end

    holder.Refresh = refreshSwatch
    holder.button = button
    holder.label = labelText
    ui.colorPickerControls = ui.colorPickerControls or {}
    table.insert(ui.colorPickerControls, holder)
    refreshSwatch()
    setButtonHoverVisual(false)
    return holder
end
ns.CreateColorPickerControl = createColorPickerControl

local function createInlineColorPickerControl(parent, label, colorGetter, onColorChanged)
    return createColorPickerControl(parent, label, colorGetter, onColorChanged, true)
end
ns.CreateInlineColorPickerControl = createInlineColorPickerControl

---------------------------------------------------------------------------
-- createTextInput
---------------------------------------------------------------------------

local function createTextInput(parent, label, valueGetter, onChanged, maxChars)
    local row = CreateFrame("Frame", nil, parent)
    row:SetSize(340, 44)

    local text = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    text:SetPoint("TOPLEFT", row, "TOPLEFT", 0, 0)
    text:SetPoint("TOPRIGHT", row, "TOPRIGHT", -2, 0)
    text:SetJustifyH("LEFT")
    text:SetText(label)
    applyTypography(text, "controlLabel")

    local input = CreateFrame("EditBox", nil, row, "BackdropTemplate")
    input:SetPoint("TOPLEFT", row, "TOPLEFT", 0, -22)
    input:SetPoint("RIGHT", row, "RIGHT", -2, 0)
    input:SetHeight(22)
    input:SetAutoFocus(false)
    input:SetFontObject(GameFontHighlightSmall)
    -- User-editable value: medium weight, primary color so the editing
    -- target reads as "this is what you're changing".
    applyTypographyFontOnly(input, "controlValue")
    input:SetJustifyH("LEFT")
    input:SetTextInsets(6, 6, 0, 0)
    input:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        edgeSize = 1,
    })
    input:SetBackdropColor(tokens.surface.canvas[1], tokens.surface.canvas[2], tokens.surface.canvas[3], 0.95)
    input:SetBackdropBorderColor(tokens.border.subtle[1], tokens.border.subtle[2], tokens.border.subtle[3], 1)
    if maxChars then
        input:SetMaxLetters(maxChars)
    end

    local function applyValue()
        local value = tostring(input:GetText() or "")
        local changed = onChanged(value)
        if changed == false then
            input:SetText(tostring(valueGetter() or ""))
            return
        end
        ns.ApplyPreviewLayout()
        ns.ApplyRuntimeSettings()
    end

    input:SetScript("OnEnterPressed", function(self)
        applyValue()
        self:ClearFocus()
    end)
    input:SetScript("OnEscapePressed", function(self)
        self:SetText(tostring(valueGetter() or ""))
        self:ClearFocus()
    end)
    input:SetScript("OnEditFocusLost", function()
        applyValue()
    end)

    function row:Refresh()
        input:SetText(tostring(valueGetter() or ""))
    end
    row:Refresh()
    row.input = input
    return row
end
ns.CreateTextInput = createTextInput

---------------------------------------------------------------------------
-- createLabeledDropdown  (convenience: label + dropdown in a single row)
---------------------------------------------------------------------------

local function createLabeledDropdown(parent, label, itemsProvider, currentValueGetter, onSelect, mediaType)
    local row = CreateFrame("Frame", nil, parent)
    row:SetSize(340, 50)

    local labelText = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    labelText:SetPoint("TOPLEFT", row, "TOPLEFT", 0, 0)
    labelText:SetPoint("TOPRIGHT", row, "TOPRIGHT", -2, 0)
    labelText:SetJustifyH("LEFT")
    labelText:SetText(label)
    applyTypography(labelText, "controlLabel")

    local dropdown = createPremiumDropDown(row, 340, itemsProvider, currentValueGetter, onSelect, mediaType)
    dropdown:SetPoint("TOPLEFT", row, "TOPLEFT", 0, -22)

    row.label = labelText
    row.dropdown = dropdown
    return row, dropdown
end
ns.CreateLabeledDropdown = createLabeledDropdown

---------------------------------------------------------------------------
-- attachTooltip
---------------------------------------------------------------------------

local function attachTooltip(target, text)
    if not target or not text then
        return
    end
    target:HookScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        GameTooltip:SetText(text, 1, 1, 1)
        GameTooltip:Show()
    end)
    target:HookScript("OnLeave", function()
        GameTooltip:Hide()
    end)
end
ns.AttachTooltip = attachTooltip

---------------------------------------------------------------------------
-- Layout Helpers
---------------------------------------------------------------------------

-- Stack tracker rationale:
-- finishGroup used to read GetPoint(1) from the last child and treat the
-- yOffset as cumulative. But because every placeControl chain anchors to the
-- previous control's BOTTOMLEFT (relative offset of -CONTROL_ROW_GAP), that
-- read only ever recovered the per-row gap, not the total stack height. The
-- result: groups were silently undersized and only kept legible by the static
-- minHeight floors at each call site. Anything that exceeded the floor (Font
-- & Style, Visibility Position, Decoration) bled out the bottom of the group.
--
-- The fix is to keep a running running-bottom on the chain itself. Each call
-- to placeControl stamps `_stackBottom` (height in px from the *content*
-- frame's TOP edge to the placed control's bottom edge); seedStack is used by
-- the manually-positioned first row of every group so the chain knows where
-- to start.
local function seedStack(control, height)
    if not control then return control end
    local h = height
    if type(h) ~= "number" or h <= 0 then
        h = (control.GetHeight and control:GetHeight()) or 24
    end
    control._stackBottom = h
    return control
end
ns.SeedStack = seedStack

-- Seed a wrapping FontString chain by measuring the *real* multi-line
-- height instead of the single-line GetHeight(). Use this for any
-- intro/help-text FontString that uses SetWordWrap(true) -- otherwise the
-- next placeControl in the chain will overlap the wrapped lines because
-- GetHeight reports only the first line's pixel height.
--
-- Replaces a pile of `seedStack(text, 24)` and
-- `_stackBottom = (prev._stackBottom or 24) + gap + 30` literal-height
-- fudges that drifted whenever the help string changed length.
local function seedWrappedText(control)
    if not control then return control end
    -- Pin the FontString's width to its parent's current width before
    -- measuring. Otherwise GetStringHeight returns the *unwrapped*
    -- single-line height because the FontString hasn't been told what
    -- to wrap against yet (anchors alone don't constrain GetStringHeight
    -- without a SetWidth or a layout pass).
    local parent = control:GetParent()
    if parent and parent.GetWidth then
        local pw = parent:GetWidth() or 0
        if pw > 0 and control.SetWidth then
            control:SetWidth(pw)
        end
    end
    local measured = (control.GetStringHeight and control:GetStringHeight())
        or (control.GetHeight and control:GetHeight())
        or 24
    if type(measured) ~= "number" or measured <= 0 then
        measured = 24
    end
    -- _stackPrefix is the cumulative chain bottom *before* this control
    -- (set by stackBelow when a wrapped text sits mid-chain; 0 when the
    -- text is the first element of a card). Storing it lets the deferred
    -- remeasureWrappedTexts() pass preserve the prefix instead of
    -- overwriting _stackBottom with just the FontString's own height,
    -- which was previously losing the chain offset and stamping cards
    -- with too-short heights.
    local prefix = control._stackPrefix or 0
    control._stackPrefix = prefix
    control._stackBottom = prefix + measured
    -- Track this control so a one-shot post-layout pass can re-measure
    -- after panels reflow at known widths.
    ns.WrappedTextControls = ns.WrappedTextControls or {}
    table.insert(ns.WrappedTextControls, control)
    return control
end
ns.SeedWrappedText = seedWrappedText

-- Re-measure every wrapped-text FontString registered via seedWrappedText
-- using its parent's current (now-known) width. Used after the panels
-- have laid out their geometry so the second RefreshPanelGridLayouts pass
-- has correct stack heights to work from.
local function remeasureWrappedTexts()
    if not ns.WrappedTextControls then return end
    for _, control in ipairs(ns.WrappedTextControls) do
        if control and control.GetParent and control.GetStringHeight then
            local parent = control:GetParent()
            if parent and parent.GetWidth then
                local pw = parent:GetWidth() or 0
                if pw > 0 and control.SetWidth then
                    control:SetWidth(pw)
                end
            end
            local measured = control:GetStringHeight() or 24
            if type(measured) == "number" and measured > 0 then
                -- Keep the chain prefix that stackBelow/placeControl
                -- stamped on this control. Overwriting _stackBottom
                -- with just `measured` (as the previous version did)
                -- threw away the cumulative offset, so cards whose
                -- last element was a wrapped help text under sliders
                -- ended up sized to just the help text height -> next
                -- card overlapped it.
                local prefix = control._stackPrefix or 0
                control._stackBottom = prefix + measured
            end
        end
    end
end
ns.RemeasureWrappedTexts = remeasureWrappedTexts

-- Record of every chain link (placeControl + stackBelow) so a deferred
-- re-measure can propagate _stackBottom updates from wrapped-text
-- anchors down through the chain. Declared before placeControl so the
-- upvalue exists when the function captures it. Each link carries the
-- gap so replay can re-derive the prefix correctly.
local placeControlChains = {}
ns.PlaceControlChains = placeControlChains

local function registerStackLink(prev, control, gap)
    table.insert(placeControlChains, {
        prev = prev,
        control = control,
        gap = gap or CONTROL_ROW_GAP,
    })
end
ns.RegisterStackLink = registerStackLink

local function placeControl(currentTop, control)
    control:SetPoint("TOPLEFT", currentTop, "BOTTOMLEFT", 0, -CONTROL_ROW_GAP)
    local prevBottom = currentTop and currentTop._stackBottom
    if type(prevBottom) ~= "number" then
        prevBottom = (currentTop and currentTop.GetHeight and currentTop:GetHeight()) or 24
    end
    local height = (control.GetHeight and control:GetHeight()) or 24
    control._stackBottom = prevBottom + CONTROL_ROW_GAP + height
    -- Stamp the prefix on the control so wrapped-text remeasure can
    -- preserve it across deferred passes (matters when a wrapped text
    -- sits mid-chain rather than at the top of a card).
    control._stackPrefix = prevBottom + CONTROL_ROW_GAP
    registerStackLink(currentTop, control, CONTROL_ROW_GAP)
    return control
end
ns.PlaceControl = placeControl

-- Card top chrome (padding + header + header-gap) + bottom padding. Both
-- match the createCard primitive constants so finishGroup can size the
-- group (card or flat section) to fit its content stack precisely.
local FRAMED_TOP_CHROME = CARD_TOP_CHROME      -- 14 + 16 + 12 = 42
local FRAMED_BOTTOM_PAD = CARD_PADDING_BOT     -- 14
local SECTION_TOP_CHROME = SECTION_TOP_PAD + SECTION_HEADER_HEIGHT + SECTION_HEADER_GAP
local SECTION_BOTTOM_PAD = 10

-- finishGroup records itself onto group so a deferred re-measure pass
-- can recompute heights with up-to-date wrapped-text _stackBottom values.
-- Recording (lastElement, minHeight) is enough since the height is purely
-- a function of those + the group's framed/flat flag.
local finishGroupRecords = {}
ns.FinishGroupRecords = finishGroupRecords

local function finishGroup(group, lastElement, minHeight)
    -- Record this call so we can replay it after wrapped-text remeasure.
    if group and not group._finishRecorded then
        group._finishRecorded = true
        table.insert(finishGroupRecords, {
            group = group,
            lastElement = lastElement,
            minHeight = minHeight,
        })
    end
    local stack
    if lastElement and type(lastElement._stackBottom) == "number" then
        stack = lastElement._stackBottom
    elseif lastElement and lastElement.GetHeight then
        local measured = lastElement:GetHeight()
        stack = (type(measured) == "number" and measured > 0) and measured or 24
    else
        stack = 24
    end
    local isFramed = group and group._isFramed == true
    local topChrome = isFramed and FRAMED_TOP_CHROME or SECTION_TOP_CHROME
    local bottomPad = isFramed and FRAMED_BOTTOM_PAD or SECTION_BOTTOM_PAD
    local computedHeight = topChrome + stack + bottomPad
    -- Flat sections shouldn't pad themselves out to the legacy 120px
    -- minimum; a 2-control section is allowed to be short. Framed cards
    -- keep the higher floor so they read as proper boxes.
    local floor = minHeight or (isFramed and 120 or 0)
    local finalHeight = math.max(floor, computedHeight)
    -- Stash the arithmetic *expanded* height so applyPanelGridLayout
    -- can pack later sections against it deterministically. WoW's frame
    -- system can return stale or zero :GetHeight() values if a layout
    -- pass runs before a paint -- which is what was causing flat
    -- sections to overlap on the Secondary Resource tab.
    group._expandedHeight = finalHeight
    group._computedHeight = finalHeight
    -- If the section is collapsed (chevron toggled), the createCard's
    -- ApplyCollapsedState already shrank it; don't expand it back.
    -- IsCollapsed is only present on flat sections that opted in.
    if group.IsCollapsed and group:IsCollapsed() then
        local collapsedHeight = (SECTION_TOP_PAD + SECTION_HEADER_HEIGHT + 6)
        group:SetHeight(collapsedHeight)
        group._computedHeight = collapsedHeight
        if group.ApplyCollapsedState then
            group:ApplyCollapsedState()
        end
    else
        group:SetHeight(finalHeight)
    end
end
ns.FinishGroup = finishGroup

-- Replay every recorded finishGroup so _computedHeight reflects the
-- latest _stackBottom values from placeControl chains. Used after
-- remeasureWrappedTexts() updates wrapped-text heights, so downstream
-- placeControl chain bottoms still need to be recomputed -- but since
-- placeControl writes _stackBottom = prev + gap + height, and finishGroup
-- reads only the *last* element's _stackBottom, replaying finishGroup
-- alone is insufficient if a wrapped text is in the middle of a chain.
-- For the current usage (every wrapped text is the FIRST element in a
-- card, used as a help intro), seedWrappedText writes its own
-- _stackBottom, and downstream placeControl calls cascade from there
-- on the next replay. We therefore replay placeControl chains too.
local function replayFinishGroups()
    -- Re-walk every recorded chain link in declaration order so
    -- _stackBottom values propagate from now-remeasured wrapped-text
    -- anchors all the way to the last element of each card. Both
    -- placeControl and stackBelow links live in placeControlChains;
    -- each carries its own gap so the prefix math stays accurate.
    for _, link in ipairs(placeControlChains) do
        local prev = link.prev
        local control = link.control
        local gap = link.gap or CONTROL_ROW_GAP
        if prev and control then
            local prevBottom = prev._stackBottom
            if type(prevBottom) ~= "number" then
                prevBottom = (prev.GetHeight and prev:GetHeight()) or 24
            end
            local prefix = prevBottom + gap
            control._stackPrefix = prefix
            -- FontStrings (wrapped help text) report their rendered
            -- height via GetStringHeight; everything else (frames:
            -- sliders, dropdowns, color pickers, etc.) is fixed-size
            -- and we read GetHeight. We detect FontString via
            -- GetObjectType so we don't accidentally call
            -- GetStringHeight on a frame that exposes a stub.
            local isFontString = control.GetObjectType
                and control:GetObjectType() == "FontString"
            local height
            if isFontString and control.GetStringHeight then
                height = control:GetStringHeight() or 24
            else
                height = (control.GetHeight and control:GetHeight()) or 24
            end
            control._stackBottom = prefix + height
        end
    end
    for _, record in ipairs(finishGroupRecords) do
        finishGroup(record.group, record.lastElement, record.minHeight)
        if record.group then
            record.group._finishRecorded = true
        end
    end
end
ns.ReplayFinishGroups = replayFinishGroups

---------------------------------------------------------------------------
-- Two-Column Responsive Grid Layout System
---------------------------------------------------------------------------

local panelGridLayouts = {}
ns.PanelGridLayouts = panelGridLayouts

local function registerPanelGridLayout(panel, topAnchor, topOffset, groups, panelPadding)
    table.insert(panelGridLayouts, {
        panel = panel,
        topAnchor = topAnchor,
        topOffset = topOffset or -8,
        groups = groups or {},
        -- 120px keeps the bottom of the last flat section comfortably
        -- above the scroll edge -- including the Threshold Markers
        -- block on the Resource Bar tab and the per-tab Reset pill we
        -- now stack at the bottom of each panel. Flat sections compute
        -- their height from a stack-bottom estimate that can
        -- underestimate by 30-60px when wrapped help text reflows
        -- narrower than expected, so we give the panel safety margin
        -- rather than chase exact numbers per section.
        panelPadding = panelPadding or 120,
        rowFrames = {},
    })
end
ns.RegisterPanelGridLayout = registerPanelGridLayout

local function normalizeGridEntry(entry)
    if type(entry) == "table" then
        local descriptorGroup = rawget(entry, "group")
        if descriptorGroup ~= nil then
            local descriptorColumn = rawget(entry, "column")
            if descriptorColumn ~= "left" and descriptorColumn ~= "right" then
                descriptorColumn = nil
            end
            return descriptorGroup, descriptorColumn
        end
    end
    return entry, nil
end

-- Read the arithmetic height stamped by finishGroup if present, falling
-- back to :GetHeight() for legacy callers that don't go through the
-- DesignSystem primitives. WoW's frame system can return stale or zero
-- :GetHeight() values when a layout pass runs before a paint, which is
-- why finishGroup also stashes the computed value -- using it directly
-- here keeps applyPanelGridLayout deterministic even in the bootstrap
-- frame where the inspector first lays out before the panels reflow.
local function effectiveHeight(group)
    if not group then return 0 end
    local stamped = group._computedHeight
    if type(stamped) == "number" and stamped > 0 then
        return stamped
    end
    if group.GetHeight then
        local measured = group:GetHeight() or 0
        if measured > 0 then
            return measured
        end
    end
    return 0
end
ns.EffectiveGroupHeight = effectiveHeight

local function computeStackHeight(groups)
    if not groups or #groups == 0 then
        return 0
    end
    local sum = 0
    for _, group in ipairs(groups) do
        sum = sum + effectiveHeight(group)
    end
    return sum + ((#groups - 1) * PANEL_GROUP_GAP)
end

local function buildGridRows(groupEntries, isFrameLike)
    local rows = {}
    local hasExplicitRows = false
    for _, entry in ipairs(groupEntries or {}) do
        if type(entry) == "table" and (rawget(entry, "left") ~= nil or rawget(entry, "right") ~= nil) then
            hasExplicitRows = true
            break
        end
    end

    if hasExplicitRows then
        for _, entry in ipairs(groupEntries or {}) do
            if type(entry) == "table" then
                local left = normalizeGridEntry(rawget(entry, "left"))
                local right = normalizeGridEntry(rawget(entry, "right"))
                if isFrameLike(left) or isFrameLike(right) then
                    table.insert(rows, {
                        left = isFrameLike(left) and left or nil,
                        right = isFrameLike(right) and right or nil,
                    })
                end
            end
        end
        return rows
    end

    local pendingLeft = nil
    for _, entry in ipairs(groupEntries or {}) do
        local group, preferredColumn = normalizeGridEntry(entry)
        if isFrameLike(group) then
            if preferredColumn == "right" and pendingLeft then
                table.insert(rows, { left = pendingLeft, right = group })
                pendingLeft = nil
            elseif preferredColumn == "left" then
                if pendingLeft then
                    table.insert(rows, { left = pendingLeft, right = nil })
                end
                pendingLeft = group
            else
                if pendingLeft then
                    table.insert(rows, { left = pendingLeft, right = group })
                    pendingLeft = nil
                else
                    pendingLeft = group
                end
            end
        end
    end
    if pendingLeft then
        table.insert(rows, { left = pendingLeft, right = nil })
    end
    return rows
end

local function flattenRows(rows)
    local groups = {}
    for _, row in ipairs(rows or {}) do
        if row.left then
            table.insert(groups, row.left)
        end
        if row.right then
            table.insert(groups, row.right)
        end
    end
    return groups
end

local function applyPanelGridLayout(layout)
    if not layout or not layout.panel or not layout.topAnchor then
        return
    end
    local function isFrameLike(group)
        if not (group and group.ClearAllPoints and group.SetPoint and group.GetHeight) then
            return false
        end
        -- Skip hidden cards entirely so callers can collapse a row by
        -- toggling group:Show()/group:Hide() without leaving a ghost gap.
        -- (Used by the Shadow Settings card on the Resource Text tab,
        -- which only appears when Outline == "Shadow".)
        if group.IsShown and not group:IsShown() then
            return false
        end
        return true
    end
    local groupEntries = layout.groups or {}
    if #groupEntries == 0 then
        return
    end

    local rows = buildGridRows(groupEntries, isFrameLike)
    local groups = flattenRows(rows)
    if #groups == 0 then
        layout.panel:SetHeight(math.max(layout.panel:GetHeight() or 0, 520))
        return
    end

    -- If every row in this descriptor is full-width (no right-column
    -- card), force the single-column branch regardless of panel width.
    -- The two-column branch's dependence on :GetHeight() to pick the
    -- next row's anchor is unreliable for tightly content-sized flat
    -- sections (it returns 0/stale on the first layout pass, causing
    -- rows to pile on top of each other on the Secondary Resource
    -- tab). The single-column branch reads the arithmetic height
    -- stamped by finishGroup via effectiveHeight(), so it works even
    -- if WoW hasn't reflowed the frame rect yet.
    local hasMultiColumnRow = false
    for _, row in ipairs(rows) do
        if row.left and row.right then
            hasMultiColumnRow = true
            break
        end
    end
    local panelWidth = layout.panel:GetWidth() or 0
    local useTwoColumns = hasMultiColumnRow and panelWidth >= TWO_COLUMN_MIN_WIDTH
    if not useTwoColumns then
        local previousGroup
        for _, group in ipairs(groups) do
            if isFrameLike(group) then
                group:ClearAllPoints()
                if previousGroup then
                    group:SetPoint("TOPLEFT", previousGroup, "BOTTOMLEFT", 0, -PANEL_GROUP_GAP)
                else
                    group:SetPoint("TOPLEFT", layout.topAnchor, "BOTTOMLEFT", 0, layout.topOffset or -8)
                end
                group:SetPoint("TOPRIGHT", layout.panel, "TOPRIGHT", 0, 0)
                previousGroup = group
            end
        end
        local stackHeight = computeStackHeight(groups)
        layout.panel:SetHeight(math.max(stackHeight + (layout.panelPadding or 32), 360))
        return
    end

    -- Two-column mode: walk row-by-row and anchor each card directly to the
    -- previous row's *taller* card. We used to use a 1x1 invisible rowAnchor
    -- frame for this, but its position was unstable (BOTTOMLEFT/BOTTOMRIGHT
    -- with SetSize(1,1) gave WoW's frame system inconsistent rect data) and
    -- caused all rows to collapse to the same Y. Anchoring directly to the
    -- taller card is simpler and reliable.
    local previousAnchorFrame = layout.topAnchor
    local previousAnchorPoint = "BOTTOMLEFT"
    local previousAnchorOffset = layout.topOffset or -8
    local totalHeight = 0
    local rowCount = 0

    for _, row in ipairs(rows) do
        local leftGroup = row.left
        local rightGroup = row.right
        local rowGap = (rowCount > 0) and -PANEL_GROUP_GAP or previousAnchorOffset

        if isFrameLike(leftGroup) then
            leftGroup:ClearAllPoints()
            leftGroup:SetPoint("TOPLEFT", previousAnchorFrame, previousAnchorPoint, 0, rowGap)
            if isFrameLike(rightGroup) then
                leftGroup:SetPoint("TOPRIGHT", layout.panel, "TOP", -(PANEL_GRID_GAP / 2), 0)
            else
                leftGroup:SetPoint("TOPRIGHT", layout.panel, "TOPRIGHT", 0, 0)
            end
        end

        if isFrameLike(rightGroup) then
            rightGroup:ClearAllPoints()
            if isFrameLike(leftGroup) then
                rightGroup:SetPoint("TOPLEFT", leftGroup, "TOPRIGHT", PANEL_GRID_GAP, 0)
                rightGroup:SetPoint("TOPRIGHT", layout.panel, "TOPRIGHT", 0, 0)
            else
                rightGroup:SetPoint("TOPLEFT", previousAnchorFrame, previousAnchorPoint, 0, rowGap)
                rightGroup:SetPoint("TOPRIGHT", layout.panel, "TOPRIGHT", 0, 0)
            end
        end

        -- Pick the taller card as the next row's anchor, falling back to
        -- whichever card exists if only one column is filled. Use the
        -- finishGroup-stamped arithmetic height so this works even when
        -- the frame rect hasn't reflowed yet.
        local nextAnchor = nil
        if isFrameLike(leftGroup) and isFrameLike(rightGroup) then
            local lh = effectiveHeight(leftGroup)
            local rh = effectiveHeight(rightGroup)
            nextAnchor = (lh >= rh) and leftGroup or rightGroup
        elseif isFrameLike(leftGroup) then
            nextAnchor = leftGroup
        elseif isFrameLike(rightGroup) then
            nextAnchor = rightGroup
        end

        local rowHeight = 0
        if isFrameLike(leftGroup) and isFrameLike(rightGroup) then
            rowHeight = math.max(effectiveHeight(leftGroup), effectiveHeight(rightGroup))
        elseif isFrameLike(leftGroup) then
            rowHeight = effectiveHeight(leftGroup)
        elseif isFrameLike(rightGroup) then
            rowHeight = effectiveHeight(rightGroup)
        end

        totalHeight = totalHeight + rowHeight + (rowCount > 0 and PANEL_GROUP_GAP or 0)
        rowCount = rowCount + 1
        if nextAnchor then
            previousAnchorFrame = nextAnchor
            previousAnchorPoint = "BOTTOMLEFT"
        end
    end

    for _, rowFrame in ipairs(layout.rowFrames or {}) do
        rowFrame:Hide()
    end

    local computedHeight = totalHeight

    if computedHeight <= 0 then
        local fallbackPrevious
        for _, group in ipairs(groups) do
            if isFrameLike(group) then
                group:ClearAllPoints()
                if fallbackPrevious then
                    group:SetPoint("TOPLEFT", fallbackPrevious, "BOTTOMLEFT", 0, -PANEL_GROUP_GAP)
                else
                    group:SetPoint("TOPLEFT", layout.topAnchor, "BOTTOMLEFT", 0, layout.topOffset or -8)
                end
                group:SetPoint("TOPRIGHT", layout.panel, "TOPRIGHT", 0, 0)
                fallbackPrevious = group
            end
        end
        computedHeight = computeStackHeight(groups)
    end

    layout.panel:SetHeight(math.max(computedHeight + (layout.panelPadding or 32), 360))
end
ns.ApplyPanelGridLayout = applyPanelGridLayout

local function applySectionCollapsedState(entry)
    if not entry then return end
    -- Two shapes possible: an explicit row { left=group, right=group }
    -- (from rowSplit/rowFull), or a bare group entry (auto-paired).
    -- normalizeGridEntry returns the group from either shape.
    if type(entry) == "table" and (entry.left or entry.right) then
        applySectionCollapsedState(entry.left)
        applySectionCollapsedState(entry.right)
        return
    end
    local g = normalizeGridEntry(entry)
    if g and g.ApplyCollapsedState then
        g:ApplyCollapsedState()
    end
end

local function refreshPanelGridLayouts()
    -- Re-apply collapsed state on every section before computing
    -- positions. This handles the bootstrap case where finishGroup
    -- runs before the saved variables are loaded, and also keeps
    -- collapsed sections sized correctly across profile swaps.
    for _, layout in ipairs(panelGridLayouts) do
        for _, entry in ipairs(layout.groups or {}) do
            applySectionCollapsedState(entry)
        end
    end
    for _, layout in ipairs(panelGridLayouts) do
        applyPanelGridLayout(layout)
    end
end
ns.RefreshPanelGridLayouts = refreshPanelGridLayouts

---------------------------------------------------------------------------
-- Row builders  (semantic sugar over registerPanelGridLayout's row table)
---------------------------------------------------------------------------
-- Keeps tab code readable:
--     RegisterPanelGridLayout(panel, intro, -8, {
--         RowSplit(cardA, cardB),
--         RowSplit(cardC, cardD),
--         RowFull(cardE),
--     })

local function rowSplit(left, right)
    return { left = left, right = right }
end
ns.RowSplit = rowSplit

local function rowFull(card)
    return { left = card }
end
ns.RowFull = rowFull

---------------------------------------------------------------------------
-- Sub-tabs  (REMOVED in the Premium UI Refresh)
---------------------------------------------------------------------------
-- The previous design fragmented Resource Bar and Secondary Resource into 4
-- sub-tabs each, which left 60-70% of every sub-page empty. The refresh
-- consolidates those tabs into single scrollable pages built from the new
-- row.split / row.full grid primitives, so this helper has no remaining
-- call-sites. Intentionally deleted to keep the surface area lean -- if a
-- future tab genuinely needs sub-tabs, reach for tab strips at the top
-- level instead.

---------------------------------------------------------------------------
-- Utility: Close All Dropdown Menus
---------------------------------------------------------------------------

local function closeAllDropdownMenus()
    for _, dd in ipairs(ui.dropdowns or {}) do
        if dd.menu and dd.menu:IsShown() then
            dd.menu:Hide()
        end
    end
    if ui.globalDropdownCloser then
        ui.globalDropdownCloser:Hide()
    end
end
ns.CloseAllDropdownMenus = closeAllDropdownMenus

---------------------------------------------------------------------------
-- Utility: Window Size Computation
---------------------------------------------------------------------------

local function getWindowStateStore()
    local db = ns.db or _G[(ns.SavedVariablesName or (ADDON_NAME:gsub("%s+", "") .. "DB"))]
    if type(db) ~= "table" then
        return nil
    end
    if type(db.window) ~= "table" then
        db.window = {}
    end
    return db.window
end
ns.GetWindowStateStore = getWindowStateStore

local function computeInitialWindowSize()
    -- The inspector layout is mostly a single 340px control column next to
    -- a 200px layer sidebar, so a narrower/taller default wastes less empty
    -- space while showing more settings before scrolling.
    local layoutVersion = 2
    local minWidth = 960
    local minHeight = 640
    local fallbackWidth = 980
    local fallbackHeight = 760
    local screenWidth = (UIParent and UIParent.GetWidth and UIParent:GetWidth()) or 1920
    local screenHeight = (UIParent and UIParent.GetHeight and UIParent:GetHeight()) or 1080
    local maxWidth = roundToInt(math.max(minWidth, math.min(screenWidth - 80, 1040)))
    local maxHeight = roundToInt(math.max(minHeight, math.min(screenHeight - 80, 780)))

    local targetWidth = fallbackWidth
    local targetHeight = fallbackHeight

    local store = getWindowStateStore()
    if store and store.layoutVersion ~= layoutVersion then
        -- Existing installs may have saved the old wide default. Since this
        -- addon has not shipped yet, migrate those saved dimensions once so
        -- users see the new release default without manually clearing SVs.
        store.width = fallbackWidth
        store.height = fallbackHeight
        store.layoutVersion = layoutVersion
    elseif store
        and type(store.width) == "number"
        and type(store.height) == "number"
        and store.width > 0
        and store.height > 0
    then
        targetWidth = roundToInt(store.width)
        targetHeight = roundToInt(store.height)
    end

    targetWidth = math.max(minWidth, math.min(maxWidth, targetWidth))
    targetHeight = math.max(minHeight, math.min(maxHeight, targetHeight))
    return targetWidth, targetHeight
end
ns.ComputeInitialWindowSize = computeInitialWindowSize
