-- VDS_Components_Custom
-- ============================================================================
-- VDS-domain widget kinds (beyond label/button/frame/spacer in
-- VDS_Components.lua). Each declares build + dispatch for the BindingEngine.
-- Registered into the WidgetTypes registry at file-load.

VDS = VDS or {}
local UI    = VDS.UI
local Types = VDS.WidgetTypes
local WHITE = "Interface\\Buttons\\WHITE8x8"

-- ============================================================================
-- rainbowTitle -- static title text with a rainbow gradient. No binding.
-- PANEL_LAYOUT_REFERENCE.md: `title.text` is a `rainbowTitle` widget.
-- ============================================================================

local RAINBOW = {
    "|cffff5555", "|cffff9955", "|cffffdd55", "|cff77ff55",
    "|cff55ddff", "|cff5577ff", "|cffaa55ff", "|cffff77cc",
}

local function rainbowify(text, offset)
    if not text or text == "" then return "" end
    offset = offset or 0  -- exception(optional): offset param optional; default 0
    local out, i = {}, 1
    for ch in text:gmatch(".") do
        out[#out + 1] = RAINBOW[((i + offset - 1) % #RAINBOW) + 1] .. ch .. "|r"
        i = i + 1
    end
    return table.concat(out)
end

-- Donor VDS_DyePanel.lua:121-138. Shared by title text and the 3px spectrum bar.
local SPECTRUM_BAR_COLORS = {
    { 1.00, 0.15, 0.15 },
    { 1.00, 0.50, 0.10 },
    { 1.00, 0.90, 0.15 },
    { 0.30, 0.85, 0.20 },
    { 0.15, 0.85, 0.75 },
    { 0.20, 0.45, 1.00 },
    { 0.55, 0.25, 0.90 },
    { 0.90, 0.35, 0.65 },
}

Types:Register("rainbowTitle", {
    specFields = { "text", "font", "justifyH" },
    build = function(parent, spec)
        local f = CreateFrame("Frame", nil, parent)
        -- Height set by layout engine post-build via spec.height.

        local fs = f:CreateFontString(nil, "OVERLAY")
        UI.applyFontRole(fs, spec.font or "subheading")
        fs:SetPoint("TOPLEFT", 0, 0)
        fs:SetJustifyH(spec.justifyH or "LEFT")
        local titleText = spec.text or "Vamoose's Dye Studio"
        fs:SetText(rainbowify(titleText))
        f._text = fs
        f._origText = titleText

        -- One-time rainbow cycle on login/reload; clears itself after `duration`.
        f.StartCycleAnimation = function(self, duration)
            self._cycleElapsed = 0
            self._cycleDuration = duration or 3.0  -- exception(optional): duration param optional; default 3s
            self:SetScript("OnUpdate", function(_, dt)
                self._cycleElapsed = self._cycleElapsed + dt
                if self._cycleElapsed >= self._cycleDuration then
                    self:SetScript("OnUpdate", nil)
                    self._text:SetText(rainbowify(self._origText))
                    return
                end
                -- 4 rotations in 3s; floor keeps repaint to actual offset changes.
                local offset = math.floor(self._cycleElapsed * 32 / 3)
                self._text:SetText(rainbowify(self._origText, offset))
            end)
        end

        -- 8-segment spectrum bar; segments re-anchored in OnSizeChanged.
        f._segments = {}
        for i, c in ipairs(SPECTRUM_BAR_COLORS) do
            local seg = f:CreateTexture(nil, "ARTWORK", nil, 1)
            seg:SetTexture(WHITE)
            seg:SetVertexColor(c[1], c[2], c[3], 0.7)
            seg:SetHeight(3)
            f._segments[i] = seg
        end
        local function layoutSegments(self)
            local w = self:GetWidth()
            if w < 1 then return end
            local n = #self._segments
            local segW = math.floor(w / n)
            for i, seg in ipairs(self._segments) do
                seg:ClearAllPoints()
                seg:SetPoint("BOTTOMLEFT", (i - 1) * segW, 0)
                seg:SetWidth(segW)
            end
        end
        f:HookScript("OnSizeChanged", layoutSegments)
        layoutSegments(f)

        return f
    end,
})

-- ============================================================================
-- iconButton -- atlas icon + tooltip. Static (no binding); click action
-- attached by the Controller in Phase 6 via spec.id lookup.
-- ============================================================================

Types:Register("iconButton", {
    specFields = { "size", "atlas", "atlasStates", "action" },
    build = function(parent, spec)
        local btn = CreateFrame("Button", nil, parent)
        -- Size set by the layout engine post-build via spec.width / spec.height.

        -- atlasStates: native 3-state Blizzard atlas (decor-controls-*, etc).
        -- atlas (single): faux state via desaturate/alpha on ARTWORK texture.
        if spec.atlasStates then
            btn:SetNormalAtlas(spec.atlasStates.default)
            btn:SetHighlightAtlas(spec.atlasStates.active)
            btn:SetPushedAtlas(spec.atlasStates.pressed)
            -- Atlas-native size can leave textures floating; re-anchor to fill.
            for _, getter in ipairs({ "GetNormalTexture", "GetHighlightTexture", "GetPushedTexture" }) do
                local t = btn[getter](btn)
                if t then
                    t:ClearAllPoints()
                    t:SetAllPoints(btn)
                end
            end
        else
            local icon = btn:CreateTexture(nil, "ARTWORK")
            icon:SetAllPoints()
            if spec.atlas then icon:SetAtlas(spec.atlas) end
            -- Resting: desaturated grey at 85% alpha -- reads as a muted control
            -- (not invisible like 0.75 was, not too prominent like full-colour 0.7).
            -- Hover snaps to full colour + 100% alpha for the click affordance.
            icon:SetDesaturated(true)
            icon:SetAlpha(0.85)
            btn._icon = icon
        end

        btn:SetScript("OnEnter", function()
            if btn._icon then
                btn._icon:SetDesaturated(false)
                btn._icon:SetAlpha(1.0)
            end
        end)
        btn:SetScript("OnLeave", function()
            if btn._icon then
                btn._icon:SetDesaturated(true)
                btn._icon:SetAlpha(0.85)
            end
        end)

        -- spec.tooltip: string -> { title } or table { title, body, anchor }.
        -- Tooltip: recipe-only (per VDS_TOOLTIP_MIGRATION.md). Validator
        -- enforces spec.tooltip = { recipe = "Name" } | false at boot.
        if type(spec.tooltip) == "table" and spec.tooltip.recipe then
            local recipe = VDS.TooltipRecipes[spec.tooltip.recipe]
            if recipe ~= nil then VDS.TooltipEngine:Attach(btn, recipe) end
        end

        return btn
    end,
})

-- ============================================================================
-- checkbox -- Blizzard UICheckButtonTemplate + binding to bool selector.
-- PANEL_LAYOUT_REFERENCE.md title-row checkboxes: cart/owned/replace.
-- ============================================================================

local function dispatchCheckbox(widget, values)
    -- _check is set in build() before the widget returns; strict-read it. If a
    -- future refactor drops the assignment, this errors loud instead of
    -- silently SetChecked'ing the outer Frame.
    local target = widget._check
    if values.checked ~= nil then
        target:SetChecked(values.checked and true or false)
    end
    if values.enabled ~= nil then
        target:SetEnabled(values.enabled and true or false)
    end
end

-- Two shapes: spec.label -> Frame container (config panel, full-width row);
-- no label -> bare 22x22 CheckButton (title row, inline with icons).
-- Stretching UICheckButtonTemplate breaks its check texture, hence the container.
Types:Register("checkbox", {
    -- "size" used to feed the no-label branch (dead now: every consumer
    -- supplies label). Kept in specFields for backwards-compat; build only
    -- consumes "label", "height" (universal), and "tooltip".
    specFields = { "label", "size", "action" },
    build = function(parent, spec)
        local f = CreateFrame("Frame", nil, parent)
        -- Height set by layout engine post-build via spec.height.
        local btn = CreateFrame("CheckButton", nil, f, "UICheckButtonTemplate")
        btn:SetSize(22, 22)
        btn:SetPoint("LEFT", f, "LEFT", 0, 0)
        f._check = btn

        local fs = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        fs:SetPoint("LEFT", btn, "RIGHT", 4, 0)
        fs:SetPoint("RIGHT", f, "RIGHT", -2, 0)
        fs:SetJustifyH("LEFT")
        fs:SetText(spec.label)
        UI.applyFontRole(fs, "small")
        f._label = fs

        if type(spec.tooltip) == "table" and spec.tooltip.recipe then
            f:EnableMouse(true)
            local recipe = VDS.TooltipRecipes[spec.tooltip.recipe]
            if recipe ~= nil then VDS.TooltipEngine:Attach(f, recipe) end
            -- Click-forwarding: clicking the label area fires the underlying
            -- CheckButton. Only wired when tooltip is set (matches prior gate).
            f:SetScript("OnMouseUp", function(_, button)
                btn:Click(button)
            end)
        end
        return f
    end,
    dispatch = { fields = { "checked" }, push = dispatchCheckbox },
})

-- ============================================================================
-- searchBox -- Blizzard SearchBoxTemplate + binding to text selector.
-- ============================================================================

local function dispatchSearchBox(widget, values)
    if values.text == nil then return end
    -- Guard loop: OnTextChanged -> dispatch -> SetText -> OnTextChanged.
    if widget:GetText() ~= values.text then
        widget:SetText(values.text)
    end
end

Types:Register("searchBox", {
    specFields = {},
    build = function(parent, spec)
        local box = CreateFrame("EditBox", nil, parent, "SearchBoxTemplate")
        box:SetAutoFocus(false)
        box:SetMaxLetters(64)
        VDS.TooltipEngine:AttachFromSpec(box, spec)
        return box
    end,
    dispatch = { fields = { "text" }, push = dispatchSearchBox },
})

-- ============================================================================
-- spectrumBar -- rainbow bar masked to a progress fraction.
-- Binding: { owned, total } table (e.g. progress.collection selector).
-- ============================================================================

local function dispatchSpectrumBar(widget, values)
    local owned, total = values.owned, values.total
    local frac
    if total > 0 then frac = owned / total else frac = 0 end
    frac = math.max(0, math.min(1, frac))
    if widget._fillBar then
        local w = widget:GetWidth()
        widget._fillBar:SetWidth(math.floor(w * frac))
    end
end

Types:Register("spectrumBar", {
    specFields = {},
    build = function(parent, _spec)
        local bar = CreateFrame("Frame", nil, parent)
        bar:SetHeight(8)

        local track = bar:CreateTexture(nil, "BACKGROUND")
        track:SetAllPoints()
        track:SetTexture(WHITE)
        track:SetVertexColor(0.20, 0.20, 0.22, 1)

        local fillBar = CreateFrame("Frame", nil, bar)
        fillBar:SetPoint("LEFT")
        fillBar:SetHeight(8)
        fillBar:SetWidth(0)
        local segs = {
            {1.00, 0.30, 0.30}, {1.00, 0.65, 0.20},
            {1.00, 0.95, 0.30}, {0.40, 0.90, 0.40},
            {0.30, 0.65, 1.00}, {0.75, 0.40, 1.00},
        }
        for i, c in ipairs(segs) do
            local t = fillBar:CreateTexture(nil, "ARTWORK")
            t:SetTexture(WHITE)
            t:SetVertexColor(c[1], c[2], c[3], 1)
            t:SetPoint("TOPLEFT",     fillBar, "TOPLEFT",     (i - 1) / #segs * 200, 0)
            t:SetPoint("BOTTOMRIGHT", fillBar, "TOPLEFT",     i / #segs * 200, -8)
        end
        bar._fillBar = fillBar
        return bar
    end,
    dispatch = { fields = { "owned", "total" }, push = dispatchSpectrumBar },
})

-- ============================================================================
-- colorsProgressBar -- horizontal progress bar with "Colors: X/Y" text
-- overlaid. Ports donor VDS_DyeDetail.lua:180-197 + VDS_Framework.lua:670-698
-- ProgressBar chrome. Binding shape: { owned, total }.
-- ============================================================================

local function dispatchColorsProgressBar(widget, values)
    local total = values.total
    local cells = values.cells

    if total <= 0 or type(cells) ~= "table" or #cells == 0 then
        widget._slices = widget._slices or {}
        for _, s in ipairs(widget._slices) do s:Hide() end
        return
    end

    local innerW  = widget:GetWidth()  - 2
    local innerH  = widget:GetHeight() - 2
    if innerW < 1 or innerH < 1 then return end

    local n      = #cells
    local cellW  = innerW / n
    widget._slices = widget._slices or {}

    for i, cell in ipairs(cells) do
        local slice = widget._slices[i]
        if not slice then
            slice = widget:CreateTexture(nil, "ARTWORK")
            slice:SetTexture(WHITE)
            widget._slices[i] = slice
        end
        slice:ClearAllPoints()
        slice:SetPoint("TOPLEFT", 1 + (i - 1) * cellW, -1)
        slice:SetWidth(math.max(cellW, 1))  -- min 1px so high-total catalogs stay visible
        slice:SetHeight(innerH)

        if cell.owned then
            local info = VDS.DyeOwnershipObserver:ResolveInfo(cell.dyeID)
            local c = info and info.swatchRGB  -- exception(nullable): catalog lookup can miss pre-CATALOG_READY
            if c then
                slice:SetVertexColor(c.r, c.g, c.b, 0.9)
            else
                slice:SetVertexColor(0.4, 0.4, 0.4, 0.6)
            end
            slice:Show()
        else
            slice:Hide()
        end
    end

    -- Hide leftover pooled slices if the catalog shrank.
    for i = n + 1, #widget._slices do widget._slices[i]:Hide() end
end

Types:Register("colorsProgressBar", {
    specFields = { "height" },
    build = function(parent, spec)
        local f = CreateFrame("Frame", nil, parent)
        -- Height set by layout engine post-build via spec.height.

        local bg = f:CreateTexture(nil, "BACKGROUND")
        bg:SetAllPoints()
        bg:SetTexture(WHITE)
        bg:SetVertexColor(0.13, 0.15, 0.17, 0.8)
        f._bg = bg

        return f
    end,
    dispatch = { fields = { "owned", "total", "cells" }, push = dispatchColorsProgressBar },
})

-- ============================================================================
-- tabBar -- equal-width mode tabs. Binding: chips array per browse.tabs.chips.
-- Repaints rebuild tabs in place (OnClick wired here, not Controller -- see makeTabBtn).
-- ============================================================================

local function makeTabBtn(parent, chip, index, count, panelWidth)
    local btn = CreateFrame("Button", nil, parent)
    local w = math.floor(panelWidth / count)
    btn:SetSize(w, 26)
    btn:SetPoint("TOPLEFT", parent, "TOPLEFT", (index - 1) * w, 0)
    btn:RegisterForClicks("AnyUp")

    local label = btn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    label:SetPoint("CENTER", 0, 1)
    UI.applyFontRole(label, "body")
    label:SetText(chip.label or chip.id or "?")
    btn._label = label

    -- Bottom accent bar (active). Per-chip brand color or Palette accent.
    local accent = btn:CreateTexture(nil, "ARTWORK")
    accent:SetPoint("BOTTOMLEFT",  2, 0)
    accent:SetPoint("BOTTOMRIGHT", -2, 0)
    accent:SetHeight(2)
    accent:SetTexture(WHITE)
    local accentC = chip.color or VDS.Palette:GetColor("accent.teal")
    accent:SetVertexColor(accentC.r, accentC.g, accentC.b, 1)
    btn._accent = accent
    if chip.active then accent:Show() else accent:Hide() end

    if chip.color then
        if chip.active then
            label:SetTextColor(chip.color.r, chip.color.g, chip.color.b, 1)
        else
            label:SetTextColor(chip.color.r * 0.55, chip.color.g * 0.55, chip.color.b * 0.55, 1)
        end
    else
        if chip.active then
            label:SetTextColor(1, 1, 1, 1)
        else
            label:SetTextColor(0.53, 0.53, 0.53, 1)
        end
    end

    VDS.Theme:EnsureHoverHighlight(btn)

    btn._chipID = chip.id

    -- chip.action overrides default SET_FILTER_MODE (e.g. warehouse uses
    -- STOCK_SET_FILL_MODE). Must be wired here, not in Controller:Wire --
    -- dispatchTabBar rebuilds chip buttons on every tick, clobbering post-build SetScript.
    btn:SetScript("OnClick", function()
        VDS.Store:Dispatch({
            type    = chip.action or VDS.Constants.ACTIONS.SET_FILTER_MODE,
            payload = { mode = chip.id },
        })
    end)

    return btn
end

-- Re-anchor chip Buttons on resize. Chips store pixel offsets; without this,
-- parent resize (every view change via refreshLayout) leaves chips at their
-- boot-time positions. The "*" Apply fires before Layout sizes the tabBar, so
-- chips start at the 420 fallback and OnSizeChanged corrects them.
local function layoutTabBarChips(widget)
    if not (widget._children and #widget._children > 0) then return end
    local panelW = widget:GetWidth()
    if not panelW or panelW <= 0 then return end  -- OnSizeChanged will fire again
    local count = #widget._children
    local w = math.floor(panelW / count)
    for i, btn in ipairs(widget._children) do
        btn:SetSize(w, 26)
        btn:ClearAllPoints()
        btn:SetPoint("TOPLEFT", widget, "TOPLEFT", (i - 1) * w, 0)
    end
end

local function dispatchTabBar(widget, values)
    local chips = values.chips
    if type(chips) ~= "table" then return end

    widget._children = widget._children or {}
    for _, btn in ipairs(widget._children) do btn:Hide(); btn:SetParent(nil) end
    widget._children = {}

    -- GetWidth() returns 0 (not nil) for un-sized frames; OnSizeChanged corrects later.
    local panelW = widget:GetWidth()
    if not panelW or panelW <= 0 then panelW = 420 end
    for i, chip in ipairs(chips) do
        widget._children[i] = makeTabBtn(widget, chip, i, #chips, panelW)
    end

    if not widget._tabBarSizeHook then  -- install once; re-anchor is cheap (<= 6 chips)
        widget._tabBarSizeHook = true
        widget:SetScript("OnSizeChanged", function(self) layoutTabBarChips(self) end)
    end
end

Types:Register("tabBar", {
    specFields = {},
    build = function(parent, spec)
        local f = CreateFrame("Frame", nil, parent)
        -- Height set by layout engine post-build via spec.height.
        VDS.TooltipEngine:AttachFromSpec(f, spec)
        return f
    end,
    dispatch = { fields = { "chips" }, push = dispatchTabBar },
})

-- ============================================================================
-- chipStrip -- horizontal chip flow; accent under active chip.
-- Overflow arrows deferred (PANEL_LAYOUT_REFERENCE.md); strip clips for now.
-- ============================================================================

local function makeChipBtn(parent, chip, xCursor, parentWidget)
    local btn = CreateFrame("Button", nil, parent)
    btn:SetHeight(VDS.Constants.FLOATING_WIDGET.DYE_CHIP_HEIGHT)
    btn:SetPoint("LEFT", parent, "LEFT", xCursor, 0)
    btn:RegisterForClicks("AnyUp")

    -- vds_chip.tga tinted to chip.rgb (0.7a); solid grey when no rgb.
    local bg = btn:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints()
    bg:SetTexture("Interface\\AddOns\\VamoosesDyeStudio\\textures\\vds_chip")
    if chip.rgb then
        bg:SetVertexColor(chip.rgb.r, chip.rgb.g, chip.rgb.b, 0.7)
    else
        bg:SetVertexColor(0.35, 0.35, 0.35, 0.85)
    end
    btn._bg = bg

    local label = btn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    label:SetPoint("LEFT",  6, 0)
    label:SetPoint("RIGHT", -6, 0)
    label:SetJustifyH("CENTER")
    UI.applyFontRole(label, "small")
    label:SetText(chip.label or chip.id or "?")
    btn._label = label

    local textW = label:GetStringWidth()
    btn:SetWidth(math.max(textW + 16, 28))

    -- Active accent -- brand teal via Palette.
    local accent = btn:CreateTexture(nil, "ARTWORK")
    accent:SetPoint("BOTTOMLEFT",  4, 1)
    accent:SetPoint("BOTTOMRIGHT", -4, 1)
    accent:SetHeight(2)
    accent:SetTexture(WHITE)
    local accentC = VDS.Palette:GetColor("accent.teal")
    accent:SetVertexColor(accentC.r, accentC.g, accentC.b, 1)
    if chip.active then accent:Show() else accent:Hide() end
    btn._accent = accent

    if chip.active then
        label:SetTextColor(1, 1, 1, 1)
    else
        label:SetTextColor(0.65, 0.65, 0.67, 1)
    end

    btn._chipID            = chip.id
    btn._isCustomDyeChip   = chip.customDyeChip and true or false
    VDS.TooltipEngine:Attach(btn, VDS.TooltipRecipes.CustomPaletteChip)

    -- SET_ACTIVE_SUBFILTER; kind from filterMode. Toggle off on re-click (nil).
    -- "__new__" (Custom tab) -> StaticPopup; reducer inserts via CUSTOM_THEME_CREATE.
    btn:SetScript("OnClick", function()
        if chip.id == "__new__" then
            if _G.StaticPopup_Show then
                _G.StaticPopup_Show("VDS_CREATE_CUSTOM_THEME")
            end
            return
        end
        if chip.customDyeChip then  -- custom-mode: toggle removes dye from active palette
            local state  = VDS.Store:GetState()
            local bucket = state.session.ui.byView[state.session.ui.filterMode]
            local name   = bucket.editingThemeName or bucket.activeTheme
            if name then
                VDS.Store:Dispatch({
                    type    = VDS.Constants.ACTIONS.CUSTOM_THEME_TOGGLE_DYE,
                    payload = { name = name, dyeID = chip.id },
                })
            end
            return
        end
        local state = VDS.Store:GetState()
        local mode  = state.session.ui.filterMode
        local kind
        if     mode == "category" then kind = "colorGroup"
        elseif mode == "custom"   then kind = "theme"
        elseif mode == "theme"    then kind = "themePalette"
        elseif mode == "crafting" then kind = "craftGroup"
        elseif mode == "herbs"    then kind = "herbGroup"
        else return end
        local uiState  = state.session.ui
        local bucket   = uiState.byView[uiState.filterMode]
        local current
        if     kind == "colorGroup"   then current = bucket.activeColorGroup
        elseif kind == "theme"        then current = bucket.activeTheme
        elseif kind == "themePalette" then current = bucket.activeThemePalette
        elseif kind == "craftGroup"   then current = bucket.activeCraftGroup
        elseif kind == "herbGroup"    then current = bucket.activeHerbGroup end
        -- "__all__" clears; others toggle (re-click active chip to clear).
        -- Explicit branch: `(current == chip.id) and nil or chip.id` is the
        -- Lua ternary trap -- nil in the middle slot always yields chip.id,
        -- so re-clicking the active chip never deselected it.
        local value = chip.id
        if chip.id == "__all__" or current == chip.id then value = nil end
        VDS.Store:Dispatch({
            type = VDS.Constants.ACTIONS.SET_ACTIVE_SUBFILTER,
            payload = { kind = kind, value = value },
        })
    end)

    return btn
end

-- Donor VDS_DyeFilters.lua:67-156: overflow -> arrows appear; content shifts by
-- scroll offset; clip frame SetClipsChildren hides overflow; mouse wheel scrolls.

local function dispatchChipStrip(widget, values)
    local chips = values.chips
    if type(chips) ~= "table" then return end

    local content = widget._content
    widget._children = widget._children or {}
    for _, btn in ipairs(widget._children) do btn:Hide(); btn:SetParent(nil) end
    widget._children = {}

    local x = 4
    for i, chip in ipairs(chips) do
        local btn = makeChipBtn(content, chip, x, widget)  -- parent to content, not outer widget, so clip applies
        widget._children[i] = btn
        x = x + btn:GetWidth() + 4
    end
    content:SetWidth(math.max(1, x))
    widget._contentWidth = x
    widget._updateScroll(widget._scrollOffset)
end

Types:Register("chipStrip", {
    specFields = {},
    build = function(parent, _spec)
        local f = CreateFrame("Frame", nil, parent)
        -- Height set by layout engine post-build via spec.height.
        local ARROW = 18
        local SCROLL_STEP = 80

        -- Left arrow (hidden by default; appears when scrolled right of 0).
        local leftBtn = CreateFrame("Button", nil, f)
        leftBtn:SetSize(ARROW, ARROW)
        leftBtn:SetPoint("LEFT", f, "LEFT", 0, 0)
        local leftIcon = leftBtn:CreateTexture(nil, "ARTWORK")
        leftIcon:SetAllPoints()
        leftIcon:SetAtlas("common-icon-backarrow")
        leftIcon:SetAlpha(0.7)
        leftBtn:SetScript("OnEnter", function() leftIcon:SetAlpha(1) end)
        leftBtn:SetScript("OnLeave", function() leftIcon:SetAlpha(0.7) end)
        leftBtn:Hide()
        f._leftBtn = leftBtn

        local rightBtn = CreateFrame("Button", nil, f)
        rightBtn:SetSize(ARROW, ARROW)
        rightBtn:SetPoint("RIGHT", f, "RIGHT", 0, 0)
        local rightIcon = rightBtn:CreateTexture(nil, "ARTWORK")
        rightIcon:SetAllPoints()
        rightIcon:SetAtlas("common-icon-forwardarrow")
        rightIcon:SetAlpha(0.7)
        rightBtn:SetScript("OnEnter", function() rightIcon:SetAlpha(1) end)
        rightBtn:SetScript("OnLeave", function() rightIcon:SetAlpha(0.7) end)
        rightBtn:Hide()
        f._rightBtn = rightBtn

        local clip = CreateFrame("Frame", nil, f)  -- SetClipsChildren; gutters managed by _updateScroll
        clip:SetPoint("TOPLEFT", f, "TOPLEFT", 0, 0)
        clip:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", 0, 0)
        clip:SetClipsChildren(true)
        f._clip = clip

        local content = CreateFrame("Frame", nil, clip)  -- may be wider than clip
        content:SetPoint("TOPLEFT", clip, "TOPLEFT", 0, 0)
        content:SetHeight(24)
        content:SetWidth(1)
        f._content = content

        f._scrollOffset = 0
        f._contentWidth = 0  -- updateScroll may be called before first dispatch sets the real width
        local function updateScroll(newOffset)
            local cw = f._contentWidth
            local clipW = clip:GetWidth()
            if clipW < 10 then clipW = f:GetWidth() - (ARROW + 2) * 2 end
            local maxScroll = math.max(0, cw - clipW)
            f._scrollOffset = math.max(0, math.min(maxScroll, newOffset))
            content:ClearAllPoints()
            content:SetPoint("TOPLEFT", clip, "TOPLEFT", -f._scrollOffset, 0)
            leftBtn:SetShown(f._scrollOffset > 0)
            rightBtn:SetShown(f._scrollOffset < maxScroll - 1)
            clip:ClearAllPoints()
            if f._scrollOffset > 0 then
                clip:SetPoint("TOPLEFT", f, "TOPLEFT", ARROW + 2, 0)
            else
                clip:SetPoint("TOPLEFT", f, "TOPLEFT", 0, 0)
            end
            if f._scrollOffset < maxScroll - 1 then
                clip:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -(ARROW + 2), 0)
            else
                clip:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", 0, 0)
            end
        end
        f._updateScroll = updateScroll

        leftBtn:SetScript("OnClick",  function() updateScroll(f._scrollOffset - SCROLL_STEP) end)
        rightBtn:SetScript("OnClick", function() updateScroll(f._scrollOffset + SCROLL_STEP) end)
        f:EnableMouseWheel(true)
        f:SetScript("OnMouseWheel", function(_, delta)
            updateScroll(f._scrollOffset - delta * SCROLL_STEP)
        end)

        return f
    end,
    dispatch = { fields = { "chips" }, push = dispatchChipStrip },
})

-- ============================================================================
-- scrollBoxList -- WowScrollBoxList + MinimalScrollBar + linear view.
-- Binding: array of { kind, ...payload }. spec.rowKinds maps kind -> RowRegistry name.
-- ============================================================================

local function dispatchScrollBoxList(widget, values)
    local rows = values.rows
    if type(rows) ~= "table" then return end

    if not _G.CreateDataProvider then return end  -- headless tests

    local provider = _G.CreateDataProvider()
    for _, row in ipairs(rows) do provider:Insert(row) end

    -- Retain scroll within a tab; reset on tab/subfilter change.
    -- ADR-exception: dispatcher reads Store directly to compute sig.
    -- Scroll position is UI-only; wrapping every rows-selector to carry
    -- scrollSig costs more than it saves until the binding system grows native
    -- UI-state support.
    local ui = VDS.Store:GetState().session.ui  -- ADR: dispatcher Store read
    local sig = (ui.filterMode or "")
                .. "|" .. (ui.activeColorGroup     or "")
                .. "|" .. (ui.activeHerbGroup      or "")
                .. "|" .. (ui.activeThemePalette   or "")
                .. "|" .. (ui.activeCraftGroup     or "")
                .. "|" .. (ui.activeTheme          or "")
                .. "|" .. (ui.editingThemeName     or "")
    local retain = (widget._scrollSig == sig)
    widget._scrollSig = sig
    widget._scrollBox:SetDataProvider(provider, retain)
end

-- ============================================================================
-- colorSwatch -- 44x44 watercolor swatch. Binding: { dyeColorID,
-- swatchColorStart? } table from detail.dye. Falls back to a neutral
-- tone when no dye selected.
-- ============================================================================

local function dispatchColorSwatch(widget, values)
    local dye = values.dye
    if not dye or not dye.dyeColorID then
        if widget._fill then widget._fill:SetVertexColor(0.20, 0.20, 0.22, 1) end
        return
    end
    -- Selector returns dyeColorID only (purity); resolve at paint time. -- boundary
    local info = VDS.DyeOwnershipObserver:ResolveInfo(dye.dyeColorID)
    local c = info and info.swatchRGB  -- exception(nullable): catalog lookup can miss pre-CATALOG_READY
    if widget._fill then
        if c then
            widget._fill:SetVertexColor(c.r, c.g, c.b, 1)
        else
            widget._fill:SetVertexColor(0.35, 0.35, 0.38, 1)
        end
    end
end

Types:Register("colorSwatch", {
    specFields = { "size" },
    build = function(parent, spec)
        local f = CreateFrame("Frame", nil, parent)
        -- Size set by the layout engine post-build via spec.width / spec.height.
        -- Internal textures use SetAllPoints; they re-anchor as the frame grows.

        -- vds_swatch_tex (donor VDS_Framework.lua:587-592); gradient from
        -- the texture's watercolor wash, not SetGradient.
        local fill = f:CreateTexture(nil, "ARTWORK")
        fill:SetAllPoints()
        fill:SetTexture("Interface\\AddOns\\VamoosesDyeStudio\\textures\\vds_swatch_tex")
        fill:SetVertexColor(0.20, 0.20, 0.22, 1)
        f._fill = fill

        return f  -- no border (donor Image #25 LEFT: watercolor wash only)
    end,
    dispatch = { fields = { "dye" }, push = dispatchColorSwatch },
})

-- ============================================================================
-- metaLine -- composes an array of { label, value, valueKind? } parts into
-- "Label: Value   Label2: Value2". valueKind = "money" routes through
-- GetCoinTextureString.
-- ============================================================================

local function formatPart(part)
    local label = part.label or ""
    local value = part.value
    if part.valueKind == "money" then
        return label .. ": " .. _G.GetCoinTextureString(value)
    end
    if part.valueKind == "colorGroup" and part.dyeColorID then
        local info = VDS.DyeOwnershipObserver:ResolveInfo(part.dyeColorID)
        if info then value = info.colorGroup end
    end
    if part.valueKind == "dyeCategory" and part.catID then  -- catID -> "General"/"Wood"/"Metal"
        value = VDS.DyeOwnershipObserver:ResolveCategoryName(part.catID)
    end
    return label .. ": " .. tostring(value or "")
end

local function dispatchMetaLine(widget, values)
    local parts = values.parts
    if type(parts) ~= "table" or #parts == 0 then
        widget:SetText("")
        return
    end
    local out = {}
    for i, p in ipairs(parts) do out[i] = formatPart(p) end
    widget:SetText(table.concat(out, "   "))
end

Types:Register("metaLine", {
    specFields = { "font", "justifyH" },
    build = function(parent, spec)
        local fs = parent:CreateFontString(nil, "OVERLAY")
        UI.applyFontRole(fs, spec.font or "small")
        fs:SetJustifyH(spec.justifyH or "LEFT")
        return fs
    end,
    dispatch = { fields = { "parts" }, push = dispatchMetaLine },
})

-- ============================================================================
-- recentDyeStrip -- last N dyes applied in live mode as a horizontal row of
-- clickable swatches (account-persisted ring buffer, newest first). Binding
-- shape: array of dyeIDs from live.recentDyes. Fixed pool of
-- LIVE_RECENT_DYES_MAX buttons built once; dispatch paints + shows/hides
-- (donor VDS_LivePreview.lua:506-613 pre-created its 10 for the same reason --
-- frames are never GC'd). Clicks + tooltips wired by Controller_Live.
-- ============================================================================

local RECENT_SWATCH   = 28
local RECENT_GAP      = 2
local RECENT_MARKER_W = 16  -- carousel arrow; 45x52 atlas scaled to strip height

local function dispatchRecentDyeStrip(widget, values)
    for i, btn in ipairs(widget._swatches) do
        local dyeID = values.ids[i]
        if dyeID then
            btn._dyeID = dyeID
            local info = VDS.DyeOwnershipObserver:ResolveInfo(dyeID)
            if info and info.swatchStart and info.swatchEnd then  -- exception(nullable): catalog lookup can miss pre-CATALOG_READY
                btn._swBase:SetVertexColor(info.swatchStart.r, info.swatchStart.g, info.swatchStart.b, 1)
                btn._swGrad:SetVertexColor(info.swatchEnd.r, info.swatchEnd.g, info.swatchEnd.b, 1)
            else
                btn._swBase:SetVertexColor(0.35, 0.35, 0.38, 1)
                btn._swGrad:SetVertexColor(0.35, 0.35, 0.38, 1)
            end
            btn:Show()
        else
            btn._dyeID = nil
            btn:Hide()
        end
    end
end

Types:Register("recentDyeStrip", {
    specFields = {},
    build = function(parent, _spec)
        local f = CreateFrame("Frame", nil, parent)
        -- Height set by layout engine post-build via spec.height.
        local ATLAS = VDS.Constants.ATLAS

        -- Left marker: carousel arrow pointing at the first swatch -- newest
        -- dyes pop in here. Inline atlas per chipStrip arrow precedent;
        -- explainer tooltip attached by Controller_Live. Hover-brighten
        -- signals the tooltip affordance (chipStrip arrows do the same).
        local marker = CreateFrame("Frame", nil, f)
        marker:SetSize(RECENT_MARKER_W, 18)
        marker:SetPoint("LEFT", f, "LEFT", 0, 0)
        marker:EnableMouse(true)
        local markerTex = marker:CreateTexture(nil, "ARTWORK")
        markerTex:SetAllPoints()
        markerTex:SetAtlas("shop-icon-carousel-next-hover")
        markerTex:SetAlpha(0.7)
        marker:SetScript("OnEnter", function() markerTex:SetAlpha(1) end)
        marker:SetScript("OnLeave", function() markerTex:SetAlpha(0.7) end)
        f._marker = marker

        f._swatches = {}
        for i = 1, VDS.Constants.LIVE_RECENT_DYES_MAX do
            local btn = CreateFrame("Button", nil, f)
            btn:SetSize(RECENT_SWATCH, RECENT_SWATCH)
            btn:SetPoint("LEFT", f, "LEFT",
                RECENT_MARKER_W + 4 + (i - 1) * (RECENT_SWATCH + RECENT_GAP), 0)

            local swBase = btn:CreateTexture(nil, "ARTWORK", nil, 1)
            swBase:SetAllPoints()
            swBase:SetAtlas(ATLAS.DYE_SWATCH_BASE)
            btn._swBase = swBase

            local swGrad = btn:CreateTexture(nil, "ARTWORK", nil, 2)
            swGrad:SetAllPoints()
            swGrad:SetAtlas(ATLAS.DYE_SWATCH_GRADIENT)
            btn._swGrad = swGrad

            local border = btn:CreateTexture(nil, "OVERLAY", nil, 1)
            border:SetAllPoints()
            border:SetAtlas(ATLAS.DYE_FRAME_DEFAULT)

            local hl = btn:CreateTexture(nil, "HIGHLIGHT")
            hl:SetAllPoints()
            hl:SetTexture(WHITE)
            hl:SetVertexColor(1, 1, 1, 0.15)

            btn:Hide()
            f._swatches[i] = btn
        end
        return f
    end,
    dispatch = { fields = { "ids" }, push = dispatchRecentDyeStrip },
})

-- ============================================================================
-- slotRow -- per-channel slot row in the live edit panel. Binding shape:
-- { slotID, dyeID } from live.slot1 / live.slot2 / live.slot3. Donor-parity
-- chrome (VDS_LivePreview.lua:125-216): atlas swatch (Housing_Customize_Dye_*)
-- + "Slot N" label below + textured nameBar (vds_swatch_tex) tinted to the
-- dye color with dye-name centered on it.
-- ============================================================================

local SLOT_SWATCH = 39

local function dispatchSlotRow(widget, values)
    -- Sparse slot arrays (e.g. channels {0, 2}): slot2 is nil; live.hasSlotN
    -- gates visibility, but dispatch still fires on first paint.
    local data = values.data or {}
    widget._slotID  = data.slotID
    widget._slotIdx = data.slotIdx

    local dyeID = data.dyeID
    local dyeInfo = VDS.DyeOwnershipObserver:ResolveInfo(dyeID)

    -- Dye assigned but not in bags -> error red (semantic.error; NOT warning
    -- amber -- donor parity). Empty slots stay in normal text.
    local warn = (dyeID ~= nil) and data.ownedCount == 0
    local warnC = warn and VDS.Theme:GetColor("semantic.error")
    local nameColor = warnC or { r = 1, g = 1, b = 1, a = 1 }
    widget._nameText:SetTextColor(nameColor.r, nameColor.g, nameColor.b, 1)

    if dyeInfo and dyeInfo.swatchStart and dyeInfo.swatchEnd then  -- exception(nullable): catalog lookup can miss pre-CATALOG_READY
        local cs, ce = dyeInfo.swatchStart, dyeInfo.swatchEnd
        widget._swBase:SetVertexColor(cs.r, cs.g, cs.b, 1); widget._swBase:Show()
        widget._swGrad:SetVertexColor(ce.r, ce.g, ce.b, 1); widget._swGrad:Show()
        widget._swEmpty:Hide()
        widget._nameBar:SetVertexColor(cs.r, cs.g, cs.b, 1)
        -- Append owned-count suffix to mirror the dye list row (VDS_Rows_Dye.lua:221-226):
        -- green "xN" when owned, dim "--" when not. Inline color codes override
        -- the base SetTextColor only for the suffix segment, leaving the name
        -- in white (or semantic.error when warn fires).
        local C = VDS.Constants.COLOR_CODE
        local ownedN = data.ownedCount or 0  -- exception(nullable): first paint pushes empty data on sparse slots
        local suffix = (ownedN > 0)
            and (C.success .. "x" .. ownedN .. "|r")
            or  (C.dim     .. "--|r")
        widget._nameText:SetText((dyeInfo.name or "") .. "  " .. suffix)
    else
        widget._swBase:Hide()
        widget._swGrad:Hide()
        widget._swEmpty:Show()
        widget._nameBar:SetVertexColor(0.2, 0.2, 0.2, 1)
        widget._nameText:SetText("Empty")
    end

    if widget._selBorder then
        if data.active then widget._selBorder:Show() else widget._selBorder:Hide() end
    end
end

Types:Register("slotRow", {
    specFields = {},
    build = function(parent, _spec)
        local f = CreateFrame("Button", nil, parent)
        -- Height set by layout engine post-build via spec.height.
        f:RegisterForClicks("LeftButtonUp", "RightButtonUp")

        local ATLAS = VDS.Constants.ATLAS  -- base + gradient for two-color dye; empty when nil
        local swBase = f:CreateTexture(nil, "ARTWORK", nil, 1)
        swBase:SetSize(SLOT_SWATCH, SLOT_SWATCH)
        swBase:SetPoint("LEFT", f, "LEFT", 0, 0)
        swBase:SetAtlas(ATLAS.DYE_SWATCH_BASE)
        f._swBase = swBase

        local swGrad = f:CreateTexture(nil, "ARTWORK", nil, 2)
        swGrad:SetAllPoints(swBase)
        swGrad:SetAtlas(ATLAS.DYE_SWATCH_GRADIENT)
        f._swGrad = swGrad

        local swEmpty = f:CreateTexture(nil, "ARTWORK", nil, 0)
        swEmpty:SetAllPoints(swBase)
        swEmpty:SetAtlas(ATLAS.DYE_SWATCH_EMPTY)
        swEmpty:Hide()
        f._swEmpty = swEmpty

        local border = f:CreateTexture(nil, "OVERLAY", nil, 1)
        border:SetAllPoints(swBase)
        border:SetAtlas(ATLAS.DYE_FRAME_DEFAULT)
        f._border = border

        -- Gold focus border (parity with channelRow), shown when this slot is focused.
        local selBorder = f:CreateTexture(nil, "OVERLAY", nil, 2)
        selBorder:SetAllPoints(swBase)
        selBorder:SetAtlas(ATLAS.DYE_FRAME_SELECTED)
        selBorder:Hide()
        f._selBorder = selBorder

        -- No "Slot N" label on the swatch -- the slot's hover tooltip (R.LiveSlot,
        -- wired in VDS_Controller_Live.lua) already names the slot + its dye.
        local nameBar = f:CreateTexture(nil, "ARTWORK", nil, 0)  -- tinted to dye color
        nameBar:SetPoint("LEFT",  swBase, "RIGHT", 4, 0)
        nameBar:SetPoint("RIGHT", f,      "RIGHT", 0, 0)
        nameBar:SetHeight(SLOT_SWATCH)
        nameBar:SetTexture("Interface\\AddOns\\VamoosesDyeStudio\\textures\\vds_swatch_tex")
        f._nameBar = nameBar

        local nameText = f:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        nameText:SetPoint("LEFT",  nameBar, "LEFT",  6, 0)
        nameText:SetPoint("RIGHT", nameBar, "RIGHT", -6, 0)
        nameText:SetJustifyH("CENTER")
        nameText:SetWordWrap(false)
        UI.applyFontRole(nameText, "body")
        f._nameText = nameText

        local hl = f:CreateTexture(nil, "HIGHLIGHT")
        hl:SetAllPoints()
        hl:SetTexture(WHITE)
        hl:SetVertexColor(1, 1, 1, 0.08)
        return f
    end,
    dispatch = { fields = { "data" }, push = dispatchSlotRow },
})

-- ============================================================================
-- channelRow -- per-channel state row in the canvas preview panel. Binding
-- shape: { index, dyeID, active, available } from canvas.channel.{1,2,3}.
-- Shows: 18px color swatch + "Ch N: <dye-or-empty>" label, with an accent
-- bar for the active channel and dim styling for unavailable channels.
-- ============================================================================

-- canvas.channel.N returns { index, dyeID, active, available }. Single "data"
-- dispatch field wraps the whole struct (BindingEngine scalar normalisation;
-- multi-field struct-spread not supported). Donor VDS_Canvas.lua:923-988.
local function dispatchChannelRow(widget, values)
    local data      = values.data or {}
    local idx       = data.index
    local dyeID     = data.dyeID
    local active    = data.active
    local available = data.available

    local dyeInfo = VDS.DyeOwnershipObserver:ResolveInfo(dyeID)

    if dyeInfo and dyeInfo.swatchStart and dyeInfo.swatchEnd then  -- exception(nullable): catalog lookup can miss pre-CATALOG_READY
        local cs, ce = dyeInfo.swatchStart, dyeInfo.swatchEnd
        widget._swBase:SetVertexColor(cs.r, cs.g, cs.b, 1); widget._swBase:Show()
        widget._swGrad:SetVertexColor(ce.r, ce.g, ce.b, 1); widget._swGrad:Show()
        widget._swEmpty:Hide()
        widget._nameBar:SetVertexColor(cs.r, cs.g, cs.b, 1)
        widget._label:SetText(dyeInfo.name or "")
    else
        widget._swBase:Hide()
        widget._swGrad:Hide()
        widget._swEmpty:Show()
        widget._nameBar:SetVertexColor(0.2, 0.2, 0.2, 1)
        widget._label:SetText(available and "Empty" or "(unavailable)")
    end

    if available and active then widget._selBorder:Show() else widget._selBorder:Hide() end
    if available then widget:SetAlpha(1.0) else widget:SetAlpha(0.35) end
    widget:EnableMouse(available == true)
    if active then
        widget._label:SetTextColor(1.00, 1.00, 1.00, 1)
    elseif available then
        widget._label:SetTextColor(0.85, 0.86, 0.88, 1)
    else
        widget._label:SetTextColor(0.45, 0.45, 0.47, 1)
    end
end

Types:Register("channelRow", {
    specFields = {},
    build = function(parent, _spec)
        local f = CreateFrame("Button", nil, parent)
        local SWATCH = 22                       -- donor's SWATCH_SIZE
        -- Height set by layout engine post-build via spec.height.

        local ATLAS = VDS.Constants.ATLAS
        local swBase = f:CreateTexture(nil, "ARTWORK", nil, 1)
        swBase:SetSize(SWATCH, SWATCH)
        swBase:SetPoint("LEFT", f, "LEFT", 0, 0)
        swBase:SetAtlas(ATLAS.DYE_SWATCH_BASE)
        f._swBase = swBase

        local swGrad = f:CreateTexture(nil, "ARTWORK", nil, 2)
        swGrad:SetAllPoints(swBase)
        swGrad:SetAtlas(ATLAS.DYE_SWATCH_GRADIENT)
        f._swGrad = swGrad

        local swEmpty = f:CreateTexture(nil, "ARTWORK", nil, 0)
        swEmpty:SetAllPoints(swBase)
        swEmpty:SetAtlas(ATLAS.DYE_SWATCH_EMPTY)
        f._swEmpty = swEmpty

        local border = f:CreateTexture(nil, "OVERLAY", nil, 1)
        border:SetAllPoints(swBase)
        border:SetAtlas(ATLAS.DYE_FRAME_DEFAULT)
        f._border = border

        local selBorder = f:CreateTexture(nil, "OVERLAY", nil, 2)
        selBorder:SetAllPoints(swBase)
        selBorder:SetAtlas(ATLAS.DYE_FRAME_SELECTED)
        selBorder:Hide()
        f._selBorder = selBorder

        local nameBar = f:CreateTexture(nil, "ARTWORK", nil, 0)  -- tinted to dye color
        nameBar:SetPoint("LEFT", swBase, "RIGHT", 4, 0)
        nameBar:SetPoint("RIGHT", f, "RIGHT", 0, 0)
        nameBar:SetHeight(SWATCH)
        nameBar:SetTexture("Interface\\AddOns\\VamoosesDyeStudio\\textures\\vds_swatch_tex")
        f._nameBar = nameBar

        local label = f:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        label:SetPoint("LEFT",  nameBar, "LEFT",  6, 0)
        label:SetPoint("RIGHT", nameBar, "RIGHT", -6, 0)
        label:SetJustifyH("CENTER")
        label:SetWordWrap(false)
        UI.applyFontRole(label, "body")
        f._label = label

        return f
    end,
    dispatch = { fields = { "data" }, push = dispatchChannelRow },
})

-- ============================================================================
-- modelPreview -- 3D decor preview (donor VDS_Canvas.lua:262-785).
-- Multi-field binding { decorID, dyes } per ADR-001; DecorInfoResolver:Resolve
-- at paint time for asset + uiModelSceneID.
-- ============================================================================

local DEFAULT_MODEL_SCENE_ID = 364   -- donor GetDefaultSceneID -- a generic housing decor scene

local function dispatchModelPreview(widget, values)
    local decorID = values.decorID
    if not decorID then
        if widget._label then
            widget._label:SetText("Select a decor item")
            widget._label:Show()
        end
        if widget._scene then widget._scene:Hide() end
        widget._currentDecorID = nil
        return
    end

    local info = VDS.DecorInfoResolver:Resolve(decorID)
    if not (info and info.asset) then  -- exception(nullable): resolver returns nil/partial pre-CATALOG_READY
        -- Catalog not primed yet for this decorID -- show placeholder + id.
        if widget._label then
            widget._label:SetText(string.format("Resolving decor #%d...", decorID))
            widget._label:Show()
        end
        if widget._scene then widget._scene:Hide() end
        return
    end

    if widget._label then widget._label:Hide() end
    if widget._scene then
        widget._scene:Show()
        -- Only (re)build the model + camera when the DECOR changes. A dye-only
        -- change (Random / Harmonise / picking a dye) must NOT re-transition the
        -- scene: TransitionToModelSceneID discards the active camera, throwing
        -- away the zoom/rotation the user set. Re-applying dyes below is enough.
        if decorID ~= widget._currentDecorID then
            widget._currentDecorID = decorID
            local sceneID = info.uiModelSceneID or DEFAULT_MODEL_SCENE_ID
            widget._scene:TransitionToModelSceneID(sceneID,
                _G.CAMERA_TRANSITION_TYPE_IMMEDIATE,
                _G.CAMERA_MODIFICATION_TYPE_DISCARD, true)
            local actor = widget._scene:GetActorByTag("decor")
            if actor then
                actor:SetPreferModelCollisionBounds(true)
                actor:SetModelByFileID(info.asset)
                widget._actor = actor
            end

            -- Donor VDS_Canvas.lua:754-776: remap left-drag to orbit (default is PAN);
            -- invert pitch so drag-up tilts backward (HDG pattern parity).
            local camera = widget._scene:GetActiveCamera()  -- nil pre-Transition complete
            if camera then
                camera:SetLeftMouseButtonXMode(_G.ORBIT_CAMERA_MOUSE_MODE_YAW_ROTATION, true)
                camera:SetLeftMouseButtonYMode(_G.ORBIT_CAMERA_MOUSE_MODE_PITCH_ROTATION, true)
                if not camera._vdsPitchHooked then
                    local orig = camera.HandleMouseMovement
                    camera.HandleMouseMovement = function(self, mode, delta, snap)
                        if mode == _G.ORBIT_CAMERA_MOUSE_MODE_PITCH_ROTATION then
                            delta = -delta
                        end
                        return orig(self, mode, delta, snap)
                    end
                    camera._vdsPitchHooked = true
                end
            end
        end
    end

    -- SetGradientMaskWithDyes absent on non-dyeable actors. -- boundary
    local dyes = values.dyes or {}
    if widget._actor and widget._actor.SetGradientMaskWithDyes then  -- exception(boundary): method absent on non-dyeable actors
        widget._actor:SetGradientMaskWithDyes(dyes[0], dyes[1], dyes[2])
    end
end

Types:Register("modelPreview", {
    specFields = {},
    build = function(parent, _spec)
        local f = CreateFrame("Frame", nil, parent)

        -- No backdrop: outer panel_bg shows through.
        local scene = CreateFrame("ModelScene", nil, f, "PanningModelSceneMixinTemplate")
        scene:SetAllPoints()
        scene:Hide()  -- hidden until decor selected
        f._scene = scene

        local label = f:CreateFontString(nil, "OVERLAY")
        label:SetPoint("CENTER")
        UI.applyFontRole(label, "body")
        label:SetTextColor(0.55, 0.55, 0.58, 1)
        label:SetText("Select a decor item")
        f._label = label
        return f
    end,
    dispatch = { fields = { "decorID", "dyes" }, push = dispatchModelPreview },
})

-- MinimalScrollBar polish (HDGR_Components.lua:1640): hide arrows, fill track,
-- auto-hide when content fits. Called by every scrollbox widget kind below.
local SCROLLBAR_RESERVE = 6   -- width reserved at scrollBox.BOTTOMRIGHT
local SCROLLBAR_OFFSET  = 2   -- x offset of the bar past scrollBox right edge

local function polishMinimalScrollBar(scrollBar)
    if not scrollBar then return end
    -- MinimalScrollBar template parts: Back / Forward / Track / Track.Thumb
    -- are guaranteed by FrameXML; no method-existence guards needed.
    scrollBar.Back:Hide()
    scrollBar.Forward:Hide()
    scrollBar.Track:ClearAllPoints()
    scrollBar.Track:SetPoint("TOPLEFT",     scrollBar, "TOPLEFT",     0, 0)
    scrollBar.Track:SetPoint("BOTTOMRIGHT", scrollBar, "BOTTOMRIGHT", 0, 0)
    scrollBar:SetHideIfUnscrollable(true)
    VDS.Theme:Register(scrollBar.Track.Thumb, "ScrollThumb")
end

Types:Register("scrollBoxList", {
    specFields = { "rowKinds", "extent" },
    build = function(parent, spec)
        local f = CreateFrame("Frame", nil, parent)

        local scrollBox = CreateFrame("Frame", nil, f, "WowScrollBoxList")
        scrollBox:SetPoint("TOPLEFT",     f, "TOPLEFT",      0, 0)
        scrollBox:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -SCROLLBAR_RESERVE, 0)

        local scrollBar = CreateFrame("EventFrame", nil, f, "MinimalScrollBar")
        scrollBar:SetPoint("TOPLEFT",    scrollBox, "TOPRIGHT",    SCROLLBAR_OFFSET, 0)
        scrollBar:SetPoint("BOTTOMLEFT", scrollBox, "BOTTOMRIGHT", SCROLLBAR_OFFSET, 0)
        polishMinimalScrollBar(scrollBar)

        local rowKinds = spec.rowKinds
        local extent   = spec.extent
        local view = _G.CreateScrollBoxListLinearView()
        -- Per-row extent calculator: fixed SetElementExtent leaves gaps with
        -- mixed row heights (dye 38 / herb 22 / header 18).
        local FW = VDS.Constants.FLOATING_WIDGET
        view:SetElementExtentCalculator(function(_index, elementData)
            local k = elementData and elementData.kind  -- exception(boundary): Blizzard view can probe extent before data binds
            if k == "herb" then return FW.DYE_HERB_ROW_HEIGHT + 2 end       -- 23
            if k == "header" or k == "herbHeader" then return 20 end
            if k == "groupHead" then return 24 end
            if k == "warehouseRow" then return 24 end
            return extent                                                    -- dye (38)
        end)
        -- SetElementInitializer (NOT SetElementFactory): initializer runs on
        -- every acquire (create + pool reuse). Factory callback only ran on
        -- create -- first SetDataProvider bound ok, subsequent calls left stale
        -- placeholder text (HDGR_Components.lua:1734; cookbook: row factories).
        -- Pool template stays "Button" (FrameFactory rejects non-type names like
        -- "dyeRow"); per-kind chrome built lazily and idempotently.
        view:SetElementInitializer("Button", function(button, elementData)
            local rowKind = rowKinds[elementData.kind]
            local rowSpec = rowKind and VDS.RowRegistry:Get(rowKind)
            if not rowSpec then return end
            if button._lsRowKind ~= rowKind then
                button._lsRowKind = rowKind
                if rowSpec.build then rowSpec.build(button) end
            end
            if rowSpec.reset then rowSpec.reset(button) end
            if rowSpec.init  then rowSpec.init(button, elementData) end
        end)

        _G.ScrollUtil.InitScrollBoxListWithScrollBar(scrollBox, scrollBar, view)

        f._scrollBox = scrollBox
        f._scrollBar = scrollBar
        f._view      = view
        return f
    end,
    dispatch = { fields = { "rows" }, push = dispatchScrollBoxList },
})

-- scrollBoxTreeList retired -- Phase 5 canvas refactor replaced the tree
-- picker with a flat scrollBoxList. No remaining consumers.

-- ============================================================================
-- linkRow -- 24px atlas icon + read-only EditBox (Ctrl+C to copy) + hint.
-- Spec: atlas (string), text (string URL), borderColor {r,g,b,a}, iconColor opt.
-- ============================================================================

Types:Register("linkRow", {
    specFields = { "atlas", "text", "borderColor", "iconColor" },
    build = function(parent, spec)
        local f = CreateFrame("Frame", nil, parent)
        -- Height set by layout engine post-build via spec.height.

        local icon = f:CreateTexture(nil, "ARTWORK")
        icon:SetSize(22, 22)
        icon:SetPoint("LEFT", f, "LEFT", 0, 0)
        if spec.atlas then icon:SetAtlas(spec.atlas) end
        if spec.iconColor then
            local c = spec.iconColor
            icon:SetVertexColor(c.r, c.g, c.b, c.a)
        end

        local box = CreateFrame("EditBox", nil, f, "BackdropTemplate")
        box:SetPoint("LEFT", icon, "RIGHT", 8, 0)
        box:SetPoint("RIGHT", f, "RIGHT", -60, 0)
        box:SetHeight(22)
        -- box inherits BackdropTemplate; SetBackdrop / SetScript always exist.
        box:SetBackdrop({
            bgFile   = "Interface\\Buttons\\WHITE8x8",
            edgeFile = "Interface\\Buttons\\WHITE8x8",
            edgeSize = 1,
        })
        -- surface.sunken must be registered in every scheme; let nil access crash to surface drift.
        local panel = VDS.Theme:GetColor("surface.sunken")
        box:SetBackdropColor(panel.r, panel.g, panel.b, 1)
        local bc = spec.borderColor or { r = 0.35, g = 0.40, b = 0.98, a = 0.6 }
        box:SetBackdropBorderColor(bc.r, bc.g, bc.b, bc.a)
        box:SetAutoFocus(false)
        box:SetTextInsets(8, 8, 0, 0)
        box:SetText(spec.text or "")
        UI.applyFontRole(box, "body")
        box:SetScript("OnEditFocusGained", function(self) self:HighlightText() end)
        box:SetScript("OnEscapePressed",   function(self) self:ClearFocus() end)
        box:SetScript("OnEnterPressed",    function(self) self:ClearFocus() end)
        f._box = box

        local hint = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        hint:SetPoint("LEFT", box, "RIGHT", 6, 0)
        UI.applyFontRole(hint, "small")
        hint:SetText("Ctrl+C")
        -- text.dim must be registered in every scheme; let nil access crash to surface drift.
        local dim = VDS.Theme:GetColor("text.dim")
        hint:SetTextColor(dim.r, dim.g, dim.b, 1)
        f._hint = hint

        return f
    end,
})
