---@diagnostic disable: undefined-field, undefined-global
local _, namespace = ...

-- Private and namespace are the SAME shared table (select(2, ...) everywhere else in the
-- addon). The feast descriptors below call into the Private:*FeastEditMode* surface, so this
-- file needs the alias bound explicitly — without it `Private` is a nil global and the
-- descriptors throw the moment BuildWidgets calls their get()/onClick.
local Private = namespace

local Theme = namespace.Theme

-- WTUI is namespace.WildThingsUI, the SAME shared table BankScreen.lua/InspectScreen.lua/
-- Shell.lua bind under this exact name (DESIGN-1c RESEARCH P1) -- the master/detail reshape
-- below needs it for AddRoundedFill/AddRoundedBorder/AddRoundedMask/AddHoverFade. Hand-
-- verified against UI/UI.lua ("namespace.WildThingsUI = {...}"). Getting this wrong fails
-- silently at runtime as a nil index, never at load, never in the checker (P1).
local WTUI = namespace.WildThingsUI

-- Approximate line height of the WildThingsFont13 title label EditBox/Dropdown
-- render above themselves (Widgets/EditBox.lua:65-69, Widgets/Dropdown.lua:23-28).
-- Neither widget exposes that label's own frame height, so this is a fixed
-- reserved-space constant rather than a measured value -- a cosmetic follow-up
-- if the real font metrics ever drift from this estimate.
local TITLE_H = 16

-- Own LibStub handle for the minimap-icon effect (WildThings.lua has its own separate
-- `LDBIcon` local — this file needs its own since there is no shared import mechanism).
local LDBIcon = LibStub("LibDBIcon-1.0")

-- READY-05: help copy shown above the per-spec candidate toggles, explaining the
-- generator's 30–120s window and why >=120s cooldowns are never listed (the game
-- resets them on a raid wipe, so they never need a ready-check reminder).
local COOLDOWN_HELP_TEXT = "Only spells with a 30–120s cooldown are listed. Cooldowns of "
    .. "2 minutes or longer are reset by the game when the raid wipes on a boss, so they "
    .. "never need a ready-check reminder. Every spell below is off until you enable it "
    .. "— pick the ones you want the ready-check to watch for each of your specs."

-- READY-05: appends a Cooldowns header + help paragraph only. The per-spec candidate
-- picker (dropdown adder + wrapping pill flow) is built separately by
-- CreateReadyCheckScreen via namespace:CreateSpecCooldownPicker, one per player spec.
local function AppendCooldownDescriptors(descriptors)
    table.insert(descriptors, {type = "header", label = "Cooldowns"})
    table.insert(descriptors, {type = "text", label = COOLDOWN_HELP_TEXT})
end

-- DESIGN-1c: card-interior row geometry. Deliberately 44/36/40 and NOT Theme.H_ROW (36):
-- this design uses a taller card row on the Settings screen specifically -- same
-- intentional local-override convention as RaidTimelineScreen.lua:1181-1185's
-- ROSTER_ROW_HEIGHT (26, also deliberately not Theme.H_ROW).
local CARD_ROW_H = 44
local CARD_SUBROW_H = 36    -- dimmed Automation sub-group rows (Task 2)
local CARD_SLIDERROW_H = 40 -- Reminder Types label+slider+readout+toggle one-liners (Task 2)

local NAV_W = 173       -- 20% narrower than the design's 216 (user request 2026-07-29),
                        -- giving the detail pane more room
local NAV_ROW_H = 32    -- design's per-category nav row height
local GRID_GAP = 16     -- design's 2-column card grid gap, also the gap between the panes
local NAV_PAD_X = 12    -- design's nav pane padding: "16px 12px" (vertical/horizontal)
local NAV_PAD_Y = 16
local CARD_PAD = 16     -- design's card interior padding: "0 16px"

-- DESIGN-1c §4: the 7 nav categories in the design's exact order. Declared explicitly, not
-- derived from first-appearance order in the descriptor array below: Automation's headers
-- (Resurrection/Quests/Summoning/Queue) appear before Raid inspect's in source order, which
-- would put Automation before Raid inspect -- the design puts Raid inspect first. Five of
-- the seven nav labels also don't equal any header string verbatim ("Interface", "Guild
-- Bank", "Break reminder", "Raid inspect", "Automation"), so an explicit table is needed
-- regardless (RESEARCH §4).
local CATEGORIES = {
    {key = "interface",  label = "Interface"},
    {key = "dashboard",  label = "Dashboard"},
    {key = "bank",       label = "Guild Bank"},   -- skipped when its card list is nil (no bank.view)
    {key = "reminders",  label = "Reminders"},
    {key = "break",      label = "Break reminder"},
    {key = "inspect",    label = "Raid inspect"},
    {key = "automation", label = "Automation"},
}

-- Splits the flat descriptor array into ordered per-card groups. A {type="header"}
-- descriptor opens a new card; every descriptor after it belongs to that card until the
-- next header. The header's `category` field routes the card into byCat[category]. MUST
-- run AFTER CreateSettingsScreen's bank.view filter, so a filtered-out Guild Bank header
-- produces no card and no nav entry -- no extra gating needed here (RESEARCH §4).
local function GroupIntoCards(descriptors)
    local byCat, card = {}, nil
    for _, d in ipairs(descriptors) do
        if d.type == "header" then
            card = {label = d.label, rows = {}}
            local list = byCat[d.category]
            if not list then list = {}; byCat[d.category] = list end
            list[#list + 1] = card
        elseif card then
            card.rows[#card.rows + 1] = d
        end
    end
    return byCat
end

-- namespace:CreateSettingsScreen(contentHost)
-- Settings screen (SET-01): a scrolling body listing General + Guild Bank toggles (via the
-- declarative namespace:BuildWidgets adapter, WTUI-04). The Ready-check toggles + per-spec
-- cooldown pickers moved to their own screen (namespace:CreateReadyCheckScreen below).
-- Each toggle descriptor's get() reads its live SavedVariable once at build time
-- (BuildWidgets' toggle adapter uses SetChecked(..., true) to suppress the initial
-- OnValueChanged) and, on change, set writes it back AND applies its effect.
function namespace:CreateSettingsScreen(contentHost)
    local root = CreateFrame("Frame", nil, contentHost)
    root:SetPoint("TOPLEFT", contentHost, "TOPLEFT", 0, 0)
    root:SetPoint("TOPRIGHT", contentHost, "TOPRIGHT", 0, 0)
    root:SetPoint("BOTTOMRIGHT", contentHost, "BOTTOMRIGHT", 0, 0)

    -- Phase 50 (FEAST-F2): defensive feast-structure accessors. These work
    -- across the old Phase-41 flat format ({enabled=bool, duration=number}),
    -- the post-migration nested format, and a fresh-install DEFAULTS table.
    local function _FeastGetDuration(typeKey)
        local sv = WildThingsSavedVars.feast
        if type(sv) ~= "table" then return 15 end
        if type(sv.duration) == "table" then
            return sv.duration[typeKey] or sv.defaultDuration or 15
        end
        if typeKey == "feast" and type(sv.duration) == "number" then
            return sv.duration
        end
        return sv.defaultDuration or 15
    end

    local function _FeastSetDuration(typeKey, v)
        local sv = WildThingsSavedVars.feast
        if type(sv) ~= "table" then return end
        if type(sv.duration) ~= "table" then
            sv.duration = { feast = nil, cauldron = nil, soulwell = nil, repair = nil }
        end
        local default = sv.defaultDuration or 15
        if v == default then
            sv.duration[typeKey] = nil
        else
            sv.duration[typeKey] = v
        end
    end

    local function _FeastIsEnabled(typeKey)
        local sv = WildThingsSavedVars.feast
        if type(sv) ~= "table" then return true end
        if type(sv.enabled) == "table" then
            return sv.enabled[typeKey]
        end
        if typeKey == "feast" and type(sv.enabled) == "boolean" then
            return sv.enabled
        end
        return true
    end

    local function _FeastSetEnabled(typeKey, checked)
        local sv = WildThingsSavedVars.feast
        if type(sv) ~= "table" then return end
        if type(sv.enabled) ~= "table" then
            sv.enabled = { feast = true, cauldron = true, soulwell = true, repair = true }
        end
        sv.enabled[typeKey] = checked
        -- Live-refresh the edit-mode preview when the user toggles a type
        -- while the anchor is unlocked.
        if Private.RefreshFeastEditPreview then
            Private:RefreshFeastEditPreview()
        end
    end

    local function _FeastLayout()
        local sv = WildThingsSavedVars.feast
        if type(sv) ~= "table" then return "horizontal" end
        return sv.layout or "horizontal"
    end

    local function _FeastSetLayout(v)
        local sv = WildThingsSavedVars.feast
        if type(sv) ~= "table" then return end
        sv.layout = v
        -- Live-refresh the edit-mode preview so the layout change is
        -- immediately visible while the anchor is unlocked.
        if Private.RefreshFeastEditPreview then
            Private:RefreshFeastEditPreview()
        end
    end

    local function _FeastDefaultDuration()
        local sv = WildThingsSavedVars.feast
        if type(sv) ~= "table" then return 15 end
        return sv.defaultDuration or 15
    end

    local function _FeastSetDefaultDuration(v)
        local sv = WildThingsSavedVars.feast
        if type(sv) ~= "table" then return end
        sv.defaultDuration = v
    end

    local descriptors = {
        {type = "header", label = "General", category = "interface"},

        -- 1. Show minimap icon — INVERTED sense: toggle-on means the icon is SHOWN, so it
        -- reads/writes the negation of WildThingsSavedVars.minimap.hide (pattern confirmed
        -- against WildThings.lua's ChatCommand "icon show|hide" branch).
        {
            type = "toggle",
            label = "Show minimap icon",
            get = function() return not WildThingsSavedVars.minimap.hide end,
            set = function(checked)
                WildThingsSavedVars.minimap.hide = not checked
                if checked then
                    LDBIcon:Show("WildThings")
                else
                    LDBIcon:Hide("WildThings")
                end
            end,
        },

        -- 2. Party mode.
        {
            type = "bound-toggle",
            path = "update.party_mode",
            label = "Party mode",
        },

        -- 3. Memes / ads.
        {
            type = "toggle",
            label = "Memes / ads",
            get = function() return WildThingsSavedVars.update.memes end,
            set = function(checked) WildThingsSavedVars.update.memes = checked end,
        },

        {type = "header", label = "Dashboard", category = "dashboard"},

        -- 4. 3D boss model — opt-OUT (default ON). Off hides the bottom section's left
        -- panel entirely and lets the four role columns take the full width; the repaint
        -- is what makes it visible without reopening the screen.
        {
            type = "toggle",
            label = "Show 3D boss model",
            description = "Off hides the left panel and widens the role columns.",
            get = function() return WildThingsSavedVars.timeline.show_boss_model ~= false end,
            set = function(checked)
                WildThingsSavedVars.timeline.show_boss_model = checked
                if namespace.RefreshTimelineScreen then
                    namespace:RefreshTimelineScreen()
                end
            end,
        },

        {type = "header", label = "Guild Bank", bankSection = true, category = "bank"},

        -- 5. Spill organize overflow to bags (OVERFLOW-02 opt-in, default OFF --
        -- default is leftmost-column consolidation; ON withdraws surplus into the
        -- presser's character bags instead).
        {
            type = "toggle",
            label = "Spill organize overflow to bags",
            description = "Withdraws surplus into your bags instead of consolidating columns.",
            get = function() return WildThingsSavedVars.bankorganize.spill_to_bags end,
            set = function(checked) WildThingsSavedVars.bankorganize.spill_to_bags = checked end,
            bankSection = true,
        },

        {type = "header", label = "Reminders", category = "reminders"},

        -- Default auto-hide duration in seconds (5-60). Used as the fallback
        -- for any type whose per-type override is nil. The display path reads
        -- the value live at show time, so a change applies to the next
        -- reminder with no reload.
        {
            type = "slider",
            label = "Default display duration (s)",
            description = "Fallback for every type without its own value.",
            min = 5,
            max = 60,
            get = _FeastDefaultDuration,
            set = _FeastSetDefaultDuration,
        },

        -- Icon layout direction. When ON, icons stack vertically; when OFF,
        -- they arrange side-by-side (horizontal). Default horizontal.
        {
            type = "toggle",
            label = "Stack reminder icons vertically",
            get = function() return _FeastLayout() == "vertical" end,
            set = function(checked)
                _FeastSetLayout(checked and "vertical" or "horizontal")
            end,
        },

        -- Unlock reminder position (FEAST-03 edit mode). This toggle is
        -- deliberately the only way in and out of edit mode: the toggle's
        -- get() runs once at build time and this screen discards the refresh
        -- handle, so any second exit path would leave the checkbox showing a
        -- state that is not true.
        {
            type = "toggle",
            label = "Unlock reminder position",
            get = function()
                if Private.IsFeastEditMode then return Private:IsFeastEditMode() end
                return false
            end,
            set = function(checked)
                if checked then
                    if Private.EnterFeastEditMode then Private:EnterFeastEditMode() end
                else
                    if Private.ExitFeastEditMode then Private:ExitFeastEditMode() end
                end
            end,
        },

        -- Reset reminder position to default (FEAST-03 reset). DESIGN-1c §1.1/§1.3:
        -- merged onto the previous "Unlock reminder position" row as a small pill button
        -- instead of its own row -- that row's own label already carries the meaning, and
        -- this merge is what turns the Reminders rendered-row count from 4 to 3.
        {
            type = "button",
            label = "Reset",
            mergeWithPrevious = true,
            onClick = function()
                if Private.ResetFeastAnchorPosition then Private:ResetFeastAnchorPosition() end
            end,
        },

        {type = "header", label = "Reminder Types", category = "reminders"},

        -- Per-type enable toggle (each type has its own independent on/off).
        {
            type = "toggle",
            label = "Feast reminder", -- "Show " prefix dropped per user request 2026-07-29
            get = function() return _FeastIsEnabled("feast") end,
            set = function(checked) _FeastSetEnabled("feast", checked) end,
        },
        -- DESIGN-1c §1.3 sliderrow: merges onto the preceding toggle's row instead of
        -- opening its own, producing one label+slider+readout+toggle line. Turns the
        -- Reminder Types rendered-row count from 8 to 4.
        {
            type = "slider",
            label = "Feast duration (s)",
            mergeWithPrevious = true,
            min = 5,
            max = 60,
            get = function() return _FeastGetDuration("feast") end,
            set = function(v) _FeastSetDuration("feast", v) end,
        },

        {
            type = "toggle",
            label = "Cauldron reminder", -- "Show " prefix dropped per user request 2026-07-29
            get = function() return _FeastIsEnabled("cauldron") end,
            set = function(checked) _FeastSetEnabled("cauldron", checked) end,
        },
        {
            type = "slider",
            label = "Cauldron duration (s)",
            mergeWithPrevious = true,
            min = 5,
            max = 60,
            get = function() return _FeastGetDuration("cauldron") end,
            set = function(v) _FeastSetDuration("cauldron", v) end,
        },

        {
            type = "toggle",
            label = "Soulwell reminder", -- "Show " prefix dropped per user request 2026-07-29
            get = function() return _FeastIsEnabled("soulwell") end,
            set = function(checked) _FeastSetEnabled("soulwell", checked) end,
        },
        {
            type = "slider",
            label = "Soulwell duration (s)",
            mergeWithPrevious = true,
            min = 5,
            max = 60,
            get = function() return _FeastGetDuration("soulwell") end,
            set = function(v) _FeastSetDuration("soulwell", v) end,
        },

        {
            type = "toggle",
            label = "Repair reminder", -- "Show " prefix dropped per user request 2026-07-29
            get = function() return _FeastIsEnabled("repair") end,
            set = function(checked) _FeastSetEnabled("repair", checked) end,
        },
        {
            type = "slider",
            label = "Repair duration (s)",
            mergeWithPrevious = true,
            min = 5,
            max = 60,
            get = function() return _FeastGetDuration("repair") end,
            set = function(v) _FeastSetDuration("repair", v) end,
        },

        {type = "header", label = "Break Reminder", category = "break"},

        -- 10. Show break reminder (BREAK-01 master toggle, default ON).
        {
            type = "toggle",
            label = "Show break reminder",
            get = function() return WildThingsSavedVars.breakreminder.enabled end,
            set = function(checked) WildThingsSavedVars.breakreminder.enabled = checked end,
        },

        -- 11. Unlock reminder position (BREAK-03 edit mode). This toggle is
        -- deliberately the only way in and out of edit mode — the toggle's
        -- get() runs once at build time and this screen discards the refresh
        -- handle, so any second exit path would leave the checkbox stale.
        {
            type = "toggle",
            label = "Unlock reminder position",
            get = function()
                if Private.IsBreakEditMode then return Private:IsBreakEditMode() end
                return false
            end,
            set = function(checked)
                if checked then
                    if Private.EnterBreakEditMode then Private:EnterBreakEditMode() end
                else
                    if Private.ExitBreakEditMode then Private:ExitBreakEditMode() end
                end
            end,
        },

        -- 12. Reset reminder position to default (BREAK-03 reset). DESIGN-1c §1.1/§1.3:
        -- merged onto the previous "Unlock reminder position" row -- turns the Break
        -- Reminder rendered-row count from 3 to 2.
        {
            type = "button",
            label = "Reset",
            mergeWithPrevious = true,
            onClick = function()
                if Private.ResetBreakAnchorPosition then Private:ResetBreakAnchorPosition() end
            end,
        },

        {type = "header", label = "Resurrection", category = "automation"},

        -- 13. Auto-accept resurrection (AUTORES-04-Master-Toggle, standardmäßig AUS).
        {
            type = "toggle",
            label = "Auto-accept resurrection",
            get = function() return WildThingsSavedVars.autoresaccept.enabled end,
            set = function(checked) WildThingsSavedVars.autoresaccept.enabled = checked end,
        },

        -- Die beiden Ausschlusszeilen bleiben auch bei deaktiviertem Master flach
        -- und bearbeitbar. Der Lebenszyklus des Controls wertet den Zeilenstatus
        -- beim Aufbau des Screens aus, nicht erneut bei Änderung einer anderen Zeile;
        -- ein Live-Greyout wäre daher irreführend.
        -- 14. Exclude combat resurrection (AUTORES-04, standardmäßig AN). DESIGN-1c §1.3
        -- data-group: visual nesting ONLY (indent + dim + left rule) -- these rows stay
        -- flat and always editable, per the comment above; no enable/disable logic is added.
        {
            type = "toggle",
            label = "Exclude combat resurrection",
            subGroup = true,
            get = function() return WildThingsSavedVars.autoresaccept.excludeCombat end,
            set = function(checked) WildThingsSavedVars.autoresaccept.excludeCombat = checked end,
        },
        -- 15. Exclude afterlife resurrection (AUTORES-04, standardmäßig AN).
        {
            type = "toggle",
            label = "Exclude afterlife resurrection",
            subGroup = true,
            get = function() return WildThingsSavedVars.autoresaccept.excludeAfterlife end,
            set = function(checked) WildThingsSavedVars.autoresaccept.excludeAfterlife = checked end,
        },

        {type = "header", label = "Quests", category = "automation"},

        -- Beide Zeilen bleiben auch bei deaktiviertem Master flach und
        -- bearbeitbar -- gleiche Begründung wie beim Resurrection-Block oben:
        -- BuildWidgets wertet den Zeilenstatus beim Aufbau des Screens aus,
        -- nicht erneut bei Änderung einer anderen Zeile.
        -- 16. Auto-accept shared quests (QUESTACCEPT-01-Master-Toggle, standardmäßig AUS).
        {
            type = "toggle",
            label = "Auto-accept shared quests",
            get = function() return WildThingsSavedVars.autoquestaccept.enabled end,
            set = function(checked) WildThingsSavedVars.autoquestaccept.enabled = checked end,
        },
        -- 17. Only from guild members and friends (QUESTACCEPT-04, standardmäßig AN).
        -- DESIGN-1c §1.3 data-group: same visual-nesting-only treatment as the
        -- Resurrection exclusion rows above.
        {
            type = "toggle",
            label = "Only from guild members and friends",
            subGroup = true,
            get = function() return WildThingsSavedVars.autoquestaccept.friendsOnly end,
            set = function(checked) WildThingsSavedVars.autoquestaccept.friendsOnly = checked end,
        },

        {type = "header", label = "Raid inspect", category = "inspect"},

        -- 13. Crest threshold (INSPECT-10, default 5, range 0-100).
        -- Players with fewer Myth Dawncrests than this value are flagged
        -- with WARN color and sorted to the top of their column.
        {
            type = "slider",
            label = "Crest threshold",
            description = "Flags players below this many Myth Dawncrests.",
            min = 0,
            max = 100,
            get = function() return WildThingsSavedVars.inspect.crestThreshold end,
            set = function(v) WildThingsSavedVars.inspect.crestThreshold = v end,
        },

        -- 11. Combat potion threshold (INSPECT-F1, default 2, range 0-20).
        -- Players with fewer combat/throughput potions than this value are flagged.
        {
            type = "slider",
            label = "Combat potion threshold",
            description = "Flags players below this many combat potions.",
            min = 0,
            max = 20,
            get = function() return WildThingsSavedVars.inspect.combatPotionThreshold end,
            set = function(v) WildThingsSavedVars.inspect.combatPotionThreshold = v end,
        },

        -- 12. Flask threshold (INSPECT-10, default 1, range 0-1).
        -- Players with fewer fleeting flasks than this value are flagged.
        {
            type = "slider",
            label = "Flask threshold",
            description = "Flags players below this many fleeting flasks.",
            min = 0,
            max = 1,
            get = function() return WildThingsSavedVars.inspect.flaskThreshold end,
            set = function(v) WildThingsSavedVars.inspect.flaskThreshold = v end,
        },

        {type = "header", label = "Summoning", category = "automation"},

        -- 13. Auto-open the Summoning Window after Invite & Arrange finishes
        -- (SUMMON-01, opt-out default ON). Never affects `/wt summons`, which
        -- always opens the window on demand regardless of this toggle. See
        -- .planning/v2.7-MILESTONE-AUDIT.md §1.
        {
            type = "toggle",
            label = "Auto-open Summoning Window after arrange",
            description = "Never affects /wt summons.",
            get = function() return WildThingsSavedVars.summons.auto_open end,
            set = function(checked) WildThingsSavedVars.summons.auto_open = checked end,
        },

        {type = "header", label = "Queue", category = "automation"},

        -- 14. Auto-accept role check (QUICK-V6N-01 master toggle, standardmäßig
        -- AUS). Confirms with whatever role the LFG tool holds -- there is
        -- deliberately no role picker here, so a single toggle is the whole
        -- section. Stays flat and always editable even when off, same
        -- reasoning as the Resurrection section above (BuildWidgets evaluates
        -- row state at build time only; a live grey-out would be misleading).
        {
            type = "toggle",
            label = "Auto-accept role check",
            description = "Confirms with whatever role the LFG tool holds.",
            get = function() return WildThingsSavedVars.autoqueue.enabled end,
            set = function(checked) WildThingsSavedVars.autoqueue.enabled = checked end,
        },
    }

    -- GATE-06: fully omit (not shown-disabled) the Guild Bank header + spill_to_bags
    -- toggle for viewers lacking bank.view. BuildWidgets has no requires/capability
    -- concept, so filter the descriptors array once here, before it runs.
    if not Private:ViewerHasCapability("bank.view") then
        local filtered = {}
        for _, d in ipairs(descriptors) do
            if not d.bankSection then filtered[#filtered + 1] = d end
        end
        descriptors = filtered
    end

    -- DESIGN-1c: group the filtered descriptor array into per-category card lists.
    local byCat = GroupIntoCards(descriptors)

    ------------------------------------------------------------------
    -- Nav pane (left, 216px, NOT inside a scroll frame -- RESEARCH §9: seven fixed rows
    -- plus a bottom-pinned button).
    ------------------------------------------------------------------
    local navPane = CreateFrame("Frame", nil, root)
    navPane:SetWidth(NAV_W)
    navPane:SetPoint("TOPLEFT", root, "TOPLEFT", Theme.PAD_WINDOW, -Theme.PAD_WINDOW)
    navPane:SetPoint("BOTTOMLEFT", root, "BOTTOMLEFT", Theme.PAD_WINDOW, Theme.PAD_WINDOW)

    local navHeader = navPane:CreateFontString(nil, "ARTWORK")
    navHeader:SetFontObject(WildThingsFont12)
    navHeader:SetText("SETTINGS")
    navHeader:SetTextColor(Theme.TEXT_SECTION.r, Theme.TEXT_SECTION.g, Theme.TEXT_SECTION.b, Theme.TEXT_SECTION.a)
    navHeader:SetPoint("TOPLEFT", navPane, "TOPLEFT", NAV_PAD_X, -NAV_PAD_Y)

    ------------------------------------------------------------------
    -- Detail pane (right, flex, the ONLY scrolling region). The Theme.PAD_WINDOW insets
    -- that used to wrap the single scroll frame now wrap the nav/detail pair; the gap
    -- between panes is GRID_GAP. Read scrollFrame.scrollBarReserve off the returned scroll
    -- frame for the right-hand gutter rather than re-deriving it (ScrollFrame.lua:10-17).
    ------------------------------------------------------------------
    local scrollFrame = namespace:CreateScrollFrame(root)
    scrollFrame:SetPoint("TOPLEFT", navPane, "TOPRIGHT", GRID_GAP, 0)
    scrollFrame:SetPoint("BOTTOMRIGHT", root, "BOTTOMRIGHT", -Theme.PAD_WINDOW, Theme.PAD_WINDOW)

    -- Width-before-measure (RESEARCH P3): content width must be bound before anything
    -- measures against it. `or 1` alone does not guard -- GetWidth() returns 0 on this
    -- same-frame construction pass, and `0 or 1` is 0 in Lua.
    local viewportWidth = scrollFrame.scrollFrame:GetWidth()
    if not viewportWidth or viewportWidth < 1 then viewportWidth = 1 end
    scrollFrame.contentFrame:SetWidth(viewportWidth)

    local hosts, navRows = {}, {}
    local selectedKey
    local SelectCategory -- forward-declared; assigned after the build loop below, once
                         -- CreateNavRow's OnClick closures already reference this upvalue

    -- BuildCardRow(card, def, isFirst, yOffset) -> row, height
    -- Shared row builder for every card (DESIGN-1c §1.3). Every row is full card width
    -- minus the card's own inner padding, label FontString left-aligned, control cluster
    -- anchored to the row's RIGHT, and -- on every row except the first in its card -- a
    -- 1px top hairline in Theme.BORDER with the pixel-grid opt-out every hairline in this
    -- repo needs (SectionHeader.lua:31-32, Shell.lua:154-155) or it renders blurry/absent
    -- at fractional UI scales. `rowHeight` lets the caller open this row at CARD_ROW_H
    -- (default), CARD_SUBROW_H (dimmed Automation sub-group rows) or CARD_SLIDERROW_H (a
    -- toggle row a slider descriptor will merge onto, Task 2). The row's own `ctrl` (its
    -- primary control) is stashed on the returned frame so BuildCard's merge path below can
    -- anchor a merged button/slider to its LEFT.
    local function BuildCardRow(card, def, isFirst, yOffset, rowHeight)
        rowHeight = rowHeight or CARD_ROW_H
        local row = CreateFrame("Frame", nil, card)
        row:SetHeight(rowHeight)
        row:SetPoint("TOPLEFT", card, "TOPLEFT", CARD_PAD, yOffset)
        row:SetPoint("TOPRIGHT", card, "TOPRIGHT", -CARD_PAD, yOffset)

        if not isFirst then
            local hairline = row:CreateTexture(nil, "ARTWORK")
            hairline:SetHeight(1)
            hairline:SetColorTexture(Theme.BORDER.r, Theme.BORDER.g, Theme.BORDER.b, Theme.BORDER.a)
            hairline:SetSnapToPixelGrid(false)
            hairline:SetTexelSnappingBias(0)
            hairline:SetPoint("TOPLEFT", row, "TOPLEFT", 0, 0)
            hairline:SetPoint("TOPRIGHT", row, "TOPRIGHT", 0, 0)
        end

        -- Control built BEFORE label/description (DESIGN-1c bugfix 2026-07-29, reordered
        -- from the original label-then-control layout): the description FontString below
        -- needs `ctrl` to already exist so it can stop short of it instead of running
        -- underneath/past it. Stashed on the returned row so BuildCard's merge path
        -- (mergeWithPrevious, Task 2) can also anchor a pill button or a sliderrow slider
        -- to this row's primary control.
        local ctrl
        -- How far the control's own RIGHT edge sits in from the row's right edge. Only the
        -- slider branch is non-zero (its numeric readout hangs outside the frame's declared
        -- width, see below). Folded into `row.ctrlSpan` at the bottom of this function, which
        -- is what lets ReflowCard give the description FontString an EXPLICIT wrap width.
        local ctrlInset = 0

        if def.type == "toggle" then
            ctrl = namespace:CreateToggle(row, function(checked) def.set(checked) end)
            ctrl:SetPoint("RIGHT", row, "RIGHT", 0, 0)
            -- true = suppress the initial OnValueChanged fire (BuildWidgets.lua:44,
            -- Toggle.lua:120,136-139) -- omitting it writes the SavedVar back and fires
            -- every side effect (LDBIcon:Show, RefreshTimelineScreen, EnterFeastEditMode)
            -- as a side effect of merely opening the screen (RESEARCH P7).
            ctrl:SetChecked(def.get(), true)

        elseif def.type == "bound-toggle" then
            -- Shallow copy: BindWidget mutates def.type to "toggle" in place, and this
            -- descriptor array is walked more than once (GroupIntoCards already ran over
            -- it above) -- mutating the original would corrupt it for the next pass.
            local copy = {}
            for k, v in pairs(def) do copy[k] = v end
            copy.type = "toggle"
            local boundRow = WTUI:BindWidget(row, copy)
            boundRow:SetAllPoints(row)
            -- Re-anchor BindWidget's own shape (a Theme.H_ROW-tall frame with the toggle
            -- LEFT and its label FontString RIGHT of it, BindWidget.lua:137-155) to this
            -- card row's label-left/control-right anatomy: stretch the row, then
            -- ClearAllPoints + re-anchor its single child (the toggle) RIGHT and its
            -- single region (the label) LEFT. This preserves BindWidget's dirty-flag
            -- decoration and _boundWidgets registration (BindWidget.lua:246-247), which a
            -- hand-built toggle would lose.
            for _, child in ipairs({boundRow:GetChildren()}) do
                child:ClearAllPoints()
                child:SetPoint("RIGHT", row, "RIGHT", 0, 0)
                ctrl = child
            end
            for _, region in ipairs({boundRow:GetRegions()}) do
                if region.GetObjectType and region:GetObjectType() == "FontString" then
                    region:ClearAllPoints()
                    region:SetPoint("LEFT", row, "LEFT", 0, 0)
                end
            end

        elseif def.type == "slider" then
            -- Empty title + hidden Low/High: the design's row anatomy is a single
            -- "label + slider + numeric readout" line (DESIGN-1c.md §1.3) -- passing
            -- def.label into CreateSlider's own title would duplicate the label already
            -- built above, and Low/High aren't part of the mock's row at all.
            -- slider.Text/.Low/.High are plain FontStrings on the returned frame
            -- (Slider.lua:32,38,46); hiding one is inert in WoW (RESEARCH A2).
            ctrl = namespace:CreateSlider(row, "", def.min, def.max,
                function(v) def.set(v) end, def.get())
            ctrl.Text:Hide()
            ctrl.Low:Hide()
            ctrl.High:Hide()
            -- DESIGN-1c bugfix 2026-07-29: ctrl.valueBox (the numeric readout) is a CHILD
            -- anchored Theme.GAP beyond THIS frame's own right edge (Slider.lua:104-106) --
            -- it is not part of the frame's declared width. The original code left the
            -- widget's DEFAULT_WIDTH (200, Slider.lua:6) and anchored flush to the row's
            -- right edge, which pushed both the track and the readout out past the card
            -- border. Size the track to the design's 120px (DESIGN-1c.md §1.3's standalone
            -- slider width) and pull the frame's own right edge that much further left so
            -- the READOUT, not the frame, ends up flush with the row.
            ctrl:SetSize(120, 20)
            ctrlInset = Theme.GAP + ctrl.valueBox:GetWidth()
            ctrl:SetPoint("RIGHT", row, "RIGHT", -ctrlInset, 0)

        elseif def.type == "button" then
            ctrl = namespace:CreateButton(row, def.label, function() def.onClick() end)
            ctrl:SetPoint("RIGHT", row, "RIGHT", 0, 0)
        end

        -- bound-toggle supplies its own label region (BindWidget.lua's `text` FontString,
        -- re-anchored above) -- building a second one here would show the label twice.
        local label
        if def.type ~= "bound-toggle" then
            label = row:CreateFontString(nil, "ARTWORK")
            label:SetFontObject(WildThingsFont13)
            label:SetText(def.label)
            label:SetTextColor(Theme.TEXT.r, Theme.TEXT.g, Theme.TEXT.b, Theme.TEXT.a)
            label:SetJustifyH("LEFT")
            if def.description then
                -- Anchored to the row's TOP, not to its vertical CENTRE (DESIGN-1c bugfix
                -- 2026-07-29, 3rd follow-up). `row, "LEFT"` is (row left edge, row centre y),
                -- so the old point put the label's top 7px above the row's CENTRE -- which is
                -- 7px below the row's TOP only when the row happens to be exactly 28px tall.
                -- ReflowCard sizes a description row to 7 + labelH + 3 + descH + 7, so at any
                -- height above 28 the whole text block sat (rowH - 28) / 2 too low and hung
                -- out past the row's bottom edge: the reported "descriptions overflow
                -- downwards". Top-anchoring makes that height formula and the actual content
                -- agree exactly, at every row height.
                label:SetPoint("TOPLEFT", row, "TOPLEFT", 0, -7)
            else
                label:SetPoint("LEFT", row, "LEFT", 0, 0)
            end
        end

        -- Stashed on the row (with label/ctrl below) so ReflowCard can re-measure this
        -- row's actual wrapped height once the card has a real, final width (BuildCard
        -- runs before any card's width is known -- DESIGN-1c bugfix follow-up 2026-07-29).
        local desc
        if def.description then
            desc = row:CreateFontString(nil, "ARTWORK")
            desc:SetFontObject(WildThingsFont12)
            desc:SetText(def.description)
            desc:SetTextColor(Theme.TEXT_DIM.r, Theme.TEXT_DIM.g, Theme.TEXT_DIM.b, Theme.TEXT_DIM.a)
            desc:SetJustifyH("LEFT")
            desc:SetPoint("TOPLEFT", label, "BOTTOMLEFT", 0, -3)
            desc:SetWordWrap(true)
            -- ONE anchor point plus an explicit width -- the canonical idiom for a wrapping
            -- FontString (DESIGN-1c bugfix 2026-07-29, 3rd follow-up). The previous version
            -- derived the wrap width from a second point, RIGHT -> ctrl's LEFT. That point
            -- also pins the region's vertical CENTRE, and together with the TOPLEFT point
            -- above that pins its HEIGHT as well: height = 2 * (top - centre), which for this
            -- anatomy (top below the row's centre, centre level with it) came out NEGATIVE.
            -- An over-constrained region has an undefined rect, so the width GetStringHeight()
            -- measured against could not be trusted -- which is why ReflowCard kept getting a
            -- single-line answer for a description that visibly wrapped to two.
            -- With SetWidth the width is explicit and takes effect immediately, and the height
            -- stays purely content-derived. ReflowCard fills in the real width as soon as the
            -- card has one; this placeholder only covers the frames before that first pass
            -- (and any future subGroup row with a description -- none exist today, and those
            -- are the only rows ReflowCard does not walk).
            desc:SetWidth(200)
        end

        row.ctrl = ctrl
        row.label = label
        row.desc = desc
        -- Horizontal space the control cluster claims at the row's right end, including the
        -- gap that separates it from the text column. ReflowCard subtracts this from the row
        -- width to get the description's wrap width; MergeIntoRow adds to it when it hangs a
        -- second control further left. Computed from the widgets' own declared sizes rather
        -- than read back off the anchor chain, which has not resolved yet at build time.
        row.ctrlSpan = ctrl and (ctrl:GetWidth() + ctrlInset + Theme.GAP) or 0
        return row, rowHeight
    end

    -- MergeIntoRow(prevRow, def) -- DESIGN-1c §1.1/§1.3: a descriptor carrying
    -- mergeWithPrevious does not open its own row; it appends to the previous row instead,
    -- anchored to the LEFT of that row's own primary control with the standard row gap
    -- (Theme.GAP). Two variants used by this screen's descriptors:
    --   button -> a small pill button (e.g. "Reset" next to "Unlock reminder position").
    --   slider -> the sliderrow variant: empty title, Low/High hidden, 110px track (the
    --     design's sliderrow width, not CreateSlider's 200px default) -- same suppression
    --     idiom BuildCardRow's own standalone slider branch uses, RESEARCH A2.
    local function MergeIntoRow(prevRow, def)
        local anchor = prevRow.ctrl
        if def.type == "button" then
            local pill = namespace:CreateButton(prevRow, def.label, function() def.onClick() end)
            pill:SetPoint("RIGHT", anchor, "LEFT", -Theme.GAP, 0)
            -- Keep row.ctrlSpan (BuildCardRow) honest: the text column now has to stop short
            -- of THIS control, not of the row's original one. No row carrying a description
            -- takes the merge path today, so this is pre-emptive rather than load-bearing.
            prevRow.ctrlSpan = prevRow.ctrlSpan + Theme.GAP + pill:GetWidth()
        elseif def.type == "slider" then
            local slider = namespace:CreateSlider(prevRow, "", def.min, def.max,
                function(v) def.set(v) end, def.get())
            slider.Text:Hide()
            slider.Low:Hide()
            slider.High:Hide()
            slider:SetSize(110, 20)
            -- DESIGN-1c bugfix 2026-07-29: same valueBox-overhang issue as BuildCardRow's
            -- standalone slider branch (Slider.lua:104-106) -- PLUS this sliderrow variant
            -- anchors next to the toggle it merges onto, not the row's own right edge. The
            -- old single-GAP offset left no room for the readout at all, so it rendered on
            -- top of the toggle. Leave room for the readout (Theme.GAP + its own width)
            -- AND the standard gap before the toggle.
            slider:SetPoint("RIGHT", anchor, "LEFT",
                -(Theme.GAP + slider.valueBox:GetWidth() + Theme.GAP), 0)
            -- Same bookkeeping as the button variant above.
            prevRow.ctrlSpan = prevRow.ctrlSpan
                + Theme.GAP + slider.valueBox:GetWidth() + Theme.GAP + slider:GetWidth()
        end
    end

    -- BuildCard(parent, cardDef) -> box, rowCount
    -- Chrome verbatim from InspectScreen.lua:574-579. Every child anchors CARD_PAD (16px,
    -- comfortably >1px) inside the box, satisfying AddRoundedBorder's mandatory
    -- caller-inset contract. rowCount is how many rows this card actually rendered --
    -- summed per category below and fed to the nav badge, never hardcoded (RESEARCH §7).
    local function BuildCard(parent, cardDef)
        local box = CreateFrame("Frame", nil, parent)
        WTUI:AddRoundedFill(box, Theme.RADIUS_MD, Theme.SURFACE_1)
        WTUI:AddRoundedBorder(box, Theme.RADIUS_MD)
        box:SetBorderColor(Theme.BORDER.r, Theme.BORDER.g, Theme.BORDER.b, Theme.BORDER.a)

        -- Card head: plain uppercase label, no rule (RESEARCH P6 -- NOT
        -- namespace:CreateSectionHeader, which self-anchors full-width and always draws a
        -- trailing hairline the design does not have). The head string is the existing
        -- header descriptor's own label.
        local head = box:CreateFontString(nil, "ARTWORK")
        head:SetFontObject(WildThingsFont12)
        head:SetText(cardDef.label:upper())
        head:SetTextColor(Theme.TEXT_SECTION.r, Theme.TEXT_SECTION.g, Theme.TEXT_SECTION.b, Theme.TEXT_SECTION.a)
        head:SetPoint("TOPLEFT", box, "TOPLEFT", CARD_PAD, -14)
        local headH = 14 + head:GetStringHeight() + 10 -- design's "padding:14px 0 10px"

        local yOffset = -headH
        local rowCount = 0
        local isFirst = true
        local prevRow

        -- Ordered list of {kind="row", frame=} / {kind="subgroup", frame=} entries, one per
        -- top-level thing built directly under `box` (subGroup rows are NOT added
        -- individually -- only their closed container is). Stashed on cardDef so
        -- ReflowCard (below) can re-measure any row's description and reposition
        -- everything after it once this card has a real, final width -- BuildCard runs
        -- before any card's width is known, so a description that wraps to more lines than
        -- CARD_ROW_H assumed left the row (and the card's own bottom margin) too short
        -- (DESIGN-1c bugfix follow-up 2026-07-29).
        local items = {}

        -- Dimmed sub-group state (DESIGN-1c §1.3 data-group): a run of consecutive
        -- subGroup descriptors is wrapped in one container Frame, indented 14px from the
        -- card's inner left edge with a 1px Theme.ACCENT_A20 left rule, alpha 0.4 on the
        -- whole container. This is visual nesting ONLY -- the rows stay interactive even
        -- with their master toggle off, matching the shipped screen's own documented
        -- reasoning (get() runs once at build time; a live grey-out would misrepresent
        -- state). Built by the SAME BuildCardRow, at CARD_SUBROW_H instead of CARD_ROW_H.
        local subGroupContainer, subGroupY, subGroupFirst

        local rows = cardDef.rows
        local i = 1
        while i <= #rows do
            local def = rows[i]

            if def.mergeWithPrevious then
                -- Does not open a row: appends to the previous row instead (RESEARCH §7 --
                -- this is what turns 4 rendered rows into 3 for Reminders/Unlock+Reset, and
                -- 8 into 4 for the Reminder Types sliderrows).
                MergeIntoRow(prevRow, def)

            elseif def.subGroup then
                if not subGroupContainer then
                    subGroupContainer = CreateFrame("Frame", nil, box)
                    subGroupContainer:SetPoint("TOPLEFT", box, "TOPLEFT", CARD_PAD + 14, yOffset)
                    subGroupContainer:SetPoint("TOPRIGHT", box, "TOPRIGHT", -CARD_PAD, yOffset)
                    subGroupContainer:SetAlpha(0.4)

                    local rule = subGroupContainer:CreateTexture(nil, "ARTWORK")
                    rule:SetWidth(1)
                    rule:SetColorTexture(Theme.ACCENT_A20.r, Theme.ACCENT_A20.g,
                        Theme.ACCENT_A20.b, Theme.ACCENT_A20.a)
                    rule:SetSnapToPixelGrid(false)
                    rule:SetTexelSnappingBias(0)
                    rule:SetPoint("TOPLEFT", subGroupContainer, "TOPLEFT", 0, 0)
                    rule:SetPoint("BOTTOMLEFT", subGroupContainer, "BOTTOMLEFT", 0, 0)

                    subGroupY = 0
                    subGroupFirst = true
                end

                local row, h = BuildCardRow(subGroupContainer, def, subGroupFirst, subGroupY, CARD_SUBROW_H)
                subGroupY = subGroupY - h
                subGroupFirst = false
                rowCount = rowCount + 1
                prevRow = row

                -- Close the container once the run of subGroup descriptors ends (the next
                -- descriptor is absent or is not itself subGroup) -- consumes the run's
                -- total height from the card's own yOffset accumulator.
                local nextDef = rows[i + 1]
                if not (nextDef and nextDef.subGroup) then
                    subGroupContainer:SetHeight(-subGroupY)
                    yOffset = yOffset - (-subGroupY)
                    isFirst = false
                    items[#items + 1] = {kind = "subgroup", frame = subGroupContainer}
                    subGroupContainer = nil
                end

            else
                -- Lookahead: a toggle immediately followed by a merged slider becomes a
                -- sliderrow host and opens at CARD_SLIDERROW_H instead of CARD_ROW_H
                -- (Reminder Types' 4 toggle+slider one-liners).
                local nextDef = rows[i + 1]
                local rowHeight = CARD_ROW_H
                if nextDef and nextDef.mergeWithPrevious and nextDef.type == "slider" then
                    rowHeight = CARD_SLIDERROW_H
                end

                local row, h = BuildCardRow(box, def, isFirst, yOffset, rowHeight)
                yOffset = yOffset - h
                rowCount = rowCount + 1
                isFirst = false
                prevRow = row
                items[#items + 1] = {kind = "row", frame = row, base = rowHeight}
            end

            i = i + 1
        end

        -- DESIGN-1c bugfix 2026-07-29: the design's own card markup ends every card with an
        -- explicit `<div style="height:6px"></div>` spacer after the last row -- omitting it
        -- left the box's bottom edge flush against the last row's content with no breathing
        -- room (reported: Dashboard/Guild Bank/Automation cards).
        box:SetHeight(-yOffset + 6)

        -- Stashed for ReflowCard (below), which re-measures description wrap height once
        -- this card has a real width -- unknowable here, since BuildCard runs before
        -- LayoutHostGrid ever sets a card's width.
        cardDef.items = items
        cardDef.headH = headH

        return box, rowCount
    end

    -- ReflowCard(cardDef): re-measures every row's description now that this card has a
    -- real, final width, and repositions every row after any row that grew (rows are
    -- re-anchored by absolute offset, not chained, so a taller row does not push later
    -- ones down on its own). BuildCard built every row at a fixed CARD_ROW_H regardless of
    -- description length, because no card has a real width yet at that point -- a
    -- description that wraps to more lines than that guess assumed spilled past the row,
    -- and past the card's own bottom margin (DESIGN-1c bugfix follow-up 2026-07-29,
    -- reported against Dashboard's "Off hides the left panel..." row). Called from
    -- LayoutHostGrid right after it sets the card's width, so row/description widths (and
    -- therefore wrap) have already resolved by the time this reads them.
    local function ReflowCard(cardDef, cardWidth)
        local box = cardDef.frame
        -- Every row spans the card minus its own inner padding on both sides (BuildCardRow's
        -- TOPLEFT/TOPRIGHT anchors). Derived from the width the caller is about to apply, NOT
        -- read back via GetWidth(): the anchor chain has not resolved at this point in the
        -- frame, so GetWidth() would hand back the previous layout's value.
        local rowWidth = cardWidth - CARD_PAD * 2
        local y = -cardDef.headH
        for _, item in ipairs(cardDef.items) do
            local frame = item.frame
            if item.kind == "row" then
                local h = item.base
                if frame.desc then
                    -- Width first, height second. The description's wrap width is explicit
                    -- (BuildCardRow), so assigning it here makes GetStringHeight() report the
                    -- real wrapped height on the spot -- no waiting on an anchor chain, and no
                    -- dependence on the card being visible.
                    frame.desc:SetWidth(math.max(rowWidth - (frame.ctrlSpan or 0), 1))
                    -- Measured, not guessed (project convention). label:GetHeight() rather
                    -- than GetStringHeight() because the description hangs off the label
                    -- REGION's bottom edge, so that is the number the anchor chain uses.
                    local wanted = 7 + frame.label:GetHeight() + 3
                        + frame.desc:GetStringHeight() + 7
                    if wanted > h then h = wanted end
                end
                frame:SetPoint("TOPLEFT", box, "TOPLEFT", CARD_PAD, y)
                frame:SetPoint("TOPRIGHT", box, "TOPRIGHT", -CARD_PAD, y)
                frame:SetHeight(h)
                y = y - h
            else -- "subgroup": static height -- no description rows exist inside one today
                frame:SetPoint("TOPLEFT", box, "TOPLEFT", CARD_PAD + 14, y)
                frame:SetPoint("TOPRIGHT", box, "TOPRIGHT", -CARD_PAD, y)
                y = y - frame:GetHeight()
            end
        end
        box:SetHeight(-y + 6)
    end

    -- LayoutHostGrid(key): row-major 2-column placement inside the selected category's
    -- host frame, align-items:start semantics (a card keeps its own fixed height; the next
    -- row starts below the taller of the pair). Column count derives from the live pane
    -- width (RESEARCH A3): falls to a single column when a card would come out under
    -- 260px wide, since the window is user-resizable with no verified resize floor.
    local function LayoutHostGrid(key)
        local host = hosts[key]
        local cardList = byCat[key]
        if not host or not cardList then return end

        local hostWidth = host:GetWidth()
        if not hostWidth or hostWidth < 1 then hostWidth = 1 end

        local twoColWidth = (hostWidth - GRID_GAP) / 2
        local columns = (twoColWidth >= 260) and 2 or 1
        local cardWidth = (columns == 2) and twoColWidth or hostWidth

        local colY = {}
        for c = 1, columns do colY[c] = 0 end

        for i, cardDef in ipairs(cardList) do
            local col = ((i - 1) % columns) + 1
            local frame = cardDef.frame
            frame:ClearAllPoints()
            frame:SetPoint("TOPLEFT", host, "TOPLEFT", (col - 1) * (cardWidth + GRID_GAP), -colY[col])
            frame:SetWidth(cardWidth)
            -- Re-measure description wrap height and the card's own bottom margin now that
            -- the card has this real width (DESIGN-1c bugfix follow-up 2026-07-29) -- must
            -- run before reading GetHeight() below, which this call may have just changed.
            ReflowCard(cardDef, cardWidth)
            colY[col] = colY[col] + frame:GetHeight() + GRID_GAP
        end

        local maxY = 0
        for c = 1, columns do
            if colY[c] > maxY then maxY = colY[c] end
        end
        host:SetHeight(math.max(maxY - GRID_GAP, 1))
    end

    -- Re-lays out every built category's grid (not just the selected one) so a hidden
    -- category is already correctly sized the instant it is shown -- see the
    -- width-before-measure deferred calls at the bottom of this function.
    local function RelayoutAllCategories()
        for _, catDef in ipairs(CATEGORIES) do
            if hosts[catDef.key] then LayoutHostGrid(catDef.key) end
        end
        if selectedKey and hosts[selectedKey] then
            scrollFrame.contentFrame:SetHeight(Theme.PAD_WINDOW * 2 + hosts[selectedKey]:GetHeight())
            scrollFrame:FullUpdate()
        end
    end

    -- CreateNavRow(catDef, rowCount) -- reproduces BankScreen.lua:187-282's copy of
    -- Shell.lua:CreateNavItem's selected/accentBar state pattern, re-anchored back to
    -- top-to-bottom (Shell's native orientation) instead of BankScreen's left-to-right
    -- re-anchor (RESEARCH §2). Does NOT copy row.screenID/row.requires (Shell plumbing),
    -- SetOpenIndicator, or the AddRoundedBorder/HideBorder attention-pulse infrastructure
    -- -- none of that exists for this secondary nav.
    local function CreateNavRow(catDef, rowCount)
        local row = CreateFrame("Button", nil, navPane)
        row:SetHeight(NAV_ROW_H)

        row.selectedOverlay = row:CreateTexture(nil, "ARTWORK")
        row.selectedOverlay:SetAllPoints()
        row.selectedOverlay:SetColorTexture(Theme.SELECTED_OVERLAY.r, Theme.SELECTED_OVERLAY.g,
            Theme.SELECTED_OVERLAY.b, Theme.SELECTED_OVERLAY.a)
        row.selectedOverlay:Hide()

        row.accentBar = row:CreateTexture(nil, "OVERLAY")
        row.accentBar:SetWidth(2)
        row.accentBar:SetPoint("TOPLEFT", row, "TOPLEFT", 0, -6)
        row.accentBar:SetPoint("BOTTOMLEFT", row, "BOTTOMLEFT", 0, 6)
        row.accentBar:SetColorTexture(Theme.ACCENT.r, Theme.ACCENT.g, Theme.ACCENT.b, Theme.ACCENT.a)
        row.accentBar:Hide()

        row.hoverOverlay = row:CreateTexture(nil, "OVERLAY")
        row.hoverOverlay:SetAllPoints()
        WTUI:AddHoverFade(row, row.hoverOverlay, {color = Theme.ACCENT_A13})

        -- The accent bar is deliberately left unmasked, matching both Shell.lua and
        -- BankScreen.lua's precedent -- only the selected/hover washes get the rounded clip.
        WTUI:AddRoundedMask(row, Theme.RADIUS_SM)
        row.selectedOverlay:AddMaskTexture(row.roundedMask)
        row.hoverOverlay:AddMaskTexture(row.roundedMask)

        row.label = row:CreateFontString(nil, "ARTWORK")
        row.label:SetFontObject(WildThingsFont13)
        row.label:SetText(catDef.label)
        row.label:SetPoint("LEFT", row, "LEFT", NAV_PAD_X, 0)

        row.count = row:CreateFontString(nil, "ARTWORK")
        row.count:SetFontObject(WildThingsFontNum12)
        row.count:SetTextColor(Theme.TEXT_SECTION.r, Theme.TEXT_SECTION.g, Theme.TEXT_SECTION.b, Theme.TEXT_SECTION.a)
        row.count:SetPoint("RIGHT", row, "RIGHT", -NAV_PAD_X, 0)

        function row:SetCount(n)
            row.count:SetText(tostring(n))
        end
        row:SetCount(rowCount)

        -- Label colour deliberately diverges from Shell.lua's main-rail 0.92/0.70 pair
        -- (Shell.lua:87-88 records that pair as a considered choice for the main rail
        -- specifically) -- this secondary nav follows the design's --text/--text-dim
        -- instead (Theme.TEXT 0.92 / Theme.TEXT_DIM 0.53).
        function row:SetSelected(isSelected)
            row.selected = isSelected and true or false
            if row.selected then
                row.label:SetTextColor(Theme.TEXT.r, Theme.TEXT.g, Theme.TEXT.b, Theme.TEXT.a)
                row.selectedOverlay:Show()
                row.accentBar:Show()
            else
                row.label:SetTextColor(Theme.TEXT_DIM.r, Theme.TEXT_DIM.g, Theme.TEXT_DIM.b, Theme.TEXT_DIM.a)
                row.selectedOverlay:Hide()
                row.accentBar:Hide()
            end
        end
        row:SetSelected(false)

        row:SetScript("OnClick", function() SelectCategory(catDef.key) end)

        return row
    end

    ------------------------------------------------------------------
    -- Build every surviving category's cards once at screen-build time; switch by
    -- Show()/Hide() on the per-category host frame (RESEARCH §3 R1-R4: WTUI._boundWidgets
    -- is never unregistered, so a rebuild-per-click nav would leak one entry per click
    -- forever, and every widget type queues deferred work a spam-clicked rebuild would
    -- re-run against already-hidden frames).
    ------------------------------------------------------------------
    local prevNavRow, firstKey
    for _, catDef in ipairs(CATEGORIES) do
        local cardList = byCat[catDef.key]
        if cardList then
            local host = CreateFrame("Frame", nil, scrollFrame.contentFrame)
            host:SetPoint("TOPLEFT", scrollFrame.contentFrame, "TOPLEFT", 0, 0)
            host:SetPoint("TOPRIGHT", scrollFrame.contentFrame, "TOPRIGHT", 0, 0)
            host:Hide()
            hosts[catDef.key] = host

            local rowSum = 0
            for _, cardDef in ipairs(cardList) do
                local cardFrame, rowCount = BuildCard(host, cardDef)
                cardDef.frame = cardFrame
                rowSum = rowSum + rowCount
            end

            local navRow = CreateNavRow(catDef, rowSum)
            if prevNavRow then
                navRow:SetPoint("TOPLEFT", prevNavRow, "BOTTOMLEFT", 0, 0)
                navRow:SetPoint("TOPRIGHT", prevNavRow, "BOTTOMRIGHT", 0, 0)
            else
                navRow:SetPoint("TOPLEFT", navHeader, "BOTTOMLEFT", -NAV_PAD_X, -NAV_PAD_Y)
                navRow:SetPoint("TOPRIGHT", navPane, "TOPRIGHT", -NAV_PAD_X, 0)
            end
            navRows[catDef.key] = navRow
            prevNavRow = navRow

            if not firstKey then firstKey = catDef.key end
        end
    end

    -- SelectCategory(key): returns early when the key is already selected, hides the
    -- previous host, shows the new one, repaints every nav row's SetSelected, sets the
    -- content frame height to the selected category's (already-laid-out) grid height and
    -- calls scrollFrame:FullUpdate().
    --
    -- Latent, currently-inert interaction (RESEARCH P9, not coded around): CreateButton
    -- hooks OnHide to unregister a condition button, and no Settings descriptor passes a
    -- condition/events pair today -- but if one ever did, hiding its category here would
    -- unregister it permanently, since there is no re-register on OnShow.
    SelectCategory = function(key)
        if key == selectedKey then return end
        if selectedKey and hosts[selectedKey] then hosts[selectedKey]:Hide() end
        selectedKey = key
        hosts[key]:Show()
        -- Re-run this category's own grid layout on show. Added 2026-07-29 on the theory
        -- that measuring a description while its host was hidden was what produced
        -- single-line row heights; that turned out NOT to be the cause (it was the
        -- over-constrained FontString rect and the centre-anchored label -- see
        -- BuildCardRow/ReflowCard). Kept anyway: it is cheap and idempotent, and it re-syncs
        -- a category whose grid was last laid out at a stale pane width.
        LayoutHostGrid(key)
        for _, catDef in ipairs(CATEGORIES) do
            if navRows[catDef.key] then
                navRows[catDef.key]:SetSelected(catDef.key == selectedKey)
            end
        end
        scrollFrame.contentFrame:SetHeight(Theme.PAD_WINDOW * 2 + hosts[key]:GetHeight())
        scrollFrame:FullUpdate()

        -- Safety-net second pass, the same "immediate + deferred" idiom this file already
        -- uses for the overall width-before-measure problem: the host's own width may only
        -- resolve a tick after Show(), and the immediate pass above would then have laid the
        -- grid out against the previous width. Guarded on the category still being selected,
        -- so clicking elsewhere before this fires does not resize a host the user already
        -- navigated away from.
        C_Timer.After(0, function()
            if selectedKey ~= key then return end
            LayoutHostGrid(key)
            scrollFrame.contentFrame:SetHeight(Theme.PAD_WINDOW * 2 + hosts[key]:GetHeight())
            scrollFrame:FullUpdate()
        end)
    end

    -- Reset to defaults: no reset feature exists anywhere in the codebase to wire --
    -- DEFAULTS and ApplyDefaults are file-locals in Migrations.lua, exposed nowhere, and
    -- SettingsScreen.lua loads before Migrations.lua anyway (RESEARCH §8). Ghost variant +
    -- refresh-cw icon per the design, bottom-pinned, then disabled so it cannot mislead.
    -- Theme.H_BTN already equals the design's 28px.
    local resetBtn = namespace:CreateButton(navPane, "Reset to defaults", function() end,
        {variant = "ghost", icon = "refresh-cw"})
    resetBtn:SetPoint("BOTTOMLEFT", navPane, "BOTTOMLEFT", NAV_PAD_X, 0)
    resetBtn:SetPoint("BOTTOMRIGHT", navPane, "BOTTOMRIGHT", -NAV_PAD_X, 0)
    resetBtn:Disable()

    RelayoutAllCategories()
    if firstKey then SelectCategory(firstKey) end

    -- Width-before-measure (RESEARCH P3): the detail pane's content width and every
    -- card's width are anchor-derived and read 0 on this same-frame construction pass.
    -- Re-bind and re-lay-out once WoW has resolved the real layout (DevToolsScreen.lua's
    -- deferred-sync idiom, :463-467/581-588), then keep it correct while the user drags
    -- the window via the OnSizeChanged hook below.
    C_Timer.After(0, function()
        local vw = scrollFrame.scrollFrame:GetWidth()
        if not vw or vw < 1 then vw = 1 end
        scrollFrame.contentFrame:SetWidth(vw)
        RelayoutAllCategories()
    end)

    scrollFrame.scrollFrame:HookScript("OnSizeChanged", function()
        local vw = scrollFrame.scrollFrame:GetWidth()
        if not vw or vw < 1 then vw = 1 end
        scrollFrame.contentFrame:SetWidth(vw)
        RelayoutAllCategories()
    end)

    return root
end

-- namespace:CreateReadyCheckScreen(contentHost)
-- Ready Check screen: the ready-check trigger-mode dropdown + the two
-- independent auto-decline / announce-chat toggles (READY-05) plus a
-- Cooldowns section with one per-spec spell picker
-- (namespace:CreateSpecCooldownPicker) per player spec. Split out of the
-- Settings screen so ready-check config gets its own nav entry.
--
-- Vision-Angleichung: the Phase-25 `cooldown.enabled` master toggle is
-- replaced by a three-way trigger-mode dropdown (`"always"` /
-- `"wt_only"` / `"disabled"`), and the bundled `auto_not_ready` toggle is
-- split into independent `auto_decline` + `announce_chat` toggles. The
-- dropdown cannot go through the generic `bound-dropdown` BuildWidgets
-- descriptor: BindWidget passes the raw SavedVars leaf value as
-- `initialValue` to CreateDropdown, but CreateDropdown:SetValue expects an
-- array of selection indices, not a string ("always"/"wt_only"/"disabled").
-- Use the proven InviteScreen.lua:124-136 / 266-289 pattern (free-standing
-- row + index-search + WR-02 suppress-initial-fire guard) instead.
--
-- The old `group_assignment` toggle + backend are removed entirely --
-- CheckGroupAssignment is gone from ReadyCheck.lua, no overlay either.
function namespace:CreateReadyCheckScreen(contentHost)
    local root = CreateFrame("Frame", nil, contentHost)
    root:SetPoint("TOPLEFT", contentHost, "TOPLEFT", 0, 0)
    root:SetPoint("TOPRIGHT", contentHost, "TOPRIGHT", 0, 0)
    root:SetPoint("BOTTOMRIGHT", contentHost, "BOTTOMRIGHT", 0, 0)

    local descriptors = {
        {type = "header", label = "Ready check"},

        -- 1. Auto-decline on cooldown (Vision-Angleichung: was bundled into
        -- `auto_not_ready` together with chat announce, now independent).
        {
            type = "toggle",
            label = "Auto-decline on cooldown",
            get = function() return WildThingsSavedVars.readycheck.cooldown.auto_decline end,
            set = function(checked) WildThingsSavedVars.readycheck.cooldown.auto_decline = checked end,
        },

        -- 2. Announce longest on-CD cooldown in raid chat (Vision-Angleichung:
        -- was bundled into `auto_not_ready` together with auto-decline, now
        -- independent). The chat message format ("Cooldown <Spell> ready in
        -- <N> seconds") is fixed by ReadyCheck.lua:PostCooldownChat.
        {
            type = "toggle",
            label = "Announce cooldown in raid chat",
            get = function() return WildThingsSavedVars.readycheck.cooldown.announce_chat end,
            set = function(checked) WildThingsSavedVars.readycheck.cooldown.announce_chat = checked end,
        },
    }

    -- 260726-r3x: rank-0-2 gated "Show cooldown window on ready check" toggle.
    -- Inserted AFTER the two existing toggles but BEFORE AppendCooldownDescriptors
    -- (so it sits visually above the per-spec cooldown pickers). Wrapped in
    -- ViewerHasCapability so non-eligible players never see the row at all
    -- (D-13's read-side gate) -- mirrors the bank.view filter pattern at the
    -- bottom of CreateSettingsScreen. The toggle controls ONLY whether the
    -- LOCAL popup auto-opens; broadcast eligibility is independent (GATE 1 in
    -- ReadyCheckCooldownComm.lua) per CONTEXT.md "Toggle steuert nur ob".
    if Private:ViewerHasCapability("readycheck.cooldownwindow") then
        table.insert(descriptors, {
            type = "toggle",
            label = "Show cooldown window on ready check",
            get = function() return WildThingsSavedVars.readycheck.cooldownwindow.auto_popup end,
            set = function(checked) WildThingsSavedVars.readycheck.cooldownwindow.auto_popup = checked end,
        })

        -- 260730-osw / OSW-01: two rows inside the same capability gate as
        -- the sibling toggle above -- a viewer who cannot get the popup
        -- has nothing to configure. The toggle is opt-OUT (default true),
        -- the slider is the N-seconds-before-pull value. Both go through
        -- the BuildWidgets renderer (no description/mergeWithPrevious),
        -- so only the fields its BUILDERS table knows about are used.
        table.insert(descriptors, {
            type = "toggle",
            label = "Close cooldown window before pull",
            get = function()
                return WildThingsSavedVars.readycheck.cooldownwindow.close_before_pull ~= false
            end,
            set = function(checked)
                WildThingsSavedVars.readycheck.cooldownwindow.close_before_pull = checked
            end,
        })
        table.insert(descriptors, {
            type = "slider",
            label = "Seconds before pull to close",
            min = 1,
            max = 30,
            get = function()
                return WildThingsSavedVars.readycheck.cooldownwindow.close_before_pull_seconds or 5
            end,
            set = function(v)
                WildThingsSavedVars.readycheck.cooldownwindow.close_before_pull_seconds =
                    math.floor(tonumber(v) or 5)
            end,
        })
    end

    AppendCooldownDescriptors(descriptors)

    -- Scroll the body: a class with 3-4 specs, each with its own picker, can run taller
    -- than the window. Content width must be bound BEFORE BuildWidgets runs so the `text`
    -- builder's wrap-height measurement (Pitfall 1) resolves correctly -- and before any
    -- picker:Layout() call below, for the same width-before-measure reason.
    local scrollFrame = namespace:CreateScrollFrame(root)
    scrollFrame:SetPoint("TOPLEFT", root, "TOPLEFT", Theme.PAD_WINDOW, -Theme.PAD_WINDOW)
    scrollFrame:SetPoint("BOTTOMRIGHT", root, "BOTTOMRIGHT", -Theme.PAD_WINDOW, Theme.PAD_WINDOW)

    -- Bind the content width to the INNER WowScrollBox viewport (scrollFrame.scrollFrame),
    -- NOT the outer container (scrollFrame): the viewport is inset by the scrollbar gutter
    -- (~30-40px, ScrollFrame.lua), and content is clipped to it -- measuring wrap against the
    -- outer width would push the rightmost pill on a wrapped row behind the scrollbar. `or 1`
    -- alone doesn't guard a not-yet-resolved frame (GetWidth() returns 0, and `0 or 1 == 0` in
    -- Lua, which would drive a negative label width downstream), so clamp to >= 1 explicitly.
    local viewportWidth = scrollFrame.scrollFrame:GetWidth()
    if not viewportWidth or viewportWidth < 1 then viewportWidth = 1 end
    scrollFrame.contentFrame:SetWidth(viewportWidth)

    local rows, totalHeight = namespace:BuildWidgets(scrollFrame.contentFrame, descriptors)

    -- Vision-Angleichung: trigger-mode dropdown. Anchor directly below the last
    -- BuildWidgets row (mirrors the InviteScreen.lua:266-289 dropdown-after-toggles
    -- layout). The totalHeight returned by BuildWidgets already includes the GAPs it
    -- added internally -- we add one more GAP so the dropdown doesn't sit flush
    -- against the last toggle, matching the inter-row visual rhythm above.
    local modeEntries = {
        {text = "Always",                 value = "always"},
        {text = "Wild Things raids only", value = "wt_only"},
        {text = "Disabled",               value = "disabled"},
    }
    local initialModeIndex = 1
    for i, entry in ipairs(modeEntries) do
        if entry.value == WildThingsSavedVars.readycheck.cooldown.trigger_mode then
            initialModeIndex = i
            break
        end
    end

    local modeRow = CreateFrame("Frame", nil, scrollFrame.contentFrame)
    local modeRowHeight = TITLE_H + Theme.H_DROPDOWN
    modeRow:SetPoint("TOPLEFT", scrollFrame.contentFrame, "TOPLEFT", 0, -(totalHeight + Theme.GAP))
    modeRow:SetPoint("TOPRIGHT", scrollFrame.contentFrame, "TOPRIGHT", 0, -(totalHeight + Theme.GAP))
    modeRow:SetHeight(modeRowHeight)

    -- WR-02 (InviteScreen.lua): CreateDropdown's constructor calls SetValue(initialValue)
    -- which fires OnValueChanged unconditionally, unlike Toggle:SetChecked(checked, true).
    -- Guard the live-write so opening the screen doesn't re-stamp the currently-saved
    -- trigger_mode. Flag flips true only after construction, so the initial SetValue
    -- already fired by then.
    local modeDropdownReady = false
    local modeDropdown = namespace:CreateDropdown(modeRow, "Cooldown check trigger",
        modeEntries, function(value)
            if not modeDropdownReady then return end
            WildThingsSavedVars.readycheck.cooldown.trigger_mode = value
        end, {initialModeIndex})
    modeDropdown:SetPoint("TOPLEFT", modeRow, "TOPLEFT", 0, -TITLE_H)
    modeDropdownReady = true

    -- Add the trigger-mode row's height (including its own trailing GAP) to totalHeight
    -- so RecomputeContentHeight below (which builds off totalHeight + pickersHeight)
    -- accounts for the new section. Same arithmetic style as InviteScreen.lua's
    -- `-y` accumulator.
    totalHeight = totalHeight + Theme.GAP + modeRowHeight

    -- READY-05: one CreateSpecCooldownPicker per player spec, replacing the old flat
    -- per-spec toggle wall. Candidates come from Private.CooldownCandidates (aliased here
    -- as namespace.CooldownCandidates -- the SAME shared addon table ReadyCheck.lua's
    -- file-load capture writes into; falls back to the raw generated global, then {}, so
    -- this still degrades gracefully if ReadyCheck.lua's capture line is ever missing).
    local candidates = namespace.CooldownCandidates or KEY_COOLDOWN_CANDIDATES or {}

    -- NOT Private.playerClassID (that field stores a localized class NAME string, a
    -- latent bug elsewhere -- select(3, UnitClass("player")) is the real numeric classID
    -- GetSpecializationInfoForClassID/GetNumSpecializationsForClassID require).
    local classID = select(3, UnitClass("player"))

    local pickers = {}

    local function RecomputeContentHeight()
        local pickersHeight = 0
        for _, picker in ipairs(pickers) do
            pickersHeight = pickersHeight + picker:GetHeight() + Theme.GAP
        end

        scrollFrame.contentFrame:SetHeight(Theme.PAD_WINDOW * 2 + totalHeight + pickersHeight)
        scrollFrame:FullUpdate()
    end

    -- Vision-Angleichung: the trigger-mode dropdown sits between the toggle block
    -- and the per-spec picker chain -- make it the chain's first anchor so the
    -- pickers line up exactly below it. modeRow is already positioned by absolute
    -- y-offset above (relative to the content frame, not the last BuildWidgets
    -- row), so re-using it as the chain anchor is purely a TOPLEFT/BOTTOMLEFT
    -- relative-point for the FIRST picker.
    local prevAnchor = modeRow

    for i = 1, GetNumSpecializationsForClassID(classID) do
        local specID, specName = GetSpecializationInfoForClassID(classID, i)

        local picker = namespace:CreateSpecCooldownPicker(
            scrollFrame.contentFrame, specID, specName, candidates[specID], RecomputeContentHeight)

        picker:SetPoint("TOPLEFT", prevAnchor, "BOTTOMLEFT", 0, -Theme.GAP)
        picker:SetPoint("TOPRIGHT", prevAnchor, "BOTTOMRIGHT", 0, -Theme.GAP)

        picker:Layout() -- width-before-measure: anchoring above bound picker's width

        pickers[#pickers + 1] = picker
        prevAnchor = picker
    end

    RecomputeContentHeight()

    -- Deferred re-layout (fixes: pills seeded from per_spec are invisible until the first
    -- add/remove after a fresh build/reload). Each picker's width is anchor-derived
    -- (TOPLEFT/TOPRIGHT above, no explicit SetWidth), so picker:GetWidth() reads 0 on this
    -- same-frame construction pass and picker:Layout() skips positioning the seeded pills
    -- (SpecCooldownPicker.lua's `availWidth > 0` guard). Re-run Layout once WoW has laid the
    -- frame out and the width has resolved. Same C_Timer.After(0, ...) deferred-sync idiom
    -- Slider.lua:175-187 / EditBox.lua:226 use for this exact anchor-derived-width gotcha.
    C_Timer.After(0, function()
        for _, picker in ipairs(pickers) do
            picker:Layout()
        end
        RecomputeContentHeight()
    end)

    return root
end
