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

local WTUI = namespace.WildThingsUI
local Theme = namespace.Theme

-- Static placeholder matching the design contract's statusRight default (Window.jsx:15).
-- Plain text only this phase — no clickable link, no presence indicator, no network call,
-- no new SavedVariables field (11-CONTEXT Area 4). Flagged for replacement with the
-- guild's real invite before ship.
local DISCORD_TEXT = "discord.gg/wildthings"

-- CreateNavItem(sidebar, def) — shell-internal composite (11-CONTEXT Area 3), not promoted
-- to its own UI/Widgets/NavItem.lua file. def = {id=, label=, icon=}. Row height Theme.H_NAV;
-- composes the Phase 10 Icon widget (14px glyph) + a WildThingsFont13 label. Selected state
-- is driven entirely by shell:ShowScreen(id) via SetSelected — this helper only paints.
local function CreateNavItem(sidebar, def)
    local row = CreateFrame("Button", nil, sidebar)
    row:SetHeight(Theme.H_NAV)
    -- An action row must never carry a screenID, so the selection-sync loop's
    -- `navRow.screenID == id` test can never match it and the row is permanently
    -- unselected (SHELL-04).
    --
    -- This MUST be an explicit branch. `def.action and nil or def.id` looks like
    -- the ternary but silently collapses to `def.id` in BOTH cases: when
    -- def.action is truthy the `and` yields nil, nil is falsy, so the `or` falls
    -- through to def.id. Lua's and/or idiom cannot express nil (or false) as its
    -- true-branch value. That form shipped in 55-01 and was a no-op -- the row
    -- kept screenID = "summons" and still highlighted on every click.
    if def.action then
        row.screenID = nil
    else
        row.screenID = def.id
    end
    row.requires = def.requires

    -- Held on the row so SetSelected can drive its alpha alongside the label color.
    row.icon = namespace:CreateIcon(row, def.icon, Theme.ICON_SM)
    local icon = row.icon
    icon:SetPoint("LEFT", row, "LEFT", Theme.GAP, 0)

    row.label = row:CreateFontString(nil, "ARTWORK")
    row.label:SetFontObject(WildThingsFont13)
    row.label:SetText(def.label)
    row.label:SetTextColor(1, 1, 1, 0.92)
    row.label:SetPoint("LEFT", icon, "RIGHT", Theme.GAP, 0)

    -- Selected wash — persistent overlay, mirrors Button.lua's SetSelected/selectedOverlay.
    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()

    -- Accent bar — static (no animation), shown only when selected.
    row.accentBar = row:CreateTexture(nil, "OVERLAY")
    row.accentBar:SetWidth(2)
    row.accentBar:SetPoint("TOPLEFT", row, "TOPLEFT", 0, -5)
    row.accentBar:SetPoint("BOTTOMLEFT", row, "BOTTOMLEFT", 0, 5)
    row.accentBar:SetColorTexture(Theme.ACCENT.r, Theme.ACCENT.g, Theme.ACCENT.b, Theme.ACCENT.a)
    row.accentBar:Hide()

    -- Hover fade: shared motion helper (SHELL-02), create-once at construction.
    row.hoverOverlay = row:CreateTexture(nil, "OVERLAY")
    row.hoverOverlay:SetAllPoints()
    WTUI:AddHoverFade(row, row.hoverOverlay, {color = Theme.ACCENT_A13})

    -- Rounded corners (ROUND-04/05, spike-002 Pattern 1): nav rows draw NOTHING at rest --
    -- only the selected/hover overlays are real textures -- so the shared mask clips those
    -- two layers only. Do NOT add a base/background texture here; rows stay transparent at
    -- rest by design. The sidebar container itself stays sharp (Phase 28/ROUND-06).
    WTUI:AddRoundedMask(row, Theme.RADIUS_SM)
    row.selectedOverlay:AddMaskTexture(row.roundedMask)
    row.hoverOverlay:AddMaskTexture(row.roundedMask)

    -- SUMMON-01 (D-08): attention-pulse border infrastructure for the nav
    -- blink (WildThings.lua's ApplyAutoOpenMatrix). The stroke this call
    -- builds is drawn on a separate child frame (BuildRoundedStroke), so
    -- WoW's compositing rules already draw it above this row's own
    -- selectedOverlay/accentBar/hoverOverlay regions with no mask
    -- coordination needed (UI/Functions.lua:293-334). Hidden immediately so
    -- the resting nav appearance is unchanged and the "rows draw NOTHING at
    -- rest" comment above stays true. See .planning/v2.7-MILESTONE-AUDIT.md §1.
    WTUI:AddRoundedBorder(row, Theme.RADIUS_SM, 1)
    row:HideBorder()

    -- Active/inactive contrast: the active row reads at full white, inactive
    -- rows at 70% alpha — a brighter baseline than the old TEXT_DIM (53%).
    function row:SetSelected(isSelected)
        row.selected = isSelected and true or false
        if row.isOpen then
            row.label:SetTextColor(Theme.OK.r, Theme.OK.g, Theme.OK.b, Theme.OK.a)
            row.icon:SetAlpha(0.95)
        elseif row.selected then
            row.label:SetTextColor(1, 1, 1, 0.92)
            row.icon:SetAlpha(0.95)
        else
            row.label:SetTextColor(1, 1, 1, 0.70)
            row.icon:SetAlpha(0.7)
        end
        if row.selected then
            row.selectedOverlay:Show()
            row.accentBar:Show()
        else
            row.selectedOverlay:Hide()
            row.accentBar:Hide()
        end
    end

    -- Paints the label green while a standalone window this row opens (e.g.
    -- Summoning) is currently up, independent of ShowScreen selection --
    -- action rows never carry a screenID (SHELL-04) and so are never
    -- selected via ShowScreen. Routed back through SetSelected so the
    -- existing selected/accentBar bookkeeping stays authoritative.
    function row:SetOpenIndicator(isOpen)
        row.isOpen = isOpen and true or false
        row:SetSelected(row.selected)
    end

    -- Inactive is the default until ShowScreen paints the first selection.
    row:SetSelected(false)

    -- sidebar's parent is the shell window that owns ShowScreen.
    row:SetScript("OnClick", function()
        if def.action then
            def.action()
        else
            sidebar:GetParent():ShowScreen(def.id)
        end
    end)

    return row
end

-- CreateNavHeader(sidebar, label) — TOOLKIT-F6 (D-12).
-- Renders a non-interactive section header label between nav-item groups:
-- WildThingsFont12 uppercase label in white + 1px BORDER hairline.
-- Increased top padding (12px vs 4px) adds visual separation from the group above.
-- No hover, no click, no rounded mask. Used by the groups-aware build loop below.
local function CreateNavHeader(sidebar, label)
    local header = CreateFrame("Frame", nil, sidebar)
    header:SetHeight(32) -- 12px font + 12px top gap + 1px hairline + 7px bottom gap
    header.isHeader = true  -- type discriminator for RelayoutNav (D-11)

    header.label = header:CreateFontString(nil, "ARTWORK")
    header.label:SetFontObject(WildThingsFont12)  -- D-12: 12px
    header.label:SetText(label:upper())
    header.label:SetTextColor(Theme.TEXT_SECTION.r, Theme.TEXT_SECTION.g, Theme.TEXT_SECTION.b, Theme.TEXT_SECTION.a)
    header.label:SetPoint("LEFT", header, "LEFT", Theme.GAP, 0)
    header.label:SetPoint("TOP", header, "TOP", 0, -12)

    header.hairline = header:CreateTexture(nil, "ARTWORK")
    header.hairline:SetHeight(1)
    header.hairline:SetColorTexture(Theme.BORDER.r, Theme.BORDER.g, Theme.BORDER.b, Theme.BORDER.a)
    header.hairline:SetSnapToPixelGrid(false)
    header.hairline:SetTexelSnappingBias(0)
    header.hairline:SetPoint("BOTTOMLEFT", header, "BOTTOMLEFT", Theme.GAP, 0)
    header.hairline:SetPoint("BOTTOMRIGHT", header, "BOTTOMRIGHT", -Theme.GAP, 0)

    -- D-12: Non-interactive — no hover, no click.
    header:EnableMouse(false)

    -- D-11: collect item capability requirements for RelayoutNav header visibility gating.
    header._itemRequires = {}

    return header
end

-- namespace:CreateShell(name, opts)
-- Wraps namespace:CreateWindow verbatim (SHELL-04) and lays out the three fixed chrome
-- zones (titlebar / sidebar / statusbar) around a flexible content host. No AceGUI
-- anywhere in this file (SHELL-05). Titlebar/sidebar/statusbar *content* ships in
-- plan 11-02 — this file only builds the frame scaffold + zone geometry.
function namespace:CreateShell(name, opts)
    opts = opts or {}
    name = name or "WildThingsShell"

    -- All chrome (titlebar/divider/hover/close, status bar, content host, click-to-raise,
    -- off-screen clamp, default geometry) now comes from the chrome primitive
    -- (ChromeWindow.lua). Shell supplies the four fixed values that distinguish its
    -- window from any other consumer: the two title strings, an empty titlebar-button
    -- strip (Shell has no extra titlebar buttons today), and the two status captions.
    local window = namespace:CreateChromeWindow(name, {
        width = opts.width or 1200,
        height = opts.height or 744,
        titleAccent = "Wild Things",
        titleDim = "Raid Tools",
        buttons = {}, -- shell has no extra titlebar buttons today
        statusLeft = (namespace.OurVersion or "") .. " |cffff5262Beta|r",
        statusRight = DISCORD_TEXT,
    })

    -- All chrome (titlebar content, divider, hover, status bar, geometry restore)
    -- now lives inside CreateChromeWindow (54-03). Shell keeps only the sidebar and the
    -- content-host left-edge override below.

    -- Sidebar: fixed-width zone between the titlebar and the statusbar.
    window.sidebar = CreateFrame("Frame", nil, window)
    window.sidebar:SetPoint("TOPLEFT", window, "TOPLEFT", 0, -Theme.H_TITLEBAR)
    window.sidebar:SetPoint("BOTTOMLEFT", window, "BOTTOMLEFT", 0, Theme.H_STATUSBAR)
    window.sidebar:SetWidth(Theme.W_SIDEBAR)

    window.sidebar.bg = window.sidebar:CreateTexture(nil, "BACKGROUND")
    window.sidebar.bg:SetAllPoints(window.sidebar)
    window.sidebar.bg:SetColorTexture(Theme.STRIP_BG.r, Theme.STRIP_BG.g, Theme.STRIP_BG.b, Theme.STRIP_BG.a)
    window.sidebar.bg:SetSnapToPixelGrid(false)
    window.sidebar.bg:SetTexelSnappingBias(0)

    -- Content-host left-edge override (UI-SPEC row 25). The primitive anchors all four
    -- contentHost points purely to window itself (a sidebar-less standalone window
    -- cannot otherwise satisfy the sidebar-coupled left edge today's Shell.lua used).
    -- Shell re-anchors the left edge to the sidebar BEFORE the contentHost's left-edge
    -- anchor would otherwise land on the primitive's window-relative top-left.
    -- Order is load-bearing: window.sidebar above must exist before ClearAllPoints runs,
    -- or the SetPoint TARGET below would anchor against a nil frame. The rendered
    -- rectangle is identical to today's three-point anchoring (UI-SPEC row 24); only
    -- the anchor mechanism changes.
    window.contentHost:ClearAllPoints()
    window.contentHost:SetPoint("TOPLEFT", window.sidebar, "TOPRIGHT")
    window.contentHost:SetPoint("TOPRIGHT", window, "TOPRIGHT", 0, -Theme.H_TITLEBAR)
    window.contentHost:SetPoint("BOTTOMLEFT", window.sidebar, "BOTTOMRIGHT")
    window.contentHost:SetPoint("BOTTOMRIGHT", window, "BOTTOMRIGHT", 0, Theme.H_STATUSBAR)

    -- Screen registration seam (the mount point Phase 12 plugs Roster/Settings into).
    window.screens = {}
    window.navRows = {}
    window.activeScreenID = nil

    -- D-persist-gate (quick task 260725-5kp): the construction-time
    -- window:ShowScreen("roster") paint at the bottom of this function runs
    -- BEFORE OpenRosterShell (WildThings.lua) has read the persisted screen
    -- id -- ungated, that call would clobber the saved id with "roster" every
    -- single session. Starts false; OpenRosterShell flips it to true only
    -- once every RegisterScreen call has run, so the first navigation the
    -- write site (ShowScreen below) sees is the real restore call.
    window.persistScreen = false

    -- builder is function(contentHost) -> rootFrame: constructs the screen's root frame
    -- parented to contentHost and returns it. Called lazily, once, on first ShowScreen.
    function window:RegisterScreen(id, builder, requires)
        self.screens[id] = self.screens[id] or {}
        self.screens[id].builder = builder
        self.screens[id].requires = requires
        -- Invalidate any previously-built frame so a re-registration (e.g. a rebuild path)
        -- is guaranteed to use the new builder rather than silently reusing a stale frame
        -- built from the old one (CR-01 fix review, WR-01).
        self.screens[id].frame = nil
    end

    function window:ShowScreen(id)
        local entry = self.screens[id]

        -- ACCESS-05 hardening: refuse a gated screen id up front, even when ShowScreen is
        -- called directly (not only via a hidden nav row). No build, no Show, no
        -- activeScreenID change, no nav-selection repaint -- a bypass attempt is a total
        -- no-op. namespace: (not Private:) -- this file has no Private local (see top).
        if entry and entry.requires and not namespace:ViewerHasCapability(entry.requires) then
            return
        end

        -- D-C2 write site (quick task 260725-5kp): record every allowed navigation at NAV
        -- time, not on window close. The window is hidden by paths that fire no usable close
        -- event -- most notably a /reload or logout with the window still open, this repo's
        -- own dev loop (deploy.ps1 + /reload) -- so an OnHide hook would silently miss that
        -- case; writing here instead makes the stored value correct at every hide path with
        -- no hook at all. Gated on self.persistScreen (see D-persist-gate above) so
        -- CreateShell's construction-time paint never overwrites a persisted id before
        -- OpenRosterShell has read it. Placed AFTER the ACCESS-05 gate so a refused id is
        -- never recorded, and BEFORE the same-id early return below so the open-time restore
        -- call always rewrites the key, self-healing a stale value.
        if self.persistScreen then
            namespace:SetLastShellScreen(id)
        end

        -- Lazy build-once, cached. Nil-guarded so an id with no registered builder (true for
        -- both "roster" and "settings" until Phase 12) falls through with no error (11-RESEARCH
        -- Pitfall 5) — the empty contentHost background remains as the placeholder.
        --
        -- This runs BEFORE the same-ID early-return below: CreateShell selects "roster" as
        -- activeScreenID at construction time, before Phase 12 has registered a builder for
        -- it. If the early-return guard only compared IDs, the first real
        -- shell:ShowScreen("roster") call after RegisterScreen("roster", builder) would be a
        -- guaranteed no-op (activeScreenID is already "roster") and the screen would never
        -- build. Building first — gated on "not yet built AND a builder now exists" — makes
        -- the seam usable for the default-selected screen too.
        if entry and not entry.frame and entry.builder then
            entry.frame = entry.builder(self.contentHost)
            if entry.frame and id == self.activeScreenID then
                entry.frame:Show()
            end
        end

        if self.activeScreenID == id then return end

        if self.activeScreenID and self.screens[self.activeScreenID] and self.screens[self.activeScreenID].frame then
            self.screens[self.activeScreenID].frame:Hide()
        end

        if entry and entry.frame then
            entry.frame:Show()
        end

        self.activeScreenID = id

        -- Sync nav selection — skip headers (isHeader rows are plain Frames
        -- with no SetSelected method; only nav items created by CreateNavItem have it).
        for _, navRow in ipairs(self.navRows) do
            if not navRow.isHeader then
                navRow:SetSelected(navRow.screenID == id)
            end
        end
    end

    -- window:RelayoutNav() -- ACCESS-04. Iterates window.navRows in order; a row is visible
    -- when it carries no requires field, or the viewer holds that capability
    -- (namespace:ViewerHasCapability -- this file has no Private local, see top of file).
    -- Visible rows are re-anchored into a contiguous chain (no gap left by a hidden row);
    -- hidden rows are Hide()n and skipped entirely in the anchor chain. Called once at the
    -- end of CreateShell (superseding the row-build loop's construction-time anchoring below,
    -- left in place harmlessly) and again from OpenRosterShell (WildThings.lua) on every open.
    function window:RelayoutNav()
        -- D-11 pre-scan: determine header visibility. A header is hidden when ALL of its
        -- child items are hidden by capability gating. Headers with no _itemRequires
        -- (no items, or no items with requires) are always visible.
        for _, row in ipairs(self.navRows) do
            if row.isHeader and row._itemRequires and #row._itemRequires > 0 then
                local allHidden = true
                for _, req in ipairs(row._itemRequires) do
                    if namespace:ViewerHasCapability(req) then
                        allHidden = false
                        break
                    end
                end
                row._allItemsHidden = allHidden
            end
        end

        -- Visibility + re-anchor loop (extended for header awareness).
        local prevVisibleRow
        for _, row in ipairs(self.navRows) do
            local visible
            if row.isHeader then
                -- Headers have no requires of their own; visibility is determined by
                -- whether all child items are hidden (D-11).
                visible = not row._allItemsHidden
            else
                visible = (not row.requires) or namespace:ViewerHasCapability(row.requires)
            end
            if visible then
                row:Show()
                row:ClearAllPoints()
                row:SetPoint("LEFT", self.sidebar, "LEFT", 0, 0)
                row:SetPoint("RIGHT", self.sidebar, "RIGHT", 0, 0)
                if prevVisibleRow then
                    row:SetPoint("TOP", prevVisibleRow, "BOTTOM", 0, 0)
                else
                    row:SetPoint("TOP", self.sidebar, "TOP", 0, -Theme.GAP)
                end
                prevVisibleRow = row
            else
                row:Hide()
            end
        end

        -- WR-02 review fix: a live rank downgrade (ReEvaluateOwnRank -> RelayoutNav) hides
        -- the nav row above, but the currently-open screen's content stays visible if it was
        -- gated -- ShowScreen only refuses a NEW navigation to a gated id, it isn't re-run
        -- here on its own. Redirect away from the now-forbidden activeScreenID to the same
        -- safe default OpenRosterShell (WildThings.lua) resolves to, so gated content never
        -- outlives the capability that unlocked it.
        local activeEntry = self.screens[self.activeScreenID]
        if activeEntry and activeEntry.requires and not namespace:ViewerHasCapability(activeEntry.requires) then
            self:ShowScreen(self:ResolveStartScreen())
        end
    end

    -- window:ResolveStartScreen() -- D-C1 (quick task 260725-5kp). Reads the persisted
    -- screen id and validates it against three independent rejection cases before trusting
    -- it as the shell's opening screen, falling back to "timeline" ("Raid night") otherwise.
    --
    -- The default used to be `namespace.CreateRosterScreen and "roster" or "settings"` --
    -- dev builds keep RosterScreen.lua (so "roster" is registered), packaged CurseForge/Wago
    -- builds strip it (pkgmeta ignore list), so the fallback had to pick whichever screen
    -- actually exists. "timeline" (RaidTimelineScreen.lua) ships in EVERY build -- it is
    -- registered unconditionally in OpenRosterShell (WildThings.lua), outside the
    -- @do-not-package@ fence -- so that dev/packaged branch is dead weight for the default
    -- and is deliberately NOT carried forward here. It is not lost; it never applied to this
    -- fallback in the first place once "Raid night" became the universal default.
    --
    -- Rejection cases (each falls back to "timeline"):
    --   1. Absent / corrupt -- namespace:GetLastShellScreen() returns nil (fresh install, or
    --      the "" sentinel / a non-string value).
    --   2. Not registered in this build -- self.screens[saved] is nil, or carries no builder.
    --      Concrete trigger: a value written on a dev install (e.g. "roster") read on a
    --      packaged one, where that screen was never registered -- must not open an empty
    --      content host.
    --   3. Capability no longer held -- entry.requires is set and
    --      namespace:ViewerHasCapability(entry.requires) is false. Concrete trigger: a guild
    --      demotion between sessions leaving a gated screen id persisted.
    --
    -- Case 3 is defence-in-depth, not the authority: this function's answer is fed straight
    -- back into window:ShowScreen, which re-runs the identical ACCESS-05 gate one line later.
    -- This resolver exists to pick a screen that will actually open, not to grant access.
    function window:ResolveStartScreen()
        local defaultScreenID = "timeline"
        local saved = namespace:GetLastShellScreen()
        if not saved then return defaultScreenID end

        local entry = self.screens[saved]
        if not entry or not entry.builder then return defaultScreenID end

        if entry.requires and not namespace:ViewerHasCapability(entry.requires) then
            return defaultScreenID
        end

        return saved
    end

    -- Sidebar nav rows (SHELL-02 / TOOLKIT-F6): data-driven groups with optional
    -- section headers. Each group has an optional `header` (section label) and an
    -- `items` array of nav-item descriptors. The build loop creates header + items
    -- interleaved in navRows; RelayoutNav handles capability-gated visibility.
    --
    -- Item descriptor fields:
    --   id / label / icon           — screen id + row presentation.
    --   action / openEvent / isOpen — standalone-window rows; these carry no screen id
    --                                 and are never selected by ShowScreen (SHELL-04).
    --   requires                    — CAPABILITY gate. Re-evaluated by RelayoutNav on
    --                                 every shell open (ACCESS-04), because a guild
    --                                 promotion/demotion can flip it mid-session.
    --   requiresBuilder             — BUILD gate. Names the namespace field that must
    --                                 hold this row's screen builder; when it is nil the
    --                                 row is never created at all. Decided exactly once,
    --                                 in the build loop below — see the gate for why.
    --
    -- Group rationale:
    --   "Raid Night"   — landing page + raid-lead quick-actions (Dashboard, Summoning, Inspect)
    --   "Raid Tools"  — raid-lead actions during pulls (Ready Check)
    --   No-header group — utility/admin tabs (Roster dev-only, Guild Bank, Invite Tools, Settings)
    local NAV = {
        groups = {
            {
                header = "Raid Night",
                items = {
                    {id = "timeline", label = "Dashboard", icon = "calendar"},
                    -- Toggle, not just open: a second click while the standalone window is
                    -- already up closes it via namespace:CloseSummonWindow (SummonScreen.lua),
                    -- the same combat-guarded chokepoint the titlebar X uses. openEvent/isOpen
                    -- feed the generic open-indicator wiring below (green label while up).
                    {id = "summons", label = "Summoning", icon = "bell", action = function()
                        if namespace.IsSummonWindowShown and namespace:IsSummonWindowShown() then
                            namespace:CloseSummonWindow()
                        else
                            namespace:OpenSummonWindow()
                        end
                    end,
                    openEvent = "WT_SUMMON_WINDOW_VISIBILITY",
                    isOpen = function() return namespace.IsSummonWindowShown and namespace:IsSummonWindowShown() end},
                    {id = "inspect", label = "Raid Inspect", icon = "search"},
                },
            },
            {
                header = "Raid Tools",
                items = {
                    {id = "readycheck", label = "Ready Check", icon = "shield"},
                },
            },
            {
                -- No header = items render directly without a group label.
                items = {
                    -- Double-gated: capability (rank <= 2) AND build (RosterScreen.lua is
                    -- fenced out of packaged builds, so there is no "roster" screen to
                    -- navigate to there — see the requiresBuilder gate in the build loop).
                    {id = "roster", label = "Roster", icon = "users", requires = "roster.view", requiresBuilder = "CreateRosterScreen"},
                    {id = "bankorganize", label = "Guild Bank", icon = "arrow-up-down", requires = "bank.view"},
                    {id = "invite", label = "Invite Tools", icon = "user-round"},
                    {id = "settings", label = "Settings", icon = "settings"},
                },
            },
        },
    }

    local prevRow
    for _, group in ipairs(NAV.groups) do
        -- Create header if this group declares one (D-10).
        local groupHeader = nil
        if group.header then
            groupHeader = CreateNavHeader(window.sidebar, group.header)
            groupHeader:SetPoint("LEFT", window.sidebar, "LEFT", 0, 0)
            groupHeader:SetPoint("RIGHT", window.sidebar, "RIGHT", 0, 0)
            if prevRow then
                groupHeader:SetPoint("TOP", prevRow, "BOTTOM", 0, 0)
            else
                groupHeader:SetPoint("TOP", window.sidebar, "TOP", 0, -Theme.GAP)
            end
            table.insert(window.navRows, groupHeader)
            prevRow = groupHeader
        end

        -- Create nav items for this group.
        for _, def in ipairs(group.items) do
            -- BUILD GATE (generic — keyed off def.requiresBuilder, no per-id special case).
            -- Packaged CurseForge/Wago builds strip whole file tiers behind the
            -- @do-not-package@ fences (pkgmeta.yaml `ignore:` + UI/UI.xml), so a row's
            -- screen builder may not exist in this build at all — and then
            -- OpenRosterShell (WildThings.lua) never registers that screen id either,
            -- because its RegisterScreen call sits behind the same existence guard.
            --
            -- A row left visible in that build is a TRAP, not a harmless no-op: clicking
            -- it reaches ShowScreen with entry == nil, which builds nothing, yet still
            -- hides the previously visible screen and paints this row selected. The
            -- result is a completely empty content host with no Lua error (every access
            -- in ShowScreen is `if entry and ...`-guarded) — exactly the "Roster page is
            -- just completely empty, no errors" report this gate closes. It also keeps that
            -- unreachable id out of SavedVariables, which ShowScreen would otherwise record
            -- as the viewer's last screen (persistScreen write site above).
            --
            -- Deliberately NOT folded into `requires`/RelayoutNav: a capability can flip
            -- mid-session (guild promotion/demotion, which is why RelayoutNav re-runs on
            -- every open), but a file that is not in the ZIP can never appear later. One
            -- decision at construction is sufficient, and it keeps RelayoutNav's per-open
            -- loop about capability alone. Skipping BEFORE the row is created also keeps
            -- the row out of window.navRows and out of the header's _itemRequires (D-11),
            -- so header visibility counts only items that actually exist.
            local hasBuilder = (not def.requiresBuilder) or namespace[def.requiresBuilder] ~= nil

            if hasBuilder then
                local row = CreateNavItem(window.sidebar, def)
                row:SetPoint("LEFT", window.sidebar, "LEFT", 0, 0)
                row:SetPoint("RIGHT", window.sidebar, "RIGHT", 0, 0)
                if prevRow then
                    row:SetPoint("TOP", prevRow, "BOTTOM", 0, 0)
                else
                    row:SetPoint("TOP", window.sidebar, "TOP", 0, -Theme.GAP)
                end
                table.insert(window.navRows, row)
                prevRow = row

                -- D-11: collect item capability requirements on the group header so
                -- RelayoutNav can hide the header when all its items are gated.
                if groupHeader and def.requires then
                    table.insert(groupHeader._itemRequires, def.requires)
                end

                -- Generic open-indicator wiring: any nav item def declaring openEvent/isOpen
                -- (currently only "Summoning", def above) gets its label painted green while
                -- the standalone window it opens is up, kept in sync via the named callback
                -- so a titlebar-X close, auto-close or /wt summons updates the row too, not
                -- just the toggle click. Subscribes once -- this loop runs exactly once per
                -- shell (OpenRosterShell's lazy-create guard).
                if def.openEvent and def.isOpen then
                    if namespace.RegisterCallback then
                        namespace.RegisterCallback("NavOpenIndicator_" .. def.id, def.openEvent, function(_, isShown)
                            row:SetOpenIndicator(isShown)
                        end)
                    end
                    row:SetOpenIndicator(def.isOpen())
                end
            end
        end
    end

    -- Dev Tools nav row: build-gated on its OWN builder, the same rule the requiresBuilder
    -- gate above applies to the Roster row. DevToolsScreen.lua sits in the identical
    -- @do-not-package@ fence as RosterScreen.lua (UI/UI.xml) and the identical pkgmeta
    -- `ignore:` block, so namespace.CreateDevToolsScreen is nil in packaged
    -- CurseForge/Wago builds and the row never appears there. It used to test
    -- namespace.CreateRosterScreen instead — accidentally equivalent today (same fence,
    -- so the two sentinels are always nil or non-nil together), but it made this row's
    -- visibility depend on an unrelated file. Also rank-gated on devtools.view
    -- (row.requires, applied generically by RelayoutNav below regardless of which loop
    -- created the row) so even a rank>2 guildmate running a dev build never sees it.
    --
    -- NOTE the other half of that coupling is still open: the "devtools" RegisterScreen
    -- call in WildThings.lua sits inside `if Private.CreateRosterScreen then`. Harmless
    -- while both files share one fence; it must be split the moment RosterScreen.lua
    -- starts shipping (see .planning/debug/roster-page-ist-leer-bei-allen.md, cause 4).
    if namespace.CreateDevToolsScreen then
        local devRow = CreateNavItem(window.sidebar, {
            id = "devtools", label = "Dev Tools", icon = "settings", requires = "devtools.view",
        })
        table.insert(window.navRows, devRow)
    end

    -- Default selection. Always a no-op for content: no builder is registered yet at
    -- construction time (OpenRosterShell registers them right after this returns), so this
    -- only seeds activeScreenID. In a dev build it also paints the Roster row selected; in a
    -- packaged build that row no longer exists at all (requiresBuilder gate above), so
    -- nothing is painted. Either way the real opening screen is decided one step later, by
    -- OpenRosterShell's persistScreen + ResolveStartScreen pair — and ResolveStartScreen
    -- rejects an unregistered id, so this "roster" seed can never survive as the shown
    -- screen in a build that has no Roster screen.
    window:ShowScreen("roster")

    -- ACCESS-04: initial visibility pass. Supersedes the row-build loop's construction-time
    -- static anchoring above (left in place harmlessly) with a gated re-anchor that hides any
    -- row whose requires capability the viewer lacks and re-flows the remaining rows with no
    -- gap. Re-run again from OpenRosterShell (WildThings.lua) on every shell open, so
    -- visibility reflects the viewer's current rank each time.
    window:RelayoutNav()

    return window
end
