--[[
    LibHLYUI-1.0
    A small, reusable, dependency-light options-UI framework for WoW addons.

    Pattern (declarative-ish, NorthernSky/Nem inspired, but self-contained):

        local LibHLYUI = LibStub("LibHLYUI-1.0")
        local panel = LibHLYUI:CreatePanel({
            name  = "MyAddonOptions",   -- global frame name (unique per addon)
            title = "My Addon",
            width = 760, height = 560,
        })
        local tab = panel:AddTab("General")
        tab:Section("Display")
        tab:Checkbox({ label = "Enable", get = ..., set = ... })
        tab:Slider({ label = "Size", min = 8, max = 24, step = 1, get = ..., set = ... })
        panel:Open()

    Widgets auto-stack vertically; no manual SetPoint needed by the caller.
    panel:Refresh() re-reads every widget's getter (keeps the UI in sync after
    a setter touches related state).
]]

local MAJOR, MINOR = "LibHLYUI-1.0", 1
local lib = LibStub:NewLibrary(MAJOR, MINOR)
if not lib then
    return
end

local CreateFrame = CreateFrame

-- ---------------------------------------------------------------------------
-- Theme
-- ---------------------------------------------------------------------------
-- Neutral base colors + layout metrics. All accent-tinted colors (header, nav,
-- window edge, title bar, slider/checkbox fill, hovers, footer links, close X)
-- are DERIVED from a single accent via lib:SetAccent — so the whole look follows
-- the addon's brand color and the theme can be swapped by changing one value.
lib.Theme = lib.Theme or {
    -- neutral (not accent-derived)
    windowBg   = { 0.05, 0.05, 0.06, 0.88 },
    panelBg    = { 0.09, 0.09, 0.10, 0 },
    panelEdge  = { 0.55, 0.56, 0.60, 0.7 },
    divider    = { 0.70, 0.72, 0.76, 0.4 },
    sidebarBg  = { 0.07, 0.08, 0.08, 0 },
    text       = { 0.86, 0.88, 0.86 },
    textDim    = { 0.58, 0.60, 0.62 },
    navIdle    = { 0.62, 0.64, 0.66, 1 },
    -- accent-derived (filled in by SetAccent below)
    accent     = { 0, 0, 0, 1 },
    header     = { 0, 0, 0, 1 },
    windowEdge = { 0, 0, 0, 1 },
    titleBg    = { 0, 0, 0, 1 },
    navActive  = { 0, 0, 0, 1 },
    navHover   = { 0, 0, 0, 1 },
    navSelBg   = { 0, 0, 0, 1 },
    -- layout metrics
    rowHeight  = 24,
    pad        = 16,
    gap        = 10,
    sectionTop = 14,
    sidebarW   = 150,
}

local function setColor(t, r, g, b, a)
    t[1], t[2], t[3], t[4] = r, g, b, a
end

-- Set the accent (brand) color; all accent-tinted theme entries are recomputed
-- from it. Call before building a panel (or pass opts.accent to CreatePanel).
-- Tables are mutated in place so existing references stay valid.
function lib:SetAccent(r, g, b)
    local T = lib.Theme
    setColor(T.accent,     r, g, b, 1)
    setColor(T.header,     r, g, b, 1)
    setColor(T.navActive,  r, g, b, 1)
    setColor(T.navHover,   r, g, b, 0.12)
    setColor(T.navSelBg,   r, g, b, 0.16)
    setColor(T.windowEdge, r * 0.45, g * 0.45, b * 0.45, 1)
    setColor(T.titleBg,    0.05 + r * 0.05, 0.06 + g * 0.06, 0.05 + b * 0.05, 0.95)
end

-- Default accent (HLYqol green); overridable per addon via lib:SetAccent / opts.accent.
lib:SetAccent(0.16, 0.90, 0.30)

local WHITE = "Interface\\Buttons\\WHITE8x8"
local BACKDROP = {
    bgFile = WHITE,
    edgeFile = WHITE,
    edgeSize = 1,
    insets = { left = 0, right = 0, top = 0, bottom = 0 },
}

local function unpackColor(c, fallbackAlpha)
    return c[1], c[2], c[3], c[4] or fallbackAlpha or 1
end

local function Skin(frame, bg, edge)
    if not frame.SetBackdrop then
        Mixin(frame, BackdropTemplateMixin)
        frame:OnBackdropLoaded()
    end
    frame:SetBackdrop(BACKDROP)
    if bg then frame:SetBackdropColor(unpackColor(bg)) end
    if edge then frame:SetBackdropBorderColor(unpackColor(edge)) end
end

local function FontString(parent, size, color, flags)
    local fs = parent:CreateFontString(nil, "OVERLAY")
    fs:SetFont(STANDARD_TEXT_FONT, size or 12, flags or "")
    if color then fs:SetTextColor(unpackColor(color)) end
    return fs
end

-- Flat scroll area: plain ScrollFrame + a thin rectangular scrollbar (no
-- Blizzard chrome). Fills `parent` (minus insets). Returns scroll, content, update.
local function CreateScrollArea(parent, inset)
    local T = lib.Theme
    inset = inset or 4
    local BARW = 6

    local scroll = CreateFrame("ScrollFrame", nil, parent)
    scroll:SetPoint("TOPLEFT", parent, "TOPLEFT", inset, -inset)
    scroll:SetPoint("BOTTOMRIGHT", parent, "BOTTOMRIGHT", -(inset + BARW + 2), inset)

    local content = CreateFrame("Frame", nil, scroll)
    content:SetSize(1, 1)
    scroll:SetScrollChild(content)

    local bar = CreateFrame("Frame", nil, parent)
    bar:SetWidth(BARW)
    bar:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -inset, -inset)
    bar:SetPoint("BOTTOMRIGHT", parent, "BOTTOMRIGHT", -inset, inset)
    local barTrack = bar:CreateTexture(nil, "BACKGROUND")
    barTrack:SetAllPoints()
    barTrack:SetColorTexture(0, 0, 0, 0.30)

    local thumb = CreateFrame("Button", nil, bar)
    thumb:SetWidth(BARW)
    thumb:SetPoint("TOP", bar, "TOP", 0, 0)
    local thumbTex = thumb:CreateTexture(nil, "ARTWORK")
    thumbTex:SetAllPoints()
    thumbTex:SetColorTexture(T.accent[1], T.accent[2], T.accent[3], 0.55)

    local function metrics()
        local ch = content:GetHeight() or 0
        local vh = scroll:GetHeight() or 1
        return math.max(ch - vh, 0), vh, math.max(ch, 1)
    end

    local function update()
        local maxScroll, vh, ch = metrics()
        if maxScroll <= 0 then
            bar:Hide()
            if scroll:GetVerticalScroll() ~= 0 then scroll:SetVerticalScroll(0) end
            return
        end
        bar:Show()
        local cur = scroll:GetVerticalScroll()
        if cur > maxScroll then cur = maxScroll; scroll:SetVerticalScroll(cur) end
        local barH = bar:GetHeight()
        local thumbH = math.max(20, barH * (vh / ch))
        thumb:SetHeight(thumbH)
        local frac = maxScroll > 0 and (cur / maxScroll) or 0
        thumb:ClearAllPoints()
        thumb:SetPoint("TOP", bar, "TOP", 0, -frac * (barH - thumbH))
    end

    scroll:EnableMouseWheel(true)
    scroll:SetScript("OnMouseWheel", function(_, delta)
        local maxScroll = metrics()
        local nv = math.min(math.max(scroll:GetVerticalScroll() - delta * 30, 0), maxScroll)
        scroll:SetVerticalScroll(nv)
        update()
    end)
    scroll:SetScript("OnSizeChanged", function(_, w)
        content:SetWidth(w)
        update()
    end)

    thumb:SetScript("OnMouseDown", function(self)
        self.dragging = true
        self.startY = select(2, GetCursorPosition()) / UIParent:GetEffectiveScale()
        self.startScroll = scroll:GetVerticalScroll()
    end)
    thumb:SetScript("OnMouseUp", function(self) self.dragging = false end)
    thumb:SetScript("OnUpdate", function(self)
        if not self.dragging then return end
        local y = select(2, GetCursorPosition()) / UIParent:GetEffectiveScale()
        local dy = self.startY - y
        local maxScroll = metrics()
        local travel = bar:GetHeight() - thumb:GetHeight()
        if travel <= 0 then return end
        local nv = math.min(math.max(self.startScroll + dy * (maxScroll / travel), 0), maxScroll)
        scroll:SetVerticalScroll(nv)
        update()
    end)

    return scroll, content, update
end

-- ---------------------------------------------------------------------------
-- LayoutMixin: vertical auto-stacker for a tab's content frame
-- ---------------------------------------------------------------------------
local LayoutMixin = {}
local LayoutMeta = { __index = LayoutMixin }

local function NewLayout(panel, content)
    return setmetatable({
        panel   = panel,
        content = content,
        cursor  = 0,        -- running y offset (negative grows downward)
        widgets = {},       -- { frame, refresh = fn }
    }, LayoutMeta)
end

function LayoutMixin:_Place(frame, height, topGap)
    local T = lib.Theme
    local pad = self.padOverride or T.pad
    self.cursor = self.cursor - (topGap or 0)
    frame:ClearAllPoints()
    frame:SetPoint("TOPLEFT", self.content, "TOPLEFT", pad, self.cursor)
    frame:SetPoint("RIGHT", self.content, "RIGHT", -pad, 0)
    frame:SetHeight(height)
    self.cursor = self.cursor - height - T.gap
    if not self.suppressHeight then
        self.content:SetHeight(math.abs(self.cursor) + pad)
        if self.scrollUpdate then self.scrollUpdate() end
    end
end

function LayoutMixin:_Register(frame, refresh)
    self.widgets[#self.widgets + 1] = { frame = frame, refresh = refresh }
end

function LayoutMixin:Refresh()
    for _, w in ipairs(self.widgets) do
        if w.refresh then w.refresh() end
    end
end

-- Row of 1-3 equal-width columns. `builders` is a list of functions; each
-- receives a cell layout exposing the normal widget methods and fills one column.
-- `columns` (optional) fixes the grid width so partial rows align: e.g. a 3+2
-- layout keeps the 2 entries in the first two of three columns (3rd stays empty).
--     layout:Row({ a, b, c })          -- 3 columns
--     layout:Row({ d, e }, 3)          -- 2 entries on a 3-column grid
function LayoutMixin:Row(builders, columns)
    local T = lib.Theme
    local n = #builders
    if n == 0 then return end
    local cols = math.max(columns or n, n)

    local row = CreateFrame("Frame", nil, self.content)
    local cells = {}
    local maxH = 0

    for i = 1, n do
        local cell = CreateFrame("Frame", nil, row)
        cells[i] = cell
        local cellLayout = setmetatable({
            panel         = self.panel,
            content       = cell,
            cursor        = 0,
            widgets       = self.widgets,   -- share so Refresh covers cell widgets
            padOverride   = 0,
            suppressHeight = true,
        }, LayoutMeta)
        builders[i](cellLayout)
        local h = math.abs(cellLayout.cursor)
        cell._h = h
        if h > maxH then maxH = h end
    end

    local function layoutCells()
        local w = row:GetWidth()
        if w <= 0 then return end
        local gap = T.gap
        local cw = (w - gap * (cols - 1)) / cols
        for i, cell in ipairs(cells) do
            cell:ClearAllPoints()
            cell:SetPoint("TOPLEFT", row, "TOPLEFT", (i - 1) * (cw + gap), 0)
            cell:SetSize(cw, cell._h or T.rowHeight)
        end
    end
    row:SetScript("OnSizeChanged", layoutCells)

    self:_Place(row, maxH, 0)
    layoutCells()
    return row
end

-- Inner sub-tabs (reiter) inside a category's content: a horizontal tab bar with
-- switchable panes. Alternative to listing sub-modules vertically.
--     tab:TabGroup({
--         { name = "Death",   build = function(p) p:Checkbox{...} end },
--         { name = "Trinket", build = function(p) ... end },
--     })
function LayoutMixin:TabGroup(defs)
    local T = lib.Theme
    local n = #defs
    if n == 0 then return end

    -- Tab bar
    local bar = CreateFrame("Frame", nil, self.content)
    self:_Place(bar, 24, self.cursor == 0 and 0 or T.sectionTop)

    local divider = bar:CreateTexture(nil, "ARTWORK")
    divider:SetHeight(1)
    divider:SetPoint("BOTTOMLEFT", bar, "BOTTOMLEFT", 0, 0)
    divider:SetPoint("BOTTOMRIGHT", bar, "BOTTOMRIGHT", 0, 0)
    divider:SetColorTexture(unpackColor(T.panelEdge))

    -- Panes container
    local container = CreateFrame("Frame", nil, self.content)
    local panes, buttons = {}, {}
    local maxH = 0

    for i, def in ipairs(defs) do
        local pane = CreateFrame("Frame", nil, container)
        pane:SetPoint("TOPLEFT", container, "TOPLEFT", 0, 0)
        pane:SetPoint("RIGHT", container, "RIGHT", 0, 0)
        panes[i] = pane
        local sub = setmetatable({
            panel = self.panel, content = pane, cursor = 0,
            widgets = self.widgets, padOverride = 0, suppressHeight = true,
        }, LayoutMeta)
        def.build(sub)
        local h = math.max(math.abs(sub.cursor), 1)
        pane._h = h
        pane:SetHeight(h)
        if h > maxH then maxH = h end
        pane:Hide()
    end

    -- Container's top y in the parent content (negative, grows downward).
    local pad = self.padOverride or T.pad
    local containerTop = self.cursor - 6

    container._active = 1
    container._previewShown = false

    local function selectSub(idx)
        -- Per-pane live previews: hide the old pane's preview, show the new one
        -- (only while this TabGroup's previews are active / page is open).
        if container._previewShown and idx ~= container._active then
            local oldDef = defs[container._active]
            if oldDef and oldDef.onHide then oldDef.onHide() end
            local newDef = defs[idx]
            if newDef and newDef.onShow then newDef.onShow() end
        end
        container._active = idx

        for i = 1, n do
            panes[i]:SetShown(i == idx)
            local b = buttons[i]
            b.underline:SetShown(i == idx)
            b.text:SetTextColor(unpackColor(i == idx and T.navActive or T.navIdle))
        end
        -- Resize container + parent content to the ACTIVE pane so the scrollbar
        -- only appears when the active tab actually overflows.
        local h = panes[idx]._h or 1
        container:SetHeight(h)
        if not self.suppressHeight then
            self.content:SetHeight(math.abs(containerTop) + h + pad)
            if self.scrollUpdate then self.scrollUpdate() end
        end
    end

    -- External hooks so the host (sidebar entry) can drive previews on show/hide.
    container.showActivePreview = function()
        container._previewShown = true
        local def = defs[container._active]
        if def and def.onShow then def.onShow() end
    end
    container.hideActivePreview = function()
        if not container._previewShown then return end
        container._previewShown = false
        local def = defs[container._active]
        if def and def.onHide then def.onHide() end
    end

    for i, def in ipairs(defs) do
        local b = CreateFrame("Button", nil, bar)
        b:SetHeight(24)
        local text = FontString(b, 12, T.navIdle)
        text:SetPoint("LEFT", 8, 0)
        text:SetPoint("RIGHT", -8, 0)
        text:SetJustifyH("CENTER")
        text:SetText(def.name or ("Tab " .. i))
        b:SetWidth(text:GetStringWidth() + 22)
        b.text = text

        local underline = b:CreateTexture(nil, "ARTWORK")
        underline:SetHeight(2)
        underline:SetPoint("BOTTOMLEFT", b, "BOTTOMLEFT", 4, 0)
        underline:SetPoint("BOTTOMRIGHT", b, "BOTTOMRIGHT", -4, 0)
        underline:SetColorTexture(unpackColor(T.accent))
        underline:Hide()
        b.underline = underline

        if i == 1 then
            b:SetPoint("LEFT", bar, "LEFT", 0, 0)
        else
            b:SetPoint("LEFT", buttons[i - 1], "RIGHT", 6, 0)
        end
        b:SetScript("OnClick", function() selectSub(i) end)
        buttons[i] = b
    end

    -- Place container at its start height (active pane resizes it in selectSub).
    self:_Place(container, panes[1]._h or maxH, 6)
    selectSub(1)
    return container
end

-- ---------------------------------------------------------------------------
-- Widgets (attached to LayoutMixin)
-- ---------------------------------------------------------------------------
function LayoutMixin:Section(text)
    local T = lib.Theme
    local f = CreateFrame("Frame", nil, self.content)

    -- Thin vertical accent bar before the left-aligned title (section marker).
    local marker = f:CreateTexture(nil, "ARTWORK")
    marker:SetSize(3, 14)
    marker:SetColorTexture(T.accent[1], T.accent[2], T.accent[3], 1)
    marker:SetPoint("LEFT", f, "LEFT", 0, 0)

    local fs = FontString(f, 13, T.header, "OUTLINE")
    fs:SetPoint("LEFT", marker, "RIGHT", 8, 0)
    fs:SetText(text or "")

    self:_Place(f, 18, self.cursor == 0 and 0 or T.sectionTop)
    self:_Register(f)
    return f
end

function LayoutMixin:Note(text)
    local T = lib.Theme
    local f = CreateFrame("Frame", nil, self.content)

    -- Info icon, accent-tinted, at the left.
    local icon = f:CreateTexture(nil, "ARTWORK")
    icon:SetTexture("Interface\\FriendsFrame\\InformationIcon")
    icon:SetVertexColor(T.accent[1], T.accent[2], T.accent[3], 1)
    icon:SetSize(12, 12)
    icon:SetPoint("TOPLEFT", f, "TOPLEFT", 0, -1)

    local fs = FontString(f, 11, T.textDim)
    fs:SetPoint("TOPLEFT", icon, "TOPRIGHT", 6, 1)
    fs:SetPoint("RIGHT", f, "RIGHT", 0, 0)
    fs:SetJustifyH("LEFT")
    fs:SetSpacing(2)
    fs:SetText(text or "")

    local h = math.max(14, fs:GetStringHeight() + 2)
    self:_Place(f, h, 0)
    -- Extra breathing room before the options that follow.
    self.cursor = self.cursor - 14
    self:_Register(f)
    return f
end

-- Flat horizontal bar: label, a proportional fill (value/max), and a value text.
-- opts: { label, value, max, valueText, color = {r,g,b} | {[1],[2],[3]}, labelWidth, valueWidth }
function LayoutMixin:Bar(opts)
    local T = lib.Theme
    local f = CreateFrame("Frame", nil, self.content)
    local LBLW = opts.labelWidth or 120
    local VALW = opts.valueWidth or 96

    local label = FontString(f, 12, T.text)
    label:SetPoint("LEFT", f, "LEFT", 0, 0)
    label:SetWidth(LBLW)
    label:SetJustifyH("LEFT")
    label:SetWordWrap(false)
    label:SetText(opts.label or "")

    local valueText = FontString(f, 11, T.textDim)
    valueText:SetPoint("RIGHT", f, "RIGHT", -2, 0)
    valueText:SetWidth(VALW)
    valueText:SetJustifyH("RIGHT")
    valueText:SetText(opts.valueText or "")

    local track = CreateFrame("Frame", nil, f)
    track:SetPoint("LEFT", label, "RIGHT", 8, 0)
    track:SetPoint("RIGHT", valueText, "LEFT", -8, 0)
    track:SetHeight(10)
    local trackBg = track:CreateTexture(nil, "BACKGROUND")
    trackBg:SetAllPoints()
    trackBg:SetColorTexture(0, 0, 0, 0.40)

    local c = opts.color
    local cr = c and (c[1] or c.r) or T.accent[1]
    local cg = c and (c[2] or c.g) or T.accent[2]
    local cb = c and (c[3] or c.b) or T.accent[3]

    local fill = track:CreateTexture(nil, "ARTWORK")
    fill:SetPoint("LEFT", track, "LEFT", 0, 0)
    fill:SetHeight(10)
    fill:SetColorTexture(cr, cg, cb, 0.85)

    local frac = (opts.max and opts.max > 0) and math.min((opts.value or 0) / opts.max, 1) or 0
    local function sizeFill()
        local w = track:GetWidth() or 0
        fill:SetWidth(math.max(w * frac, 0.001))
    end
    track:SetScript("OnSizeChanged", sizeFill)
    sizeFill()

    self:_Place(f, 18, 0)
    self:_Register(f)
    return f
end

function LayoutMixin:Checkbox(opts)
    local T = lib.Theme
    local f = CreateFrame("Frame", nil, self.content)

    -- Flat toggle: an empty square that fills solid when on.
    local box = CreateFrame("Button", nil, f, "BackdropTemplate")
    box:SetSize(16, 16)
    box:SetPoint("LEFT", f, "LEFT", 1, 0)
    Skin(box, { 0, 0, 0, 0 }, T.panelEdge)

    local fill = box:CreateTexture(nil, "ARTWORK")
    fill:SetAllPoints(box)
    fill:SetColorTexture(unpackColor(T.accent))
    fill:Hide()

    local hover = box:CreateTexture(nil, "HIGHLIGHT")
    hover:SetAllPoints()
    hover:SetColorTexture(unpackColor(T.navHover))

    local label = FontString(f, 12, T.text)
    label:SetPoint("LEFT", box, "RIGHT", 8, 0)
    label:SetText(opts.label or "")

    -- On: solid green, no border. Off: hollow with a white border.
    local function setShown(v)
        if v then
            fill:Show()
            box:SetBackdropBorderColor(0, 0, 0, 0)
        else
            fill:Hide()
            box:SetBackdropBorderColor(unpackColor(T.panelEdge))
        end
    end

    box:SetScript("OnClick", function()
        local v = not (opts.get and opts.get())
        if opts.set then opts.set(v) end
        setShown(v)
        if opts.onChange then opts.onChange(v) end
    end)

    if opts.tooltip then
        box:SetScript("OnEnter", function(self)
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            GameTooltip:SetText(opts.tooltip, nil, nil, nil, nil, true)
            GameTooltip:Show()
        end)
        box:SetScript("OnLeave", GameTooltip_Hide)
    end

    local function refresh()
        if opts.get then setShown(opts.get()) end
    end
    refresh()

    self:_Place(f, T.rowHeight, 0)
    self:_Register(f, refresh)
    return f
end

function LayoutMixin:Slider(opts)
    local T = lib.Theme
    local f = CreateFrame("Frame", nil, self.content)

    local minV = opts.min or 0
    local maxV = opts.max or 100
    local step = opts.step or 1
    local LBLW = opts.labelWidth or 150
    local VALW = 46

    -- Single line: label | slider | value
    local label = FontString(f, 12, T.text)
    label:SetPoint("LEFT", f, "LEFT", 0, 0)
    label:SetWidth(LBLW)
    label:SetJustifyH("LEFT")
    label:SetWordWrap(false)
    label:SetText(opts.label or "")

    local function fmt(v)
        if opts.format then return opts.format(v) end
        if step < 1 then return string.format("%.1f", v) end
        return tostring(math.floor(v + 0.5))
    end

    -- Flat editable value box (right).
    local valueBox = CreateFrame("EditBox", nil, f, "BackdropTemplate")
    valueBox:SetAutoFocus(false)
    valueBox:SetSize(VALW, 18)
    valueBox:SetJustifyH("CENTER")
    valueBox:SetFontObject(ChatFontNormal)
    valueBox:SetTextInsets(2, 2, 0, 0)
    valueBox:SetPoint("RIGHT", f, "RIGHT", -2, 0)
    Skin(valueBox, { 0, 0, 0, 0.30 }, T.panelEdge)

    local slider = CreateFrame("Slider", nil, f)
    slider:SetOrientation("HORIZONTAL")
    slider:SetPoint("LEFT", label, "RIGHT", 10, 0)
    slider:SetPoint("RIGHT", valueBox, "LEFT", -10, 0)
    slider:SetHeight(12)
    slider:SetMinMaxValues(minV, maxV)
    slider:SetValueStep(step)
    slider:SetObeyStepOnDrag(true)

    -- Flat rectangles: dark track, green fill up to the thumb, green thumb.
    local track = slider:CreateTexture(nil, "BACKGROUND")
    track:SetPoint("LEFT", slider, "LEFT", 0, 0)
    track:SetPoint("RIGHT", slider, "RIGHT", 0, 0)
    track:SetHeight(4)
    track:SetColorTexture(0, 0, 0, 0.45)

    local thumb = slider:CreateTexture(nil, "ARTWORK")
    thumb:SetSize(6, 12)
    thumb:SetColorTexture(unpackColor(T.accent))
    slider:SetThumbTexture(thumb)

    local fill = slider:CreateTexture(nil, "BORDER")
    fill:SetPoint("LEFT", track, "LEFT", 0, 0)
    fill:SetPoint("RIGHT", thumb, "CENTER", 0, 0)
    fill:SetHeight(4)
    fill:SetColorTexture(T.accent[1], T.accent[2], T.accent[3], 0.55)

    slider:SetScript("OnValueChanged", function(_, value, userInput)
        valueBox:SetText(fmt(value))
        if userInput and opts.set then
            opts.set(value)
            if opts.onChange then opts.onChange(value) end
        end
    end)

    valueBox:SetScript("OnEnterPressed", function(self)
        local v = tonumber(self:GetText())
        if v then
            if v < minV then v = minV elseif v > maxV then v = maxV end
            slider:SetValue(v)
            if opts.set then opts.set(v) end
            if opts.onChange then opts.onChange(v) end
        end
        self:ClearFocus()
    end)
    valueBox:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)

    local function refresh()
        if opts.get then
            local v = opts.get()
            slider:SetValue(v)
            valueBox:SetText(fmt(v))
        end
    end
    refresh()

    self:_Place(f, T.rowHeight, 0)
    self:_Register(f, refresh)
    return f
end

function LayoutMixin:Button(opts)
    local T = lib.Theme
    local f = CreateFrame("Frame", nil, self.content)
    local btn = CreateFrame("Button", nil, f, "BackdropTemplate")
    btn:SetSize(opts.width or 150, 24)
    btn:SetPoint("LEFT", f, "LEFT", 0, 0)
    Skin(btn, { 0, 0, 0, 0 }, T.panelEdge)

    -- Accent-tinted vertical gradient (darker bottom -> lighter top) for a raised look.
    local a = T.accent
    local function shade(f) return CreateColor(a[1] * f + 0.05, a[2] * f + 0.05, a[3] * f + 0.05, 0.95) end
    local grad = btn:CreateTexture(nil, "BACKGROUND")
    grad:SetPoint("TOPLEFT", 1, -1)
    grad:SetPoint("BOTTOMRIGHT", -1, 1)
    grad:SetColorTexture(1, 1, 1, 1)
    grad:SetGradient("VERTICAL", shade(0.14), shade(0.34))

    -- Top highlight line (accent) for a subtle bevel.
    local topLine = btn:CreateTexture(nil, "BORDER")
    topLine:SetPoint("TOPLEFT", 1, -1)
    topLine:SetPoint("TOPRIGHT", -1, -1)
    topLine:SetHeight(1)
    topLine:SetColorTexture(a[1], a[2], a[3], 0.20)

    -- Accent border.
    btn:SetBackdropBorderColor(a[1], a[2], a[3], 0.55)

    local hover = btn:CreateTexture(nil, "HIGHLIGHT")
    hover:SetAllPoints()
    hover:SetColorTexture(a[1], a[2], a[3], 0.22)

    local text = FontString(btn, 12, T.text)
    text:SetPoint("CENTER", 0, 0)
    text:SetText(opts.label or "")

    btn:SetScript("OnMouseDown", function()
        text:SetPoint("CENTER", 0, -1)
        grad:SetGradient("VERTICAL", shade(0.10), shade(0.22))
    end)
    btn:SetScript("OnMouseUp", function()
        text:SetPoint("CENTER", 0, 0)
        grad:SetGradient("VERTICAL", shade(0.14), shade(0.34))
    end)
    btn:SetScript("OnClick", function()
        if opts.func then opts.func() end
    end)
    self:_Place(f, T.rowHeight, 0)
    self:_Register(f)
    return f
end

-- EditBox -------------------------------------------------------------------
function LayoutMixin:EditBox(opts)
    local T = lib.Theme
    local f = CreateFrame("Frame", nil, self.content)
    local LBLW = opts.labelWidth or 150

    local label = FontString(f, 12, T.text)
    label:SetPoint("LEFT", f, "LEFT", 0, 0)
    label:SetWidth(LBLW)
    label:SetJustifyH("LEFT")
    label:SetWordWrap(false)
    label:SetText(opts.label or "")

    local edit = CreateFrame("EditBox", nil, f, "BackdropTemplate")
    edit:SetAutoFocus(false)
    edit:SetHeight(20)
    edit:SetPoint("LEFT", label, "RIGHT", 10, 0)
    edit:SetPoint("RIGHT", f, "RIGHT", -2, 0)
    edit:SetFontObject(ChatFontNormal)
    edit:SetTextInsets(4, 4, 0, 0)
    Skin(edit, { 0, 0, 0, 0.30 }, T.panelEdge)

    local function commit()
        if opts.set then opts.set(edit:GetText()) end
        edit:ClearFocus()
        if opts.onChange then opts.onChange(edit:GetText()) end
    end
    edit:SetScript("OnEnterPressed", commit)
    edit:SetScript("OnEscapePressed", function() edit:ClearFocus() end)

    local function refresh()
        if opts.get then edit:SetText(opts.get() or "") end
    end
    refresh()

    self:_Place(f, T.rowHeight, 0)
    self:_Register(f, refresh)
    return f
end

-- Color swatch --------------------------------------------------------------
function LayoutMixin:Color(opts)
    local T = lib.Theme
    local f = CreateFrame("Frame", nil, self.content)
    local LBLW = opts.labelWidth or 150

    local label = FontString(f, 12, T.text)
    label:SetPoint("LEFT", f, "LEFT", 0, 0)
    label:SetWidth(LBLW)
    label:SetJustifyH("LEFT")
    label:SetWordWrap(false)
    label:SetText(opts.label or "")

    local swatch = CreateFrame("Button", nil, f, "BackdropTemplate")
    swatch:SetSize(28, 16)
    swatch:SetPoint("LEFT", label, "RIGHT", 10, 0)
    Skin(swatch, { 1, 1, 1, 1 }, T.panelEdge)

    local function refresh()
        if opts.get then
            local r, g, b = opts.get()
            swatch:SetBackdropColor(r or 1, g or 1, b or 1, 1)
        end
    end

    swatch:SetScript("OnClick", function()
        local r, g, b = opts.get()
        local function apply()
            local nr, ng, nb = ColorPickerFrame:GetColorRGB()
            if opts.set then opts.set(nr, ng, nb) end
            refresh()
            if opts.onChange then opts.onChange(nr, ng, nb) end
        end
        local info = {
            r = r, g = g, b = b,
            swatchFunc = apply,
            cancelFunc = function(prev)
                if prev and opts.set then opts.set(prev.r, prev.g, prev.b) end
                refresh()
            end,
        }
        if ColorPickerFrame.SetupColorPickerAndShow then
            ColorPickerFrame:SetupColorPickerAndShow(info)
        else
            ColorPickerFrame.func = apply
            ColorPickerFrame.cancelFunc = info.cancelFunc
            ColorPickerFrame:SetColorRGB(r, g, b)
            ColorPickerFrame:Show()
        end
    end)

    refresh()
    self:_Place(f, T.rowHeight, 0)
    self:_Register(f, refresh)
    return f
end

-- Dropdown ------------------------------------------------------------------
local dropdownPopup, dropdownCloser

local function HideDropdownPopup()
    if dropdownPopup then dropdownPopup:Hide() end
    if dropdownCloser then dropdownCloser:Hide() end
end

local DROPDOWN_MAX_HEIGHT = 280
local DROPDOWN_ROW_HEIGHT = 18

local function GetDropdownPopup()
    if dropdownPopup then return dropdownPopup end
    local T = lib.Theme

    dropdownCloser = CreateFrame("Button", nil, UIParent)
    dropdownCloser:SetFrameStrata("TOOLTIP")
    dropdownCloser:SetAllPoints(UIParent)
    dropdownCloser:SetFrameLevel(1)
    dropdownCloser:Hide()
    dropdownCloser:SetScript("OnClick", HideDropdownPopup)

    local p = CreateFrame("Frame", "LibHLYUIDropdownPopup", UIParent, "BackdropTemplate")
    p:SetFrameStrata("TOOLTIP")
    p:SetFrameLevel(10)
    Skin(p, T.windowBg, T.windowEdge)
    p:Hide()
    p.items = {}

    local scroll, content, scrollUpdate = CreateScrollArea(p, 4)
    p.scroll = scroll
    p.content = content
    p.scrollUpdate = scrollUpdate

    dropdownPopup = p
    return p
end

local function NormalizeValues(values, order)
    if type(values) == "function" then values = values() end
    local list = {}
    if type(values) ~= "table" then return list end
    if values[1] ~= nil then
        for _, it in ipairs(values) do
            if type(it) == "table" then
                list[#list + 1] = { value = it.value, text = it.text or tostring(it.value) }
            else
                list[#list + 1] = { value = it, text = tostring(it) }
            end
        end
    elseif order then
        for _, v in ipairs(order) do
            list[#list + 1] = { value = v, text = values[v] or tostring(v) }
        end
    else
        for k, v in pairs(values) do
            list[#list + 1] = { value = k, text = v }
        end
        table.sort(list, function(a, b) return tostring(a.text) < tostring(b.text) end)
    end
    return list
end

function LayoutMixin:Dropdown(opts)
    local T = lib.Theme
    local f = CreateFrame("Frame", nil, self.content)
    local LBLW = opts.labelWidth or 150

    local label = FontString(f, 12, T.text)
    label:SetPoint("LEFT", f, "LEFT", 0, 0)
    label:SetWidth(LBLW)
    label:SetJustifyH("LEFT")
    label:SetWordWrap(false)
    label:SetText(opts.label or "")

    local button = CreateFrame("Button", nil, f, "BackdropTemplate")
    button:SetHeight(20)
    button:SetPoint("LEFT", label, "RIGHT", 10, 0)
    button:SetPoint("RIGHT", f, "RIGHT", -2, 0)
    Skin(button, { 0, 0, 0, 0.30 }, T.panelEdge)

    local current = FontString(button, 12, T.text)
    current:SetPoint("RIGHT", -22, 0)
    current:SetJustifyH("LEFT")

    local arrow = FontString(button, 12, T.textDim)
    arrow:SetPoint("RIGHT", -8, 0)
    arrow:SetText("v")

    -- Optional font preview: render each entry (and the current value) in its
    -- own typeface via LibSharedMedia.
    local LSM = opts.fontPreview and LibStub and LibStub("LibSharedMedia-3.0", true) or nil
    local function applyFontPreview(fs, value)
        if not LSM then return end
        local path = LSM:Fetch("font", value, true)
        if path then
            local _, size, flags = fs:GetFont()
            fs:SetFont(path, size or 12, flags or "")
        end
    end

    -- Optional sound preview: a speaker button (left) per entry and on the
    -- selected value that plays the LSM sound.
    local LSM_SOUND = opts.soundPreview and LibStub and LibStub("LibSharedMedia-3.0", true) or nil
    local function playPreview(value)
        if not LSM_SOUND then return end
        local path = LSM_SOUND:Fetch("sound", value, true)
        if path then PlaySoundFile(path, "Master") end
    end

    if LSM_SOUND then
        local play = CreateFrame("Button", nil, button)
        play:SetSize(14, 14)
        play:SetPoint("LEFT", button, "LEFT", 6, 0)
        local icon = play:CreateTexture(nil, "ARTWORK")
        icon:SetAllPoints()
        icon:SetAtlas("voicechat-icon-speaker")
        icon:SetVertexColor(T.accent[1], T.accent[2], T.accent[3], 1)
        play:SetScript("OnClick", function() playPreview(opts.get and opts.get()) end)
        current:SetPoint("LEFT", play, "RIGHT", 6, 0)
    else
        current:SetPoint("LEFT", button, "LEFT", 8, 0)
    end

    local function currentText()
        local v = opts.get and opts.get()
        for _, item in ipairs(NormalizeValues(opts.values, opts.order)) do
            if item.value == v then return item.text end
        end
        return v ~= nil and tostring(v) or ""
    end

    local function refresh()
        current:SetText(currentText())
        applyFontPreview(current, opts.get and opts.get())
    end

    button:SetScript("OnClick", function(self)
        local popup = GetDropdownPopup()
        if popup:IsShown() and popup.owner == self then
            HideDropdownPopup()
            return
        end
        popup.owner = self

        for _, it in ipairs(popup.items) do it:Hide() end

        local list = NormalizeValues(opts.values, opts.order)
        local content = popup.content
        local y = 0
        local width = math.max(self:GetWidth(), 120)
        for i, item in ipairs(list) do
            local row = popup.items[i]
            if not row then
                row = CreateFrame("Button", nil, content)
                row:SetHeight(DROPDOWN_ROW_HEIGHT)
                row.text = FontString(row, 12, T.text)
                row.text:SetJustifyH("LEFT")
                local hl = row:CreateTexture(nil, "HIGHLIGHT")
                hl:SetAllPoints()
                hl:SetColorTexture(unpackColor(T.accent))
                hl:SetAlpha(0.25)
                -- Always create the speaker (shared popup is reused across dropdowns);
                -- visibility is toggled per open below.
                local play = CreateFrame("Button", nil, row)
                play:SetSize(14, 14)
                play:SetPoint("LEFT", row, "LEFT", 6, 0)
                local icon = play:CreateTexture(nil, "ARTWORK")
                icon:SetAllPoints()
                icon:SetAtlas("voicechat-icon-speaker")
                icon:SetVertexColor(T.accent[1], T.accent[2], T.accent[3], 1)
                row.play = play
                popup.items[i] = row
            end
            row:ClearAllPoints()
            row:SetPoint("TOPLEFT", content, "TOPLEFT", 0, y)
            row:SetPoint("TOPRIGHT", content, "TOPRIGHT", 0, y)
            row.text:SetFont(STANDARD_TEXT_FONT, 12, "")
            row.text:ClearAllPoints()
            row.text:SetPoint("RIGHT", row, "RIGHT", -8, 0)
            if LSM_SOUND then
                row.play:Show()
                row.play:SetScript("OnClick", function() playPreview(item.value) end)
                row.text:SetPoint("LEFT", row.play, "RIGHT", 6, 0)
            else
                row.play:Hide()
                row.text:SetPoint("LEFT", row, "LEFT", 8, 0)
            end
            row.text:SetText(item.text)
            applyFontPreview(row.text, item.value)
            row:SetScript("OnClick", function()
                if opts.set then opts.set(item.value) end
                refresh()
                HideDropdownPopup()
                if opts.onChange then opts.onChange(item.value) end
            end)
            row:Show()
            y = y - DROPDOWN_ROW_HEIGHT
        end

        local total = #list * DROPDOWN_ROW_HEIGHT
        content:SetHeight(math.max(total, 1))
        popup:SetWidth(width + 16)
        popup:SetHeight(math.min(total + 8, DROPDOWN_MAX_HEIGHT))
        popup:ClearAllPoints()
        popup:SetPoint("TOPLEFT", self, "BOTTOMLEFT", 0, -2)
        popup.scroll:SetVerticalScroll(0)
        if popup.scrollUpdate then popup.scrollUpdate() end
        dropdownCloser:Show()
        popup:Show()
    end)

    refresh()
    self:_Place(f, T.rowHeight, 0)
    self:_Register(f, refresh)
    return f
end

-- ---------------------------------------------------------------------------
-- PanelMixin
-- ---------------------------------------------------------------------------
local PanelMixin = {}
local PanelMeta = { __index = PanelMixin }

-- Non-clickable group label in the sidebar; categories added after it appear
-- beneath it. `sub=true` renders a smaller, indented sub-heading (second level).
function PanelMixin:AddSidebarHeader(label, sub)
    local T = lib.Theme
    local host = self.sidebarContent

    local h = CreateFrame("Frame", nil, host)
    h:SetHeight(16)
    self.sidebarCursor = self.sidebarCursor - (self.sidebarCursor == 0 and 6 or (sub and 8 or 12))
    h:SetPoint("TOPLEFT", host, "TOPLEFT", 0, self.sidebarCursor)
    h:SetPoint("RIGHT", host, "RIGHT", 0, 0)

    local fs = FontString(h, sub and 10 or 12, sub and T.textDim or T.accent, "OUTLINE")
    fs:SetPoint("LEFT", h, "LEFT", sub and 14 or 6, 0)
    fs:SetJustifyH("LEFT")
    fs:SetText((label or ""):upper())

    self.sidebarCursor = self.sidebarCursor - (sub and 16 or 18)
    host:SetHeight(math.abs(self.sidebarCursor) + 4)
    if self.sidebarUpdate then self.sidebarUpdate() end
    return h
end

-- AddCategory(label[, hidden[, headerStyle]]) — hidden: no sidebar entry (reachable
-- programmatically). headerStyle: a clickable top-level entry styled like a group
-- header (accent, uppercase, top gap) — for a consolidated group shown as one entry.
function PanelMixin:AddCategory(label, hidden, headerStyle)
    local T = lib.Theme
    local index = #self.tabs + 1

    local btn
    if not hidden then
        local host = self.sidebarContent
        btn = CreateFrame("Button", nil, host)
        btn:SetHeight(headerStyle and 20 or 24)
        if self.sidebarCursor == 0 then self.sidebarCursor = -4 end
        if headerStyle then self.sidebarCursor = self.sidebarCursor - 8 end
        btn:SetPoint("TOPLEFT", host, "TOPLEFT", 0, self.sidebarCursor)
        btn:SetPoint("RIGHT", host, "RIGHT", 0, 0)
        self.sidebarCursor = self.sidebarCursor - (headerStyle and 22 or 26)
        host:SetHeight(math.abs(self.sidebarCursor) + 4)
        if self.sidebarUpdate then self.sidebarUpdate() end

        local selBg = btn:CreateTexture(nil, "BACKGROUND")
        selBg:SetAllPoints()
        selBg:SetColorTexture(unpackColor(T.navSelBg))
        selBg:Hide()
        btn.selBg = selBg

        local selBar = btn:CreateTexture(nil, "ARTWORK")
        selBar:SetWidth(3)
        selBar:SetPoint("TOPLEFT", btn, "TOPLEFT", 0, 0)
        selBar:SetPoint("BOTTOMLEFT", btn, "BOTTOMLEFT", 0, 0)
        selBar:SetColorTexture(unpackColor(T.accent))
        selBar:Hide()
        btn.selBar = selBar

        local hover = btn:CreateTexture(nil, "HIGHLIGHT")
        hover:SetAllPoints()
        hover:SetColorTexture(unpackColor(T.navHover))

        local btnText = FontString(btn, headerStyle and 12 or 12, headerStyle and T.accent or T.navIdle, "OUTLINE")
        btnText:SetPoint("LEFT", btn, "LEFT", headerStyle and 6 or 18, 0)
        btnText:SetJustifyH("LEFT")
        btnText:SetText(headerStyle and (label or ""):upper() or (label or ("Category " .. index)))
        btn.text = btnText
        btn.headerStyle = headerStyle
    end

    -- Scroll + content (right body), flat scrollbar
    local scrollHost = CreateFrame("Frame", nil, self.body)
    scrollHost:SetAllPoints(self.body)
    scrollHost:Hide()
    local scroll, content, scrollUpdate = CreateScrollArea(scrollHost, 4)

    local layout = NewLayout(self, content)
    layout.scrollUpdate = scrollUpdate
    local tab = { button = btn, scroll = scrollHost, content = content, layout = layout, scrollUpdate = scrollUpdate }
    self.tabs[index] = tab

    if btn then
        btn:SetScript("OnClick", function()
            self:SelectTab(index)
        end)
    end

    if not self.activeTab and not hidden then
        self:SelectTab(index)
    end

    return layout
end

-- Backwards-compatible alias.
function PanelMixin:AddTab(label, hidden, headerStyle)
    return self:AddCategory(label, hidden, headerStyle)
end

function PanelMixin:SelectTab(index)
    local T = lib.Theme
    for i, tab in ipairs(self.tabs) do
        local active = (i == index)
        tab.scroll:SetShown(active)
        if active and tab.scrollUpdate then tab.scrollUpdate() end
        if tab.button then
            tab.button.selBg:SetShown(active)
            tab.button.selBar:SetShown(active)
            local idle = tab.button.headerStyle and T.accent or T.navIdle
            tab.button.text:SetTextColor(unpackColor(active and T.navActive or idle))
        end
    end
    self.activeTab = index
    if self.onTabChanged then self.onTabChanged(index) end
end

function PanelMixin:Refresh()
    for _, tab in ipairs(self.tabs) do
        tab.layout:Refresh()
    end
end

-- Footer ----------------------------------------------------------------
function PanelMixin:SetFooterRight(text)
    self.footerRight:SetText(text or "")
end

function PanelMixin:AddFooterLink(label, onClick)
    local T = lib.Theme

    -- Pipe separator before every link after the first.
    if self.footerX > 12 then
        local sep = FontString(self.footer, 11, T.textDim)
        sep:SetPoint("LEFT", self.footer, "LEFT", self.footerX, 0)
        sep:SetText("|")
        self.footerX = self.footerX + (sep:GetStringWidth() + 8)
    end

    local btn = CreateFrame("Button", nil, self.footer)
    btn:SetHeight(20)

    local fs = FontString(btn, 11, T.accent)
    fs:SetPoint("LEFT", 0, 0)
    fs:SetText(label or "")
    btn:SetWidth(fs:GetStringWidth() + 4)
    btn.text = fs

    btn:SetPoint("LEFT", self.footer, "LEFT", self.footerX, 0)
    self.footerX = self.footerX + (fs:GetStringWidth() + 12)

    btn:SetScript("OnEnter", function() fs:SetTextColor(unpackColor(T.header)) end)
    btn:SetScript("OnLeave", function() fs:SetTextColor(unpackColor(T.accent)) end)
    btn:SetScript("OnClick", function()
        if onClick then onClick(self) end
    end)
    return btn
end

function PanelMixin:Open()
    self.frame:Show()
    self:Refresh()
end

function PanelMixin:Close()
    self.frame:Hide()
end

function PanelMixin:Toggle()
    if self.frame:IsShown() then
        self:Close()
    else
        self:Open()
    end
end

function PanelMixin:IsShown()
    return self.frame:IsShown()
end

-- ---------------------------------------------------------------------------
-- Optional helpers
-- ---------------------------------------------------------------------------
-- Build a Dropdown `values` list from LibSharedMedia (e.g. "font", "sound").
-- Returns an array of { value = name, text = name }. Empty if LSM is absent.
function lib:GetMediaValues(mediaType)
    local LSM = LibStub and LibStub("LibSharedMedia-3.0", true)
    local list = {}
    if not LSM then return list end
    for _, name in ipairs(LSM:List(mediaType)) do
        list[#list + 1] = { value = name, text = name }
    end
    return list
end

-- ---------------------------------------------------------------------------
-- Panel factory
-- ---------------------------------------------------------------------------
function lib:CreatePanel(opts)
    opts = opts or {}
    if opts.accent then
        lib:SetAccent(opts.accent[1], opts.accent[2], opts.accent[3])
    end
    local T = lib.Theme
    local width = opts.width or 760
    local height = opts.height or 560

    local root = CreateFrame("Frame", opts.name, UIParent, "BackdropTemplate")
    root:SetSize(width, height)
    root:SetPoint("CENTER")
    root:SetFrameStrata("FULLSCREEN_DIALOG")
    root:SetToplevel(true)
    root:EnableMouse(true)
    root:SetMovable(true)
    root:SetClampedToScreen(true)
    root:RegisterForDrag("LeftButton")
    root:SetScript("OnDragStart", root.StartMoving)
    root:SetScript("OnDragStop", root.StopMovingOrSizing)
    root:Hide()
    tinsert(UISpecialFrames, opts.name)  -- ESC closes it
    Skin(root, T.windowBg, T.windowEdge)

    -- Title bar
    local titleBar = CreateFrame("Frame", nil, root, "BackdropTemplate")
    titleBar:SetPoint("TOPLEFT", 0, 0)
    titleBar:SetPoint("TOPRIGHT", 0, 0)
    titleBar:SetHeight(30)
    Skin(titleBar, T.titleBg, T.windowEdge)

    local title = FontString(titleBar, 14, T.text, "OUTLINE")
    title:SetPoint("CENTER", titleBar, "CENTER", 0, 0)
    title:SetText(opts.title or "Options")

    local close = CreateFrame("Button", nil, titleBar)
    close:SetSize(22, 22)
    close:SetPoint("RIGHT", -6, 0)
    -- Borderless X drawn from two crossed bars (ElvUI-style): white, accent on hover.
    local function makeBar()
        local t = close:CreateTexture(nil, "ARTWORK")
        t:SetColorTexture(1, 1, 1, 1)
        t:SetSize(14, 2)
        t:SetPoint("CENTER")
        -- Equal-thickness diagonals: disable pixel snapping so rotation AA stays symmetric.
        if t.SetSnapToPixelGrid then t:SetSnapToPixelGrid(false) end
        if t.SetTexelSnappingBias then t:SetTexelSnappingBias(0) end
        return t
    end
    local bar1, bar2 = makeBar(), makeBar()
    bar1:SetRotation(math.rad(45))
    bar2:SetRotation(math.rad(-45))
    close:SetScript("OnEnter", function()
        bar1:SetColorTexture(unpackColor(T.accent))
        bar2:SetColorTexture(unpackColor(T.accent))
    end)
    close:SetScript("OnLeave", function()
        bar1:SetColorTexture(1, 1, 1, 1)
        bar2:SetColorTexture(1, 1, 1, 1)
    end)

    -- Footer (bottom bar): clickable links left, version text right
    local footer = CreateFrame("Frame", nil, root, "BackdropTemplate")
    footer:SetPoint("BOTTOMLEFT", root, "BOTTOMLEFT", 0, 0)
    footer:SetPoint("BOTTOMRIGHT", root, "BOTTOMRIGHT", 0, 0)
    footer:SetHeight(26)
    Skin(footer, T.titleBg, T.windowEdge)

    local footerDivider = footer:CreateTexture(nil, "ARTWORK")
    footerDivider:SetHeight(1)
    footerDivider:SetPoint("TOPLEFT", footer, "TOPLEFT", 0, 0)
    footerDivider:SetPoint("TOPRIGHT", footer, "TOPRIGHT", 0, 0)
    footerDivider:SetColorTexture(unpackColor(T.windowEdge))

    local footerRight = FontString(footer, 11, T.textDim)
    footerRight:SetPoint("RIGHT", footer, "RIGHT", -12, 0)
    footerRight:SetText(opts.footerRight or "")

    -- Left sidebar (category nav) — no border, transparent, own flat scroll
    local sidebar = CreateFrame("Frame", nil, root)
    sidebar:SetPoint("TOPLEFT", titleBar, "BOTTOMLEFT", 8, -8)
    sidebar:SetPoint("BOTTOMLEFT", footer, "TOPLEFT", 8, 8)
    sidebar:SetWidth(T.sidebarW)
    local sbScroll, sbContent, sbUpdate = CreateScrollArea(sidebar, 0)

    -- Single vertical divider between sidebar and content
    local divider = root:CreateTexture(nil, "ARTWORK")
    divider:SetWidth(1)
    divider:SetColorTexture(unpackColor(T.divider))
    divider:SetPoint("TOPLEFT", sidebar, "TOPRIGHT", 4, 0)
    divider:SetPoint("BOTTOMLEFT", sidebar, "BOTTOMRIGHT", 4, 0)

    -- Body (holds the scroll frames), right of the divider — no border
    local body = CreateFrame("Frame", nil, root)
    body:SetPoint("TOPLEFT", divider, "TOPRIGHT", 8, 0)
    body:SetPoint("BOTTOMRIGHT", footer, "TOPRIGHT", -8, 8)

    local panel = setmetatable({
        frame          = root,
        titleBar       = titleBar,
        sidebar        = sidebar,
        sidebarContent = sbContent,
        sidebarUpdate  = sbUpdate,
        sidebarCursor  = 0,
        body           = body,
        footer         = footer,
        footerRight    = footerRight,
        footerX        = 12,
        tabs           = {},
    }, PanelMeta)

    close:SetScript("OnClick", function() panel:Close() end)

    lib.panels = lib.panels or {}
    lib.panels[#lib.panels + 1] = panel
    return panel
end
