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

-- Private and namespace are the SAME shared table (select(2, ...) everywhere else in the
-- codebase) -- this alias makes engine/data calls read as Private:GetSelfKey /
-- Private:IsViewerSetForBoss, matching the codebase's existing engine-call convention,
-- while widget calls read as namespace:CreateIcon (WTUI toolkit surface). Same binding
-- shape as GroupGrid.lua's alias (RESEARCH.md Pattern 1 / 38-PATTERNS.md) -- NOT
-- RosterScreen.lua's `select(2, ...)`-only spelling, since this file calls both surfaces.
local Private = namespace
local WTUI = namespace.WildThingsUI
local Theme = namespace.Theme

-- Boss tile geometry (DESIGN-NOTES §4). Tiles are per-boss sized now, not one constant
-- width: the current-progression boss renders larger so the raid lead's eye lands on
-- "where we are" without reading a single label. Every consumer must therefore ask
-- CreateBossCell for the width it actually used rather than multiplying a constant.
local CELL_WIDTH_CURRENT = 86
local CELL_WIDTH_DEFAULT = 72
local AVATAR_SIZE_CURRENT = 72
local AVATAR_SIZE_DEFAULT = 58
local CELL_GAP = 6
local CELL_HEIGHT = 112

-- Instance-grouping icon-stem cycle (PAGE-03/PAGE-06): deterministic cycling assignment
-- for each discovered raid instance (swords -> skull -> star -> anchor -> wraps by
-- modulo for a 5th+ instance). Purely cosmetic (RESEARCH Assumption A4).
local ICON_STEMS = { "swords", "skull", "star", "anchor" }

-- Fallback group identity (PAGE-03 graceful fallback, locked decision): a boss whose
-- dungeonEncounterID doesn't resolve via the EJ reverse-lookup cache (EJ cold/
-- unavailable, unknown/future id, or the /wt timelinetest dev harness's id=1) joins this
-- one shared flat group instead of erroring.
local FLAT_GROUP_NAME = "Dashboard"
local FLAT_GROUP_ICON = "calendar"

-- Instance-group / horizontal-pan layout constants (DESIGN-NOTES.md §3: 22px between
-- instance groups, 6px between cells within a group; header = 16px icon + 13px/700 name
-- + flex hairline).
local INSTANCE_GAP = 22
local GROUP_HEADER_ICON_SIZE = 28
local GROUP_HEADER_HEIGHT = 30
local GROUP_ROW_GAP = 6
local STRIP_HEIGHT = GROUP_HEADER_HEIGHT + GROUP_ROW_GAP + CELL_HEIGHT
local PAN_STEP = 60

-- Header identity row layout constants (DESIGN-NOTES.md §2: title 20px/700, date line,
-- meta line, count badge card, divider hairline below the header before the strip).
local HEADER_HEIGHT = 64
local HEADER_DIVIDER_GAP = 12
local DIVIDER_STRIP_GAP = 12
-- The count badge is content-sized, not fixed: at 74px the 11px "ON THE ROSTER" caption
-- overflowed its own box. 74 is now the FLOOR (the design's proportions for a short count
-- like "9/10"), and the badge grows to fit whichever of its two strings is wider plus the
-- design's 12px horizontal padding.
local BADGE_MIN_WIDTH = 74
-- Viewer identity chip (the circular portrait beside the "Name-Realm" line). Kept as one
-- constant because four things derive from it: the frame size, its rounded fill/border
-- radius (half of it, i.e. a true circle), and the portrait inset 1px inside the hairline.
local IDENTITY_CHIP_SIZE = 34
local BADGE_PAD_X = 12

-- Vertical geometry shared by RenderInto and the bottom section (DESIGN-NOTES §5). Kept
-- as three derived constants rather than repeating the arithmetic at each use site: the
-- bottom section is anchored from the same terms the strip is, so the two can never
-- disagree about where the strip ends and the detail row begins.
local DIVIDER_Y = Theme.PAD_WINDOW + HEADER_HEIGHT + HEADER_DIVIDER_GAP
local STRIP_TOP = DIVIDER_Y + 1 + DIVIDER_STRIP_GAP
local BOTTOM_SECTION_TOP = STRIP_TOP + STRIP_HEIGHT + Theme.GAP_LG

-- Bottom-section layout constants (DESIGN-NOTES §5): the model panel takes a fixed 15%
-- of the row width (floored at 120px) and the four role columns split the remainder
-- equally, 10px apart, 14px clear of the model panel. 34px column/panel header strip.
local BOTTOM_MODEL_GAP = 14
local BOTTOM_COLUMN_GAP = 10
local BOTTOM_MODEL_MIN_WIDTH = 120
local BOTTOM_MODEL_WIDTH_FRACTION = 0.15
local BOTTOM_HEADER_HEIGHT = 34
-- Card chrome geometry. Named rather than written as bare literals at the anchor sites
-- because ShrunkColumnHeight has to reproduce the exact same stack to size a shrink-to-fit
-- column: card inset, header band, the gap under it, and the gap above the card's bottom
-- edge. Hand-copying those numbers is what put that calculation 1px short and left the Tank
-- column with a scrollbar it did not need -- the card-inset term had simply been forgotten.
local BOTTOM_CARD_INSET = 1
local BOTTOM_BODY_TOP_GAP = 2
local BOTTOM_BODY_BOTTOM_GAP = 2

-- Trimmed 2-state status vocabulary (In/Benched only -- PAGE-02's binary viewer-status
-- dot). COPIED (not referenced) from GroupGrid.lua's STATUS_META shape -- GroupGrid.lua
-- is dev-only/pkgmeta-excluded (RESEARCH Pitfall 4) and would be nil in a packaged
-- build, so this shipped file carries its own copy, trimmed to the 2 states this page
-- needs (no invited/missing/offline/lead -- those don't apply to a boss the viewer isn't
-- currently raiding with).
local STATUS_META = {
    ["in"] = { label = "In", color = Theme.OK, tip = "You are on this boss's roster." },
    bench = { label = "Benched", color = Theme.DANGER, tip = "You are not on this boss's roster." },
}

-- Row-pill status vocabulary (CR-01 review fix). STATUS_META above is written in the
-- SECOND PERSON and is about the VIEWER -- correct for the boss tile's own In/Benched
-- badge, and false for every one of the up-to-60 per-player row pills in the bottom
-- section's role columns, which are about the player on THAT row. Same two states,
-- stated impersonally so the copy is true of whichever player the row is showing (the
-- dot has no access to the name, and the row it sits on already spells it out).
local ROW_STATUS_META = {
    ["in"] = { label = "Selected", tip = "On this boss's roster." },
    bench = { label = "Benched", tip = "Not on this boss's roster." },
}

-- MakeStatusDot(parent, size, status, tooltipMode) -- the design's circular status pill: a
-- check glyph on an ACCENT_A70 disc for "in", an x glyph on a DANGER_A70 disc for "bench"
-- (DESIGN-NOTES §4 tile badge, §5 row pill). Size is a parameter because the same dot is
-- the 16px row pill here and the 22px boss-tile badge in CreateBossCell -- one builder, two
-- scales, rather than two near-identical helpers that could drift apart.
--
-- tooltipMode picks whose state the tooltip describes and is REQUIRED at every call site:
-- "viewer" for the boss tile's badge (STATUS_META's second-person copy about the viewer),
-- "row" for a per-player row pill (ROW_STATUS_META's impersonal copy about that row's
-- player). Getting this wrong is not cosmetic -- it makes a row assert something false
-- about the player it is attached to.
--
-- Both glyphs are built once and shown/hidden by SetStatus rather than re-resolving a
-- texture path per state change. The tooltip is fed from those tables, which keeps
-- STATUS_META's label/tip strings live readers (WR-09's point: they were dead data once
-- before).
local function MakeStatusDot(parent, size, status, tooltipMode)
    local dot = CreateFrame("Frame", nil, parent)
    dot:SetSize(size, size)
    WTUI:AddRoundedFill(dot, size / 2, Theme.ACCENT_A70)

    local glyphSize = math.max(6, math.floor(size * 0.55))

    local checkGlyph = namespace:CreateIcon(dot, "check", glyphSize)
    checkGlyph:SetPoint("CENTER", dot, "CENTER", 0, 0)
    checkGlyph:ClearTint()
    checkGlyph:SetAlpha(0.85)

    local xGlyph = namespace:CreateIcon(dot, "x", glyphSize)
    xGlyph:SetPoint("CENTER", dot, "CENTER", 0, 0)
    xGlyph:ClearTint()
    xGlyph:SetAlpha(0.85)

    function dot:SetStatus(newStatus)
        local isIn = newStatus == "in"
        local vocabulary = (tooltipMode == "row") and ROW_STATUS_META or STATUS_META
        local meta = vocabulary[isIn and "in" or "bench"]
        local fill = isIn and Theme.ACCENT_A70 or Theme.DANGER_A70
        dot:SetRoundedFillColor(fill.r, fill.g, fill.b, fill.a)
        checkGlyph:SetShown(isIn)
        xGlyph:SetShown(not isIn)
        WTUI:AddTooltip(dot, meta.label, meta.tip)
    end

    dot:SetStatus(status)
    return dot
end

-- Private:IsViewerSetForBoss(boss, payload, selfKey) -- strict binary In/Benched
-- membership check (PAGE-02, RESEARCH Pattern 3), now owner-aware (G-5nd-01, D-05).
-- Two passes:
--   1. Exact-key: true when selfKey resolves from any index in boss.list. Kept FIRST and
--      byte-for-byte unchanged -- it is both the fast path and the entire degrade path when
--      payload.mains is absent (an older stored/received payload, or a lead on a packaged
--      build with no charToBattletag map).
--   2. Owner: true when ANY character of the viewer's own person is in boss.list -- a raid
--      lead logged into their main while their alt is the planned character must still read
--      "In", not "Benched".
-- Mirrors RosterScreen.lua's DeriveStatus bench-overrides-all rule, trimmed to this screen's
-- 2-state vocabulary (no invited/offline/missing -- those don't apply to a boss the viewer
-- isn't raiding).
function Private:IsViewerSetForBoss(boss, payload, selfKey)
    for _, idx in ipairs(boss.list) do
        if payload.players[idx] == selfKey then
            return true
        end
    end

    local mains = payload.mains
    if type(mains) ~= "table" then return false end
    if type(selfKey) ~= "string" then return false end

    local viewerIdx
    for i, key in ipairs(payload.players) do
        if key == selfKey then
            viewerIdx = i
            break
        end
    end
    if not viewerIdx then return false end

    local viewerOwner = mains[viewerIdx]
    if type(viewerOwner) ~= "string" then return false end

    for _, idx in ipairs(boss.list) do
        if mains[idx] == viewerOwner then
            return true
        end
    end
    return false
end

-- Module-local: the screen's root frame, kept across rebuilds so RefreshTimelineScreen
-- can find it -- the no-op-safe repaint seam Timeline.lua:120's already-shipped guarded
-- call (`if Private.RefreshTimelineScreen then ... end`) needs a real body for.
local screenRoot

-- Module-local: dungeonEncounterID of the boss whose detail row is currently showing in
-- the bottom section (DESIGN-NOTES §5/§7), or nil before the first click. Deliberately
-- declared OUTSIDE RenderInto/ClearRendered's teardown so a RefreshTimelineScreen repaint
-- re-derives the SAME selection instead of silently closing the detail row. Selection is
-- one-way by design -- the mock has a select transition and no deselect -- so a click is a
-- plain assignment, never a toggle.
local selectedBossID

-- Module-local: the bottom section's frame set (nil until the first boss selection).
--
-- THE RULE: this is created at MOST ONCE per session and is NEVER added to renderedFrames.
-- ClearRendered() reparents its entries to nil and WoW never reclaims a CreateFrame'd
-- frame, so rebuilding the section per repaint would permanently leak ~100 frames (a
-- PlayerModel, four scroll-frame composites, and up to 60 pooled player rows) on EVERY
-- ENCOUNTER_END -- i.e. on every wipe of a progression night. A SetParent(nil)'d
-- PlayerModel also loses its loaded model, so the rebuild would visibly reload/flicker the
-- boss on each wipe. Updated in place via section:Update(); hidden, never reparented.
local bottomSection

-- Module-local: horizontal pan offset of the timeline strip (WR-01 review fix).
-- Declared OUTSIDE AttachHorizontalPan for the same reason selectedBossID is above --
-- RenderInto/RefreshTimelineScreen calls AttachHorizontalPan fresh on EVERY repaint,
-- including one triggered by clicking a boss cell to expand its roster panel or by the
-- panel's own outside-click-to-close handler; a local `offsetX = 0` re-declared inside
-- AttachHorizontalPan snapped the strip back to the start on that very next repaint,
-- making a just-expanded panel (parented to screenRoot, escaping this viewport's
-- SetClipsChildren) appear to teleport away from the panned-into-view cell the user
-- was looking at. Persisting the offset here and re-applying it (clamped to the new
-- content width) keeps the strip's scroll position stable across repaints.
local panOffsetX = 0

-- Module-local: "the next render pass should centre the current-progression boss"
-- (DESIGN-NOTES §7 / CONTEXT specifics). Set on first load, on every OnShow, on a new
-- night's payload, and on a KILL; consumed EXACTLY ONCE by RenderInto, which clears it on
-- the first pass whose viewport geometry is resolved -- whether or not there was a current
-- boss to centre on.
--
-- The one-shot discipline is what keeps a WIPE from re-centring: ENCOUNTER_END fires for
-- both outcomes, and re-centring on every wipe would yank the strip out from under a raid
-- lead who had deliberately panned elsewhere. A kill is the deliberate exception -- it
-- moves the selection to the next boss (AdvanceTimelineSelectionAfterKill), so the strip
-- has to follow or the detail row would describe a tile that is off screen.
local pendingCenterOnCurrent = true

-- Module-local: frames created by the last RenderInto pass. Cleared (hidden + reparented
-- to nil) at the top of every RenderInto call -- the "tear down existing children" half
-- of the single render path shared by the builder and RefreshTimelineScreen.
--
-- DELIBERATELY EXCLUDED: the bottom section (see the `bottomSection` module-local above).
-- Everything in this list is cheap and disposable -- a header, a divider and ~30 strip
-- cells. The bottom section is neither: it owns a PlayerModel plus four scroll composites
-- and a pooled row set, and reparenting a PlayerModel to nil unloads its model. It is
-- therefore built once and updated in place, and must never be inserted here.
local renderedFrames = {}

local function ClearRendered()
    for _, frame in ipairs(renderedFrames) do
        -- WR-05 review fix: rendered frames may carry event registrations, and this loop
        -- previously only Hide()d and SetParent(nil)'d them -- leaving any listener live
        -- for the rest of the session on every now-orphaned frame. Cheap insurance kept
        -- after the outside-click roster panel that motivated it was removed: nothing in
        -- the current render path registers events, but a future one might, and the guard
        -- costs one field lookup. renderedFrames also holds Texture/FontString entries
        -- (the divider, the empty-state placeholder), which have no UnregisterEvent method
        -- at all -- hence the guard on frame.UnregisterEvent rather than a bare call.
        if frame.UnregisterEvent then
            frame:UnregisterEvent("GLOBAL_MOUSE_DOWN")
        end
        frame:Hide()
        frame:SetParent(nil)
    end
    renderedFrames = {}
end

-- specID -> "MELEE" | "RANGED" (D-02). DPS specs only: TANK and HEALER are answered by
-- payload.roles, which is the sharer's ground truth and is never re-derived from here.
--
-- A FILE-LOCAL, not Private.SPEC_POSITION, and deliberately so: UI/UI.xml is pulled in at
-- the top of WildThings.toc, so every UI/Screens/*.lua file loads BEFORE Timeline.lua,
-- RosterAltsData.lua and everything else in the .toc -- a file-top
-- `local X = Private.X` for a toc-defined table would bind nil. Same precedent as
-- DIFFICULTY_LABEL below and STATUS_META above, both local copies for the same reason.
--
-- Source: cross-checked against generate_rosteralts.py's SPEC_ID table (this repo's
-- authoritative hand-maintained spec-ID list). No WoW API exposes a melee/ranged
-- axis -- GetSpecializationInfoByID's role return is only TANK/HEALER/DAMAGER, and
-- primaryStat cannot separate Hunter from Rogue -- so a static table is the only
-- option, not a shortcut.
--
-- Spec 1480 (Demon Hunter Devourer) is classified RANGED on guide evidence (an
-- Intellect-based ~25yd caster, "the first non-melee Demon Hunter spec"). Recorded
-- here so a future correction has a documented starting point rather than a bare number.
local SPEC_POSITION = {
    -- Melee DPS
    [70] = "MELEE",   -- Paladin Retribution
    [71] = "MELEE",   -- Warrior Arms
    [72] = "MELEE",   -- Warrior Fury
    [103] = "MELEE",  -- Druid Feral
    [251] = "MELEE",  -- Death Knight Frost
    [252] = "MELEE",  -- Death Knight Unholy
    [255] = "MELEE",  -- Hunter Survival
    [259] = "MELEE",  -- Rogue Assassination
    [260] = "MELEE",  -- Rogue Outlaw
    [261] = "MELEE",  -- Rogue Subtlety
    [263] = "MELEE",  -- Shaman Enhancement
    [269] = "MELEE",  -- Monk Windwalker
    [577] = "MELEE",  -- Demon Hunter Havoc
    -- Ranged DPS
    [62] = "RANGED",  -- Mage Arcane
    [63] = "RANGED",  -- Mage Fire
    [64] = "RANGED",  -- Mage Frost
    [102] = "RANGED", -- Druid Balance
    [253] = "RANGED", -- Hunter Beast Mastery
    [254] = "RANGED", -- Hunter Marksmanship
    [258] = "RANGED", -- Priest Shadow
    [262] = "RANGED", -- Shaman Elemental
    [265] = "RANGED", -- Warlock Affliction
    [266] = "RANGED", -- Warlock Demonology
    [267] = "RANGED", -- Warlock Destruction
    [1467] = "RANGED", -- Evoker Devastation
    [1473] = "RANGED", -- Evoker Augmentation
    [1480] = "RANGED", -- Demon Hunter Devourer (see note above)
}

-- difficultyID -> display label. The inverse of Timeline.lua's DIFFICULTY_NAME_TO_ID
-- (mythic 16 / heroic 15 / normal 14 / lfr 17), kept here rather than imported because
-- that table is a Timeline.lua file-local.
local DIFFICULTY_LABEL = {
    [233] = "Mythic Flex",
    [16] = "Mythic",
    [15] = "Heroic",
    [14] = "Normal",
    [17] = "LFR",
}

-- Single-letter difficulty suffix appended to the boss name on each tile (DESIGN-NOTES §4).
-- The mock hardcodes "M"; deriving it from boss.diff instead means a Heroic raid night is
-- not silently mislabelled Mythic -- the same class of bug WR-01 fixed in the header
-- subline, from the same field, which the payload already carries and the validator already
-- checks.
local DIFFICULTY_SUFFIX = {
    [233] = "MF",
    [16] = "M",
    [15] = "H",
    [14] = "N",
    [17] = "L",
}

-- The suffix is drawn as an inline color escape inside the name FontString rather than as a
-- second FontString, so the name+suffix pair stays centred and ellipsis-truncates as one
-- string. Color escapes carry no alpha, so the non-Mythic difficulties use Theme.NEUTRAL's
-- opaque grey (#8A8F8C, the palette's own dim tone) in place of TEXT_DIM's white-at-.53 --
-- a recorded, visually equivalent substitution. Mythic purple is the design's one
-- non-token literal (#A335EE). Safe against escape injection: Timeline.lua rejects any
-- payload string containing a pipe before it is ever stored.
local DIFFICULTY_SUFFIX_HEX = {
    MF = "a335ee",
    M = "a335ee",
    H = "8a8f8c",
    N = "8a8f8c",
    L = "8a8f8c",
}

-- DifficultyLabel(bosses) -- the header subline's difficulty word.
--
-- WR-01 (38-REVIEW.md): the subline hardcoded "Mythic", so a Heroic or Normal raid-night
-- plan was mislabelled for every raider -- a display string contradicting a field the
-- payload already carries and the validator already checks. Derives from the real
-- per-boss diff instead: one word when the plan is uniform, "Mixed" when it spans
-- difficulties, and nothing at all when no boss carries a recognizable difficulty (the
-- caller then renders no trailing separator rather than an empty one).
local function DifficultyLabel(bosses)
    local seen, firstLabel, count = {}, nil, 0
    for _, boss in ipairs(bosses or {}) do
        local label = DIFFICULTY_LABEL[boss.diff]
        if label and not seen[label] then
            seen[label] = true
            count = count + 1
            firstLabel = firstLabel or label
        end
    end
    if count == 0 then return nil end
    if count == 1 then return firstLabel end
    return "Mixed"
end

-- Boss name -> uppercase initials (e.g. "Belo'ren" -> "BE").
--
-- WR-10 (38-REVIEW.md): this used a raw `name:sub(1, 2)`, which byte-slices. On a
-- localized client boss.name comes straight from the export, so a name starting
-- with a multibyte UTF-8 sequence was cut mid-codepoint and rendered as a broken glyph
-- -- and this is the fallback path for every boss the EJ cache cannot resolve, the
-- /wt timelinetest harness included. Take up to two CODEPOINTS instead. The same file's
-- DisplayKey already goes out of its way to be multibyte-safe; these two helpers should
-- not disagree about the same class of input.
local function BossInitials(name)
    if type(name) ~= "string" or name == "" then
        return "??"
    end
    -- UTF-8 continuation bytes are 0x80-0xBF; a codepoint is its lead byte plus any
    -- continuation bytes that follow. `.` in a Lua pattern matches a single byte, so
    -- "one codepoint" is a lead byte followed by zero or more [\128-\191].
    local initials = name:match("^.[\128-\191]*.?[\128-\191]*") or name:sub(1, 1)
    -- Uppercase ASCII letters ONLY, exactly like DisplayKey below: string.upper is
    -- byte-wise, so letting it near a multibyte sequence risks corrupting it.
    return (initials:gsub("[a-z]", string.upper))
end

-- DisplayKey(key) -- display-only title-case presentation of a CanonicalKey (G-38-9
-- sub-issue 1). Splits on the single "-" separator and uppercases only the first ASCII
-- byte of each half via an anchored gsub, so a multibyte UTF-8 lead byte (o-umlaut,
-- e-circumflex, etc. in guild names like moewe/pepsy/souls) passes through unmodified.
-- Never modifies RosterKeys.lua's CanonicalKey, which stays lowercase for matching.
local function DisplayKey(key)
    if type(key) ~= "string" then
        return key
    end
    local name, realm = key:match("^(.-)%-(.+)$")
    if not name then
        return (key:gsub("^[a-z]", string.upper))
    end
    name = (name:gsub("^[a-z]", string.upper))
    realm = (realm:gsub("^[a-z]", string.upper))
    return name .. "-" .. realm
end

-- DeriveBossState(boss) -- the killed / current-progression tri-state, read from
-- WildThingsSavedVars.timeline.defeated (keyed by dungeonEncounterID) plus Timeline.lua's
-- Private:GetTimelineCurrentBossID accessor. Factored out because three call sites need the
-- same answer -- the tile, the width pre-pass that sizes the instance group before any tile
-- exists, and the bottom section's state label -- and they must never disagree about which
-- boss the raid is on. Reads only: no current-boss or pull-count logic is recomputed here
-- (C-04). Defensive by construction -- a `defeated` table that isn't shaped as expected
-- yields false, never an index error.
local function DeriveBossState(boss)
    local defeated = WildThingsSavedVars.timeline and WildThingsSavedVars.timeline.defeated
    local isKilled = defeated and defeated[boss.id] and true or false
    local isCurrent = not isKilled and Private.GetTimelineCurrentBossID
        and boss.id == Private:GetTimelineCurrentBossID() or false
    return isKilled, isCurrent
end

-- BossCellWidth(boss) -- the tile width DESIGN-NOTES §4 gives this boss. Used by
-- CreateInstanceGroup's pre-pass, which must know the column's total width before it can
-- create the group frame the tiles are then parented to.
local function BossCellWidth(boss)
    local _, isCurrent = DeriveBossState(boss)
    return isCurrent and CELL_WIDTH_CURRENT or CELL_WIDTH_DEFAULT
end

-- Builds one boss cell (avatar + status dot + name + state line) anchored at offset (x, y)
-- inside `root` (y is a downward offset, e.g. below an instance-group header row), and
-- returns the width it used -- per-boss now, so no caller may assume a single constant.
--
-- Click-to-select (DESIGN-NOTES §5/§7): assigns selectedBossID and repaints via
-- RefreshTimelineScreen, which rebuilds this cell and points the persistent bottom section
-- at the newly selected boss. A plain assignment, NOT a toggle -- the design's only tile
-- state transition is select; there is no deselect and no empty-detail-row state to fall
-- back to once a boss has been picked.
local function CreateBossCell(root, boss, status, x, y, bossIcon)
    local isKilled, isCurrentBoss = DeriveBossState(boss)
    local isSelected = selectedBossID == boss.id
    local isBenched = status == "bench"

    local cellWidth = isCurrentBoss and CELL_WIDTH_CURRENT or CELL_WIDTH_DEFAULT
    local avatarSize = isCurrentBoss and AVATAR_SIZE_CURRENT or AVATAR_SIZE_DEFAULT

    local cell = CreateFrame("Frame", nil, root)
    cell:SetSize(cellWidth, CELL_HEIGHT)
    cell:SetPoint("TOPLEFT", root, "TOPLEFT", x, -(y or 0))
    cell:EnableMouse(true)
    cell:SetScript("OnMouseUp", function()
        selectedBossID = boss.id
        Private:RefreshTimelineScreen()
    end)

    -- Selected halo (DESIGN-NOTES §4: `0 0 0 3px var(--accent-a20)` outside the 2px ring).
    -- A frame 3px proud of the avatar on every side, at a LOWER frame level so the avatar's
    -- opaque SURFACE_1 fill covers all of it but that 3px band. Built before the avatar so
    -- the frame-level arithmetic below has something to sit under.
    if isSelected then
        local halo = CreateFrame("Frame", nil, cell)
        halo:SetSize(avatarSize + 6, avatarSize + 6)
        halo:SetPoint("TOP", cell, "TOP", 0, 3)
        halo:SetFrameLevel(cell:GetFrameLevel())
        WTUI:AddRoundedFill(halo, Theme.RADIUS_MD + 3, Theme.ACCENT_A20)
    end

    local avatar = CreateFrame("Frame", nil, cell)
    avatar:SetSize(avatarSize, avatarSize)
    avatar:SetPoint("TOP", cell, "TOP", 0, 0)
    avatar:SetFrameLevel(cell:GetFrameLevel() + 1)
    WTUI:AddRoundedFill(avatar, Theme.RADIUS_MD, Theme.SURFACE_1)

    -- Ring priority (DESIGN-NOTES §4, evaluated once, exactly one ring COLOR chosen):
    -- SELECTED beats everything, then killed, then in-progress, then benched/plain. The
    -- selected ring REPLACES the killed and in-progress rings -- it must never stack with
    -- them, or a selected killed boss would render two concentric greens.
    local ringColor = Theme.BORDER_STRONG
    if isSelected then
        ringColor = Theme.ACCENT
    elseif isKilled then
        ringColor = Theme.ACCENT_A70
    elseif isCurrentBoss then
        local warn = Theme.WARN
        ringColor = { r = warn.r, g = warn.g, b = warn.b, a = 0.6 }
    end
    WTUI:AddRoundedBorder(avatar, Theme.RADIUS_MD)
    avatar:SetBorderColor(ringColor.r, ringColor.g, ringColor.b, ringColor.a)

    -- The design's 2px selected ring, composed from TWO concentric 1px strokes rather than
    -- one 2px stroke. BuildRoundedStroke's `thickness` only sizes the four straight color
    -- edges -- the four corner ARCS come from art that bakes a 1-screen-px stroke per radius
    -- variant -- so asking for thickness = 2 produced 2px edges meeting 1px arcs, which read
    -- in-game as a ring with its corners missing. The second stroke is pulled one pixel
    -- INWARD (negative offsets, per Functions.lua's sign convention), which matches the
    -- design's `inset 2px` semantics, leaves the 3px ACCENT_A20 halo above at full width,
    -- and keeps the tile's outer geometry identical across selection states so nothing jumps
    -- on click. Both strokes use RADIUS_MD: RADIUS_MD - 1 would be 5, equidistant from the
    -- 4px and 6px stroke variants, and GetCornerStrokeTexture's `pairs` walk makes that tie
    -- nondeterministic.
    if isSelected then
        local accent = Theme.ACCENT
        local innerRing = WTUI:AddRoundedStroke(avatar, Theme.RADIUS_MD, 1, nil, -1, -1)
        innerRing:SetStrokeColor(accent.r, accent.g, accent.b, accent.a)
    end

    -- G-38-15: hover affordance so the clickable cell reads as clickable. Overlay
    -- texture on the avatar, faded in/out via the established WTUI:AddHoverFade
    -- pattern (self-wires OnEnter/OnLeave via HookScript, does not disturb the
    -- OnMouseUp select handler above).
    local hoverOverlay = avatar:CreateTexture(nil, "OVERLAY")
    hoverOverlay:SetAllPoints(avatar)
    WTUI:AddHoverFade(cell, hoverOverlay, { color = Theme.ACCENT_A13 })

    -- Boss portrait (user report: the card showed a dark empty box with the boss name's
    -- 2-letter initials instead of the boss's icon), with those initials kept as the
    -- FALLBACK branch. Initials are retained -- rather than a neutral glyph or a bare box
    -- -- because they are the shipped behaviour, they are strictly more informative than
    -- an empty card, and the dev harness `/wt timelinetest` uses a synthetic boss id that
    -- can never resolve through the EJ cache; an empty box there would be a regression.
    if type(bossIcon) == "number" then
        -- The SAME numeric-fileID namespace:CreateIcon branch CreateInstanceHeader already
        -- uses for buttonImage1 -- no second icon mechanism is introduced. ClearTint +
        -- SetAlpha(1) undo Icon.lua's dimmed idleAlpha default so the art renders
        -- full-colour (same two calls the header makes).
        --
        -- texCoord: EJ creature portraits are 128x64 -- exactly 2:1 wide, same as the
        -- instance banner (verified against the exported UI-EJ-BOSS-*.blp files and
        -- Blizzard's own EncounterBossButtonTemplate, which declares the texture at its
        -- native 128x64 and never crops it). Drawn uncropped into this square avatar they
        -- squash to 50% width, so take the centred half -- 32px off each side, i.e.
        -- u 32/128=0.25 .. 96/128=0.75, full height -- for an undistorted square. Identical
        -- values to CreateInstanceHeader's crop because the source ratio is identical.
        -- Composition is per-boss and not always centred (Ghuun et al. sit left of centre),
        -- and the API exposes no bounding box, so a fixed centred window is the best
        -- available choice.
        --
        -- avatarSize - 2 is the 1px-per-side inset WTUI:AddRoundedBorder's caller contract
        -- requires, and reusing the avatar's own roundedMask clips the portrait to the exact
        -- arcs the border traces.
        local portrait = namespace:CreateIcon(
            avatar, bossIcon, avatarSize - 2, { texCoord = { 0.25, 0.75, 0.0, 1.0 } })
        portrait:SetPoint("CENTER", avatar, "CENTER", 0, 0)
        portrait:ClearTint()
        portrait:SetAlpha(1)
        if avatar.roundedMask then
            portrait:AddMaskTexture(avatar.roundedMask)
        end
        -- Benched treatment (DESIGN-NOTES §4: `filter: grayscale(1) brightness(.8)`).
        -- SetVertexColor must follow ClearTint above, which resets it to full white.
        if isBenched then
            portrait:SetDesaturated(true)
            portrait:SetVertexColor(0.8, 0.8, 0.8)
        end
    else
        local initials = avatar:CreateFontString(nil, "ARTWORK")
        initials:SetFontObject(isCurrentBoss and WildThingsFontBold24 or WildThingsFontBold20)
        initials:SetPoint("CENTER", avatar, "CENTER", 0, 0)
        initials:SetText(BossInitials(boss.name))
        -- The initials monogram stands in for the portrait, so it takes the same benched
        -- treatment the design applies to the avatar: .4 white instead of .9.
        initials:SetTextColor(1, 1, 1, isBenched and 0.4 or 0.9)
    end

    -- Status dot (DESIGN-NOTES §4: a 22px circle overlapping the avatar's bottom-right
    -- corner by 4px on both axes, green check for in / red x for benched). The same helper
    -- the bottom section's 16px row pill uses, at a larger size. "viewer": this badge shows
    -- the VIEWER's own In/Benched state for the boss, so it keeps STATUS_META's copy.
    -- The parent is deliberately `cell`, NOT `avatar`: the badge overhangs the avatar's
    -- bounds by 4px on both axes. That parenting is also why the frame level has to be set
    -- explicitly -- the avatar was raised to cell:GetFrameLevel() + 1 above, so a badge
    -- inheriting `cell`'s level draws UNDERNEATH the portrait. Raise it above the
    -- avatar so it sits on top of the art, as the design's cutout badge does.
    --
    -- "above the avatar" is NOT avatar+1: BuildRoundedStroke parents its own container
    -- frame to the avatar, so the border ring -- and, on a selected tile, the second
    -- inset accent ring -- already sit at avatar+1 themselves. A badge placed at that
    -- same level ties with them, and the tie resolves in the rings' favour, which is the
    -- reported bug (the green selected border drawing over the status circle). Derive the
    -- level from whichever ring containers actually exist and go one above the highest,
    -- so this stays correct if the ring composition changes again.
    local dot = MakeStatusDot(cell, 22, status, "viewer")
    local topRingLevel = avatar:GetFrameLevel()
    if avatar.roundedBorder then
        topRingLevel = math.max(topRingLevel, avatar.roundedBorder:GetFrameLevel())
    end
    if avatar.roundedStroke then
        topRingLevel = math.max(topRingLevel, avatar.roundedStroke:GetFrameLevel())
    end
    dot:SetFrameLevel(topRingLevel + 1)
    dot:SetPoint("BOTTOMRIGHT", avatar, "BOTTOMRIGHT", 4, -4)

    -- Name + difficulty suffix as ONE string so the pair centres and truncates together.
    local nameText = boss.name
    local suffix = DIFFICULTY_SUFFIX[boss.diff]
    if suffix then
        nameText = nameText .. " |cff" .. DIFFICULTY_SUFFIX_HEX[suffix] .. suffix .. "|r"
    end

    local nameLabel = cell:CreateFontString(nil, "ARTWORK")
    nameLabel:SetFontObject(isCurrentBoss and WildThingsFontBold14 or WildThingsFontBold12)
    nameLabel:SetPoint("TOP", avatar, "BOTTOM", 0, -6)
    nameLabel:SetWidth(cellWidth)
    nameLabel:SetJustifyH("CENTER")
    nameLabel:SetWordWrap(false)
    nameLabel:SetText(nameText)
    if isSelected then
        local accent = Theme.ACCENT
        nameLabel:SetTextColor(accent.r, accent.g, accent.b, accent.a)
    elseif isBenched then
        local dim = Theme.TEXT_DIM
        nameLabel:SetTextColor(dim.r, dim.g, dim.b, dim.a)
    end

    -- Live killed / current / upcoming state line (LIVE-02), driven entirely by
    -- DeriveBossState above plus the one pull-count accessor.
    local stateLabel = cell:CreateFontString(nil, "ARTWORK")
    stateLabel:SetFontObject(WildThingsFont11)
    stateLabel:SetPoint("TOP", nameLabel, "BOTTOM", 0, -2)
    stateLabel:SetWidth(cellWidth)
    stateLabel:SetJustifyH("CENTER")

    if isKilled then
        -- A cleared boss dims out of the way -- unless it is the one being detailed below,
        -- which renders at full strength so the selection reads unambiguously.
        if not isSelected then
            cell:SetAlpha(0.55)
        end
        local ok = Theme.OK
        stateLabel:SetTextColor(ok.r, ok.g, ok.b, ok.a)
        stateLabel:SetText("Killed")
    elseif isCurrentBoss then
        local pullCount = Private:GetTimelinePullCount(boss.id)
        local warn = Theme.WARN
        stateLabel:SetTextColor(warn.r, warn.g, warn.b, warn.a)
        stateLabel:SetText(pullCount == 1 and (pullCount .. " pull") or (pullCount .. " pulls"))
    else
        local neutral = Theme.NEUTRAL
        stateLabel:SetTextColor(neutral.r, neutral.g, neutral.b, neutral.a)
        stateLabel:SetText("Not pulled")
    end

    table.insert(renderedFrames, cell)
    return cellWidth
end

-- Module-local: the EJ dungeonEncounterID -> {instanceName, iconStem, iconTexture,
-- bossIcon, bossDisplayID} reverse-lookup cache (PAGE-03). The entry is no longer
-- instance-level only: `bossIcon` carries the PER-BOSS Encounter Journal portrait fileID
-- (user report: boss cards must show real boss art, not an empty box with 2-letter
-- initials), and `bossDisplayID` carries that same creature's CreatureDisplayID -- the
-- value the bottom section's PlayerModel:SetDisplayInfo renders as a live 3D model. Note the two
-- EJ id spaces are different -- the payload's `boss.id` is a dungeonEncounterID and is
-- this table's KEY, while the portrait lookup needs a journalEncounterID (see below).
-- nil until first build; memoized for the whole session (raid tiers don't change
-- mid-session -- RESEARCH.md Pattern 2/Pitfall 2).
local instanceLookupCache

-- BuildInstanceLookupCache() -- lazy, memoized EJ reverse-lookup cache build
-- (RESEARCH.md Pattern 2's exact recipe). Never called at file load
-- (WildThingsSavedVars/EJ data may be cold) -- called from RenderInto on first real use.
-- A nil/unavailable EJ_* global leaves the cache empty rather than erroring -- an empty
-- cache is a valid input to the graceful flat-fallback grouping below. NO hardcoded
-- dungeonEncounterID -> name table, ever (CONTEXT/REQUIREMENTS forbid Init.lua's stale
-- encounterIdToName anti-pattern).
local function BuildInstanceLookupCache()
    if instanceLookupCache then
        return instanceLookupCache
    end
    instanceLookupCache = {}

    -- WR-02: snapshot the player's current EJ selection before this build forces the
    -- tier/instance to enumerate raids, so a stock Encounter Journal window open (or
    -- opened later this session) isn't left on whatever tier/instance this loop last
    -- visited. Nil-safe (EJ getters can return nil before EJ data loads); restored
    -- below. Runs at most once per session since this function is memoized.
    local savedTier = EJ_GetCurrentTier and EJ_GetCurrentTier() or nil
    local savedInstance = EJ_GetCurrentInstance and EJ_GetCurrentInstance() or nil

    -- Force EJ to the current/latest tier before enumerating instances.
    local numTiers = EJ_GetNumTiers and EJ_GetNumTiers() or 0
    if numTiers > 0 and EJ_SelectTier then
        EJ_SelectTier(numTiers)
    end

    if not EJ_GetInstanceByIndex or not EJ_GetEncounterInfoByIndex then
        if savedTier and EJ_SelectTier then
            EJ_SelectTier(savedTier)
        end
        return instanceLookupCache
    end

    local iconIndex = 0
    local i = 1
    while true do
        -- true = raids only; buttonImage1 (5th return) is the real per-instance
        -- Blizzard button/crest texture fileID (G-38-5/6), discarding returns 3-4.
        local instanceID, instanceName, _, _, buttonImage1 = EJ_GetInstanceByIndex(i, true)
        if not instanceID then
            break
        end

        -- Priming quirk (RESEARCH.md Pitfall 2): EJ_SelectInstance must run at least
        -- once this session before a journalInstanceID-scoped EJ_GetEncounterInfoByIndex
        -- call is trustworthy. Harmless background pass, memoized so it runs once.
        if EJ_SelectInstance then
            EJ_SelectInstance(instanceID)
        end

        iconIndex = iconIndex + 1
        local iconStem = ICON_STEMS[((iconIndex - 1) % #ICON_STEMS) + 1]

        local j = 1
        while true do
            -- Two DIFFERENT EJ id spaces come out of this one call: position 3 is the
            -- journalEncounterID (the Encounter Journal's own encounter handle, which is
            -- what EJ_GetCreatureInfo wants) and position 7 is the dungeonEncounterID
            -- (what the shared timeline payload's `boss.id` carries, so it is the cache
            -- KEY). Both are read here so no second EJ enumeration pass is needed.
            local encName, _, journalEncounterID, _, _, _, dungeonEncounterID =
                EJ_GetEncounterInfoByIndex(j, instanceID)
            if not encName then
                break
            end
            if dungeonEncounterID then
                -- Per-boss portrait (user report: boss cards showed an empty box with
                -- 2-letter initials instead of the boss's icon). EJ_GetCreatureInfo's 5th
                -- return, iconImage, is a plain numeric fileID -- exactly what the shipped
                -- namespace:CreateIcon numeric branch renders, the same mechanism the
                -- instance header already uses for buttonImage1. Index 1 is the
                -- encounter's primary creature, i.e. the boss itself. A client without the
                -- global, or an encounter with no portrait, leaves bossIcon nil and the
                -- render path falls back to the initials.
                --
                -- Position 4, displayInfo, is the CreatureDisplayID -- a different id
                -- space from the fileID beside it, and the one PlayerModel:SetDisplayInfo
                -- consumes for the bottom section's live 3D boss model. Both come off the
                -- same row of the same EJ table as the portrait that already renders on
                -- the strip today, so the tier/instance priming this function performs
                -- above is exactly what makes both returns trustworthy -- no second EJ
                -- enumeration pass is needed. Accepted only as a positive number so a
                -- cold/absent EJ leaves it nil and the panel takes its initials-placeholder
                -- branch instead of calling the model API with garbage.
                local bossIcon, bossDisplayID
                if journalEncounterID and EJ_GetCreatureInfo then
                    local _, _, _, displayInfo, iconImage = EJ_GetCreatureInfo(1, journalEncounterID)
                    if type(iconImage) == "number" then
                        bossIcon = iconImage
                    end
                    if type(displayInfo) == "number" and displayInfo > 0 then
                        bossDisplayID = displayInfo
                    end
                end

                instanceLookupCache[dungeonEncounterID] = {
                    instanceName = instanceName,
                    iconStem = iconStem,
                    iconTexture = buttonImage1,
                    bossIcon = bossIcon,
                    bossDisplayID = bossDisplayID,
                }
            end
            j = j + 1
        end
        i = i + 1
    end

    -- WR-02: restore the player's original EJ selection -- tier first, then instance,
    -- since EJ_SelectInstance is only meaningful once its owning tier is reselected.
    if savedTier and EJ_SelectTier then
        EJ_SelectTier(savedTier)
    end
    if savedInstance and EJ_SelectInstance then
        EJ_SelectInstance(savedInstance)
    end

    return instanceLookupCache
end

-- GroupBossesByInstance(bosses, cache) -- groups payload.bosses (already in pull order)
-- into ordered {instanceName, iconStem, bosses={}} groups, keyed by resolved instance
-- name in first-seen order. A boss whose id doesn't resolve joins one shared flat
-- fallback group instead of erroring (PAGE-03 graceful fallback, locked decision). Never
-- re-sorts -- pull order is preserved within and across groups by construction.
local function GroupBossesByInstance(bosses, cache)
    local groups = {}
    local byKey = {}

    for _, boss in ipairs(bosses) do
        local info = cache[boss.id]
        local key = info and info.instanceName or "__flat__"
        local group = byKey[key]
        if not group then
            group = {
                instanceName = info and info.instanceName or FLAT_GROUP_NAME,
                iconStem = info and info.iconStem or FLAT_GROUP_ICON,
                iconTexture = info and info.iconTexture,
                bosses = {},
            }
            byKey[key] = group
            table.insert(groups, group)
        end
        table.insert(group.bosses, boss)
    end

    return groups
end

-- CreateInstanceHeader(parent, iconStem, name, width, iconTexture) -- instance group
-- header row: a 16px icon + bold instance name + a flex accent hairline filling to the
-- column's right edge (DESIGN-NOTES.md §3). Mirrors SectionHeader.lua's label+hairline
-- shape. Prefers the real EJ instance texture (buttonImage1, G-38-5/6) over the
-- ICON_STEMS placeholder glyph; iconTexture is nil for the EJ-miss/flat fallback group,
-- which falls through to iconStem exactly as before.
local function CreateInstanceHeader(parent, iconStem, name, width, iconTexture)
    local header = CreateFrame("Frame", nil, parent)
    header:SetSize(width, GROUP_HEADER_HEIGHT)

    -- Real EJ instance textures (buttonImage1, ~2:1 wide banner) get a centered
    -- texCoord crop so the emblem reads as a squarer, recognizable thumbnail instead of
    -- being squished; the EJ-miss/flat fallback (iconTexture nil) keeps rendering the
    -- already-square Lucide iconStem glyph uncropped.
    local iconOpts
    if iconTexture then
        iconOpts = { texCoord = { 0.25, 0.75, 0.0, 1.0 } }
    end

    local icon = namespace:CreateIcon(header, iconTexture or iconStem, GROUP_HEADER_ICON_SIZE, iconOpts)
    icon:SetPoint("LEFT", header, "LEFT", 0, 0)
    icon:ClearTint()
    icon:SetAlpha(1)

    local label = header:CreateFontString(nil, "ARTWORK")
    label:SetFontObject(WildThingsFontBold13)
    label:SetPoint("LEFT", icon, "RIGHT", Theme.GAP, 0)
    label:SetText(name)

    local hairline = header:CreateTexture(nil, "ARTWORK")
    hairline:SetHeight(1)
    local a20 = Theme.ACCENT_A20
    hairline:SetColorTexture(a20.r, a20.g, a20.b, a20.a)
    hairline:SetSnapToPixelGrid(false)
    hairline:SetTexelSnappingBias(0)
    hairline:SetPoint("LEFT", label, "RIGHT", Theme.GAP, 0)
    hairline:SetPoint("RIGHT", header, "RIGHT", 0, 0)

    return header
end

-- CreateInstanceGroup(parent, group, payload, selfKey, x, cache) -- one instance column:
-- header row over a left-to-right row of per-boss cells. `cache` is the EJ lookup cache,
-- consulted per boss purely to hand the cell its portrait fileID (nil for the flat fallback
-- group's unresolved bosses).
--
-- Returns the column's width (so RenderInto can advance its running x offset by
-- width+INSTANCE_GAP) AND the x offset within the column of the current-progression boss's
-- cell, or nil when this column has no such boss -- that offset is what the auto-centre
-- pass needs and only this function can know.
--
-- Cell widths are per-boss (DESIGN-NOTES §4), so the column width is SUMMED from
-- BossCellWidth rather than multiplied from one constant. The pre-pass is unavoidable: the
-- group frame and its header have to be sized before the cells that go inside them exist.
local function CreateInstanceGroup(parent, group, payload, selfKey, x, cache)
    local rowWidth = 0
    for index, boss in ipairs(group.bosses) do
        rowWidth = rowWidth + BossCellWidth(boss)
        if index > 1 then
            rowWidth = rowWidth + CELL_GAP
        end
    end
    rowWidth = math.max(rowWidth, 1)

    local groupFrame = CreateFrame("Frame", nil, parent)
    groupFrame:SetSize(rowWidth, STRIP_HEIGHT)
    groupFrame:SetPoint("TOPLEFT", parent, "TOPLEFT", x, 0)
    table.insert(renderedFrames, groupFrame)

    local header = CreateInstanceHeader(
        groupFrame, group.iconStem, group.instanceName, rowWidth, group.iconTexture)
    header:SetPoint("TOPLEFT", groupFrame, "TOPLEFT", 0, 0)

    local cellX = 0
    local currentBossX, currentBossWidth
    for _, boss in ipairs(group.bosses) do
        local status = Private:IsViewerSetForBoss(boss, payload, selfKey) and "in" or "bench"
        local info = cache and cache[boss.id]
        local width = CreateBossCell(
            groupFrame, boss, status, cellX, GROUP_HEADER_HEIGHT + GROUP_ROW_GAP,
            info and info.bossIcon)

        local _, isCurrent = DeriveBossState(boss)
        if isCurrent then
            currentBossX, currentBossWidth = cellX, width
        end

        cellX = cellX + width + CELL_GAP
    end

    return rowWidth, currentBossX, currentBossWidth
end

-- AttachHorizontalPan(viewport, content, contentWidth) -- screen-local horizontal-pan
-- container (RESEARCH.md Pitfall 1, option 2): viewport clips via SetClipsChildren,
-- content is wider than the viewport, OnMouseWheel offsets content's X, clamped so it
-- never pans past either end. NOT a WTUI:CreateScrollFrame reuse -- that widget is
-- vertical-only and four other screens depend on it; this is scoped to this one screen.
local function AttachHorizontalPan(viewport, content, contentWidth)
    viewport:SetClipsChildren(true)

    -- WR-01 review fix: re-apply (not reset to 0) the offset preserved across
    -- repaints in the module-local panOffsetX above, re-clamped to THIS render
    -- pass's content width -- a re-render with fewer bosses must not leave the
    -- strip panned past its own (now-narrower) left edge.
    local viewportWidth = viewport:GetWidth()
    local minOffset = math.min(0, viewportWidth - contentWidth)
    panOffsetX = math.max(minOffset, math.min(0, panOffsetX))
    content:SetPoint("TOPLEFT", viewport, "TOPLEFT", panOffsetX, 0)

    viewport:EnableMouseWheel(true)
    viewport:SetScript("OnMouseWheel", function(_, delta)
        local vw = viewport:GetWidth()
        local minOff = math.min(0, vw - contentWidth)
        panOffsetX = math.max(minOff, math.min(0, panOffsetX + delta * PAN_STEP))
        content:ClearAllPoints()
        content:SetPoint("TOPLEFT", viewport, "TOPLEFT", panOffsetX, 0)
    end)
end

-- CreateHeaderRow(root, payload, groups, selfKey) -- header identity row (PAGE-04): left
-- column title + instance/boss summary, right cluster viewer identity (class-colored
-- name + dim realm) and an inCount/total "ON THE ROSTER" badge. Nil-payload-safe
-- (G-37-1): title, viewer identity, and the officer-gated Share control build in BOTH
-- the no-payload and payload states, so RenderInto's empty-state branch can call this
-- too and give an officer a first-share entry point. The subline, inCount loop, and
-- count badge all read payload fields and are gated behind `if payload then`; when the
-- badge is absent, the identity name label re-anchors to the header's own TOPRIGHT.
-- inCount reuses Private:IsViewerSetForBoss so the badge count and the per-cell dots
-- can never disagree.
local function CreateHeaderRow(root, payload, groups, selfKey)
    local header = CreateFrame("Frame", nil, root)
    header:SetPoint("TOPLEFT", root, "TOPLEFT", Theme.PAD_WINDOW, -Theme.PAD_WINDOW)
    header:SetPoint("TOPRIGHT", root, "TOPRIGHT", -Theme.PAD_WINDOW, -Theme.PAD_WINDOW)
    header:SetHeight(HEADER_HEIGHT)
    table.insert(renderedFrames, header)

    local title = header:CreateFontString(nil, "ARTWORK")
    title:SetFontObject(WildThingsFontBold20)
    title:SetPoint("TOPLEFT", header, "TOPLEFT", 0, 0)
    title:SetText("Dashboard")

    -- Date line (D-04, DESIGN-NOTES §2). TODAY'S DATE ONLY -- deliberately no time range.
    -- The mock's "Wednesday, 29 July · 20:00 - 23:00" has no data source: the addon carries
    -- no schedule or calendar model, and inventing a raid window here would render a
    -- confident lie on every raider's screen. Built in the no-payload state too, so the
    -- empty screen still reads as today's raid night.
    local dim = Theme.TEXT_DIM
    local dateLine = header:CreateFontString(nil, "ARTWORK")
    dateLine:SetFontObject(WildThingsFont14)
    dateLine:SetTextColor(dim.r, dim.g, dim.b, dim.a)
    dateLine:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -3)
    -- tostring narrows date()'s declared string|osdate union: it returns an osdate table
    -- only for a "*t" format, never for this one, but the analyser cannot prove that.
    dateLine:SetText(tostring(date("%A, %d %B")))

    local badge
    if payload then
        local totalBosses = #payload.bosses
        local section = Theme.TEXT_SECTION
        local subLine = header:CreateFontString(nil, "ARTWORK")
        subLine:SetFontObject(WildThingsFont12)
        subLine:SetTextColor(section.r, section.g, section.b, section.a)
        subLine:SetPoint("TOPLEFT", dateLine, "BOTTOMLEFT", 0, -3)
        local subText = #groups .. " instances · " .. totalBosses .. " bosses"
        local diffLabel = DifficultyLabel(payload.bosses)
        if diffLabel then
            subText = subText .. " · " .. diffLabel
        end
        subLine:SetText(subText)

        local inCount = 0
        for _, boss in ipairs(payload.bosses) do
            if Private:IsViewerSetForBoss(boss, payload, selfKey) then
                inCount = inCount + 1
            end
        end

        badge = CreateFrame("Frame", nil, header)
        badge:SetSize(BADGE_MIN_WIDTH, HEADER_HEIGHT)
        badge:SetPoint("TOPRIGHT", header, "TOPRIGHT", 0, 0)
        WTUI:AddRoundedFill(badge, Theme.RADIUS_MD, Theme.ACCENT_A08)
        WTUI:AddRoundedBorder(badge, Theme.RADIUS_MD)
        local accentA20 = Theme.ACCENT_A20
        badge:SetBorderColor(accentA20.r, accentA20.g, accentA20.b, accentA20.a)

        local countLabel = badge:CreateFontString(nil, "ARTWORK")
        countLabel:SetFontObject(WildThingsFontNum22)
        local accent = Theme.ACCENT
        countLabel:SetTextColor(accent.r, accent.g, accent.b, accent.a)
        countLabel:SetPoint("TOP", badge, "TOP", 0, -10)
        countLabel:SetText(inCount .. "/" .. totalBosses)

        local countCaption = badge:CreateFontString(nil, "ARTWORK")
        countCaption:SetFontObject(WildThingsFont11)
        countCaption:SetTextColor(section.r, section.g, section.b, section.a)
        countCaption:SetPoint("TOP", countLabel, "BOTTOM", 0, -2)
        countCaption:SetText("ON THE ROSTER")

        -- Size the badge from its own contents. Both strings have their font object and
        -- their text set by this point, so GetStringWidth is resolved. The badge is anchored
        -- TOPRIGHT and the identity line anchors to the badge's LEFT, so widening it pushes
        -- the identity cluster left on its own -- no other anchor needs touching.
        local contentWidth = math.max(countLabel:GetStringWidth(), countCaption:GetStringWidth())
        badge:SetWidth(math.max(BADGE_MIN_WIDTH, math.ceil(contentWidth) + BADGE_PAD_X * 2))
    end

    -- Viewer identity: own class-colored name (UnitClass's 3rd return -> GetClassColor)
    -- + dim realm (Private:GetSelfRealm), anchored left of the count badge when present,
    -- else to the header's own TOPRIGHT (no-payload state, G-37-1).
    local _, classFilename = UnitClass("player")
    local nameColor = Theme.GetClassColor(classFilename)

    -- Name and realm read as ONE "Name-Realm" line rather than a stacked pair. Still two
    -- FontStrings, not a single string with |cff escapes: TEXT_DIM is an alpha colour and
    -- inline escapes carry no alpha channel, so the realm would render opaque.
    --
    -- The chain anchors from the RIGHT inward -- realm to the badge, name to the realm's
    -- left, chip to the name's left -- so the cluster stays flush against the count badge
    -- while each piece keeps its natural width. Vertically centred rather than
    -- top-aligned, which is what a single line wants.
    local realmLabel = header:CreateFontString(nil, "ARTWORK")
    realmLabel:SetFontObject(WildThingsFont14)
    realmLabel:SetTextColor(dim.r, dim.g, dim.b, dim.a)
    if badge then
        realmLabel:SetPoint("RIGHT", badge, "LEFT", -Theme.GAP, 0)
    else
        realmLabel:SetPoint("RIGHT", header, "RIGHT", 0, 0)
    end
    realmLabel:SetText("-" .. Private:GetSelfRealm())

    local nameLabel = header:CreateFontString(nil, "ARTWORK")
    nameLabel:SetFontObject(WildThingsFontBold16)
    nameLabel:SetTextColor(nameColor.r, nameColor.g, nameColor.b, nameColor.a)
    nameLabel:SetPoint("RIGHT", realmLabel, "LEFT", 0, 0)
    nameLabel:SetText(UnitName("player"))

    -- Identity chip (DESIGN-NOTES §2): a 24px circular WHITE_A08 frame with a BORDER
    -- hairline, immediately left of the name. Anchored to the name's own LEFT edge, so it
    -- tracks whatever width the character name happens to be.
    local identityChip = CreateFrame("Frame", nil, header)
    identityChip:SetSize(IDENTITY_CHIP_SIZE, IDENTITY_CHIP_SIZE)
    identityChip:SetPoint("RIGHT", nameLabel, "LEFT", -6, 0)
    WTUI:AddRoundedFill(identityChip, IDENTITY_CHIP_SIZE / 2, Theme.WHITE_A08)
    WTUI:AddRoundedBorder(identityChip, IDENTITY_CHIP_SIZE / 2)
    local border = Theme.BORDER
    identityChip:SetBorderColor(border.r, border.g, border.b, border.a)

    -- The chip holds the VIEWER'S OWN character portrait rather than the generic 2-person
    -- glyph the mock stood in with -- it sits directly beside their own name and realm, so
    -- the identity it should assert is theirs. Sized IDENTITY_CHIP_SIZE - 2 so it stays 1px
    -- clear of the chip's own hairline border at whatever size the chip is set to.
    --
    -- Clipped to the chip's circle by TempPortraitAlphaMaskSmall: that asset is already
    -- bound in this codebase as Functions.lua's CORNER_TEXTURE, and its own comment there
    -- records that its solid region CURVES -- the very property that made it wrong for
    -- nine-sliced rounded rects. Used full-rect instead of quadrant-cropped, that curve is
    -- exactly the circle this chip wants.
    --
    -- SetPortraitTexture is guarded on its own existence (house style for external API
    -- surface, same shape as the model.SetDisplayInfo guards below), falling back to the
    -- previous users glyph. The WHITE_A08 fill and the border underneath stay in place
    -- either way -- they are what shows if the portrait never resolves.
    if SetPortraitTexture then
        local portrait = identityChip:CreateTexture(nil, "ARTWORK")
        portrait:SetSize(IDENTITY_CHIP_SIZE - 2, IDENTITY_CHIP_SIZE - 2)
        portrait:SetPoint("CENTER", identityChip, "CENTER", 0, 0)
        SetPortraitTexture(portrait, "player")

        local portraitMask = identityChip:CreateMaskTexture(nil, "ARTWORK")
        portraitMask:SetTexture(
            [[Interface\CHARACTERFRAME\TempPortraitAlphaMaskSmall]],
            "CLAMPTOBLACKADDITIVE",
            "CLAMPTOBLACKADDITIVE")
        portraitMask:SetAllPoints(portrait)
        portrait:AddMaskTexture(portraitMask)
    else
        local identityGlyph = namespace:CreateIcon(identityChip, "users", IDENTITY_CHIP_SIZE / 2)
        identityGlyph:SetPoint("CENTER", identityChip, "CENTER", 0, 0)
        identityGlyph:ClearTint()
        identityGlyph:SetAlpha(0.55)
    end

    -- Officer-only "Share Data" control (PAGE-07, header right cluster, left of the
    -- identity/count badge -- NOT the shared shell titlebar, which is cross-screen chrome
    -- per DESIGN-NOTES §9). Built CONDITIONALLY (never build-then-hide) so a non-officer
    -- never has the control in the frame tree at all; since CreateHeaderRow re-runs on
    -- every RenderInto/RefreshTimelineScreen repaint, a live guild promotion/demotion is
    -- re-evaluated on the next repaint too. ViewerHasCapability is a cosmetic, local gate
    -- only -- BroadcastTimeline already re-checks ViewerHasCapability internally, so no
    -- gating logic is duplicated inside the OnClick. Being in a raid is deliberately NOT
    -- a condition: outside one the click populates only the local timeline.
    -- CR-01 review fix: also gated on Private.CreateRosterScreen, the same dev-build
    -- sentinel used throughout WildThings.lua (sendertest/timelinetest).
    -- BuildTimelinePayload's only data source, Private.parsedRosterExport, is derived
    -- exclusively via Private.ParseRosterExport (RosterParser.lua), which -- like
    -- RosterScreen.lua/Private.CreateRosterScreen -- is stripped from every packaged
    -- build by pkgmeta.yaml. Without this gate the button renders for every officer but
    -- silently does nothing in the real, distributed (packaged) addon; until a
    -- packaged-build-safe roster source ships, hide the control entirely rather than
    -- offer a dead button.
    if Private.CreateRosterScreen and Private:ViewerHasCapability("timeline.share") then
        local shareBtn = namespace:CreateButton(header, "Share Data", function()
            -- WR-04 review fix: BroadcastTimeline now returns ok, reason -- print a chat
            -- message either way so a failed/no-op share is never silently indistinguishable
            -- from a successful one. On success the second return is the mode: "broadcast"
            -- (sent to the raid) or "local" (no raid -- only this client was populated), so
            -- pressing the button solo never reads as if the raid received something.
            local ok, result = Private:BroadcastTimeline()
            if ok and result == "local" then
                print("|cff99ff00Wild Things:|r Timeline loaded locally (not in a raid — nothing sent).")
            elseif ok then
                print("|cff99ff00Wild Things:|r Timeline shared.")
            else
                print("|cff99ff00Wild Things:|r Failed to share timeline: " .. tostring(result))
            end
        end)
        -- Re-anchored to the identity CHIP rather than the name label, since the chip is
        -- now the leftmost element of the right cluster. Gating conditions and the
        -- broadcast call are untouched (D-03: the control stays, only its position moves).
        shareBtn:SetPoint("RIGHT", identityChip, "LEFT", -Theme.GAP, 0)

        -- Import sits immediately left of Share Data so the whole "paste a corrected
        -- export, push it to the raid" loop lives on one screen -- previously the only
        -- Import button was on the Roster page, so amending a roster mid-raid meant
        -- switching screens and coming back. Same popup, same Private:CaptureRosterImport
        -- as that button: this is a second entry point, never a second implementation.
        -- No onCaptured callback -- the Dashboard has no equivalent of RosterScreen's
        -- staged-layout caches to invalidate, and its own repaint arrives through the
        -- ApplyTimelinePayload -> RefreshTimelineScreen path once Share Data is pressed.
        -- Deliberately does NOT auto-share: an import is a correction the raid lead may
        -- want to eyeball before pushing it to 25 people.
        -- Inside the existing gate on purpose. Private.OpenRosterImportPopup lives in
        -- RosterScreen.lua, the same do-not-package file that defines the
        -- Private.CreateRosterScreen sentinel this branch already tests -- so on a
        -- packaged build both vanish together and neither button is built. The call is
        -- made at click time, long after load, so RosterScreen.lua loading AFTER this
        -- file (UI/UI.xml:39 vs :45) does not matter.
        local importBtn = namespace:CreateButton(header, "Import", function()
            Private:OpenRosterImportPopup(header)
        end, { icon = "download" })
        importBtn:SetPoint("RIGHT", shareBtn, "LEFT", -Theme.GAP, 0)
    end
end

-- Bottom-section player-row geometry (DESIGN-NOTES §5). ROSTER_ROW_HEIGHT is deliberately
-- 26 and NOT Theme.H_ROW (36): the design uses a tighter row on this screen specifically,
-- so this is an intentional local override of the generic token, not an oversight.
-- Benched rows dim by OPACITY, matching the mock -- not by desaturation.
local ROSTER_ROW_HEIGHT = 26
local ROSTER_ROW_DOT_SIZE = 6
-- The fixed-size slot the row's leading mark lives in. Fixed, because the mark is either a
-- 16px spec icon or the 6px class dot depending on the player -- anchoring the name to this
-- container instead of to the mark itself is what keeps every name's left edge on the same
-- vertical line whichever one is showing.
local ROSTER_ROW_MARK_SIZE = 16
local ROSTER_ROW_PILL_SIZE = 16
-- Info-icon slot (G-5nd-01, D-04): same fixed-size-container reasoning as
-- ROSTER_ROW_MARK_SIZE above -- an optional icon lives inside a container of constant
-- size, so the name's right edge sits on the same vertical line whether the icon shows or
-- not. 16 to match ROSTER_ROW_PILL_SIZE.
local ROSTER_ROW_INFO_SLOT_SIZE = 16
local ROSTER_ROW_PAD_X = 8
local ROSTER_ROW_GAP_X = 7
local ROSTER_BENCHED_ALPHA = 0.45
local ROSTER_SECTION_LABEL_HEIGHT = 18
local ROSTER_BODY_PAD_Y = 6

-- ResolveColumn(payload, idx) -- which of the four design columns a planned player belongs
-- in (D-02). Resolution order (RESEARCH Q3):
--   1. payload.roles[idx] of "TANK"/"HEALER" wins outright and is NEVER re-derived from the
--      spec table -- the role field is the sharer's ground truth, and a payload may carry a
--      role for a player whose spec is 0.
--   2. payload.specs[idx] through SPEC_POSITION. Nil-safe: the whole `specs` field is
--      optional on payloads persisted before spec data shipped.
--   3. Private.RosterAlts.charInfo[key].specID through the same table, guarded at every hop
--      -- Private.RosterAlts may be absent entirely, and a pug key is not in the guild's
--      generated table.
--   4. MELEE.
--
-- The return is always one of the four known bucket keys, which is what lets the caller
-- index its bucket table directly: a garbage role string on the wire cannot reach a nil
-- bucket, it just falls through to the spec lookups like any other DAMAGER.
-- ResolveSpecID(payload, idx) -- the ONE spec-resolution path, walking the two hops in the
-- order ResolveColumn has always walked them: the payload's own specs table first (nil-safe,
-- the whole field is optional on payloads persisted before spec data shipped), then the
-- guild's generated charInfo table, guarded at every hop (Private.RosterAlts may be absent
-- entirely, and a pug key is not in it), else nil.
--
-- Extracted so the row's spec ICON and the row's COLUMN are derived from the same answer and
-- cannot drift into disagreeing about which spec a player is. A hostile or garbage specID on
-- the wire is harmless here: it is returned as-is and each consumer fails soft -- an
-- unknown id has no SPEC_POSITION entry (so the player lands in the MELEE default) and
-- yields no icon from GetSpecializationInfoByID (so the row falls back to its class dot).
local function ResolveSpecID(payload, idx)
    local specID = payload.specs and payload.specs[idx]
    if specID then
        return specID
    end

    local key = payload.players and payload.players[idx]
    local charInfo = key and Private.RosterAlts and Private.RosterAlts.charInfo
        and Private.RosterAlts.charInfo[key]
    return charInfo and charInfo.specID or nil
end

local function ResolveColumn(payload, idx)
    local role = payload.roles and payload.roles[idx]
    if role == "TANK" then
        return "TANK"
    elseif role == "HEALER" then
        return "HEAL"
    end

    local position = SPEC_POSITION[ResolveSpecID(payload, idx) or 0]
    if position then
        return position
    end

    -- The deliberate defined-column default D-02 requires: an unresolved spec must land in
    -- a column rather than vanish from the roster. MELEE specifically, because an unknown
    -- surfacing among the melee a raid lead scans for positioning gets noticed and
    -- corrected -- one hidden among eight ranged does not.
    return "MELEE"
end

-- KeyClassColor(key) -- the player's WoW class color, or Theme.TEXT when the key is not in
-- the guild's generated table (a pug, or a client whose RosterAltsData.lua predates them).
--
-- Reading class data here is safe in EVERY build: RosterAltsData.lua sits OUTSIDE the
-- .toc's #@do-not-package@ fence and ships in every packaged build precisely because it
-- carries no personal data (the battletag half, RosterAlts.lua, is the dev-only one and is
-- never touched here). The old CreateBossRosterPanel comment claiming class data was a
-- packaged-build nil hazard predates that split and no longer holds.
local function KeyClassColor(key)
    local charInfo = type(key) == "string"
        and Private.RosterAlts and Private.RosterAlts.charInfo
        and Private.RosterAlts.charInfo[key]
    if charInfo and charInfo.class then
        return Theme.GetClassColor(charInfo.class)
    end
    return Theme.TEXT
end

-- OwnerOf(mains, idx) -- the person index idx belongs to, for the owner-based grouping
-- PopulateColumns' pre-pass runs (G-5nd-01, D-02, D-03). Returns mains[idx] when it
-- resolves to a string; otherwise returns idx itself.
--
-- Returning nil, or any shared sentinel value, would be a bug, not a stylistic choice: a
-- shared sentinel groups every unresolvable character into one fictional person and hides
-- all but one of them. Returning the index makes each unresolvable character its own
-- person instead -- the same fail-soft answer BuildTimelinePayload's dense self-as-own-main
-- default gives on the sender side. Numbers and strings never collide as table keys, so
-- mixing them in one lookup table (selectedOwners/benchRep below) is safe and deliberate.
local function OwnerOf(mains, idx)
    return type(mains[idx]) == "string" and mains[idx] or idx
end

-- RGBToHex(color) -- inline-escape hex formatter for the info-icon tooltip's class-colour
-- (G-5nd-01, D-04). AddTooltip hard-codes its own line colours, so the class colour has to
-- be an inline "|cffRRGGBB...|r" escape rather than a SetTextColor call. No rgb-to-hex
-- helper exists anywhere in the repo outside Libs/, so this is the two-line surgical
-- answer. Floors each channel to an integer before formatting -- a fractional value passed
-- to Lua 5.1's %02x integer format specifier is not safe.
local function RGBToHex(color)
    return string.format("%02x%02x%02x",
        math.floor(color.r * 255), math.floor(color.g * 255), math.floor(color.b * 255))
end

-- CreateRosterRow(parent) -- one 26px role-column player row: alternating background,
-- class-color dot, class-colored name, and a status pill. Built blank; SetPlayer drives it.
-- POOLED, never recreated (see AcquireRow).
local function CreateRosterRow(parent)
    local row = CreateFrame("Frame", nil, parent)
    row:SetHeight(ROSTER_ROW_HEIGHT)

    local bg = row:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints(row)

    -- Both marks are built ONCE here and only re-pointed (SetTexture/Show/Hide) per repaint
    -- -- the same create-once rule the row pool itself exists to enforce. Nothing below is
    -- ever constructed inside SetPlayer.
    local mark = CreateFrame("Frame", nil, row)
    mark:SetSize(ROSTER_ROW_MARK_SIZE, ROSTER_ROW_MARK_SIZE)
    mark:SetPoint("LEFT", row, "LEFT", ROSTER_ROW_PAD_X, 0)

    -- SetTexCoord trims the stock spec icon's baked-in border, which otherwise reads as a
    -- grey frame around every row's mark at this size.
    local specIcon = mark:CreateTexture(nil, "ARTWORK")
    specIcon:SetAllPoints(mark)
    specIcon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    specIcon:Hide()

    local dot = CreateFrame("Frame", nil, mark)
    dot:SetSize(ROSTER_ROW_DOT_SIZE, ROSTER_ROW_DOT_SIZE)
    dot:SetPoint("CENTER", mark, "CENTER", 0, 0)
    WTUI:AddRoundedFill(dot, ROSTER_ROW_DOT_SIZE / 2)

    -- "row": this pill is about the player on this row, never about the viewer.
    local pill = MakeStatusDot(row, ROSTER_ROW_PILL_SIZE, "in", "row")
    pill:SetPoint("RIGHT", row, "RIGHT", -ROSTER_ROW_PAD_X, 0)

    -- Info-icon slot (G-5nd-01, D-04): built ONCE here, like the leading mark above --
    -- nothing below is ever constructed inside SetPlayer. A Button, not a Frame:
    -- namespace:CreateIcon returns a Texture, which receives no OnEnter, so the hover
    -- target has to be the Button wrapping it (InspectScreen.lua's crest-tooltip idiom).
    -- EnableMouse(true) is the hard prerequisite for AddTooltip to ever fire
    -- (UI/Widgets/Pill.lua:29's documented gotcha). Hidden until SetPlayer decides this row
    -- is a selected alt.
    local infoSlot = CreateFrame("Button", nil, row)
    infoSlot:SetSize(ROSTER_ROW_INFO_SLOT_SIZE, ROSTER_ROW_INFO_SLOT_SIZE)
    infoSlot:SetPoint("RIGHT", pill, "LEFT", -ROSTER_ROW_GAP_X, 0)
    infoSlot:EnableMouse(true)
    local infoIcon = namespace:CreateIcon(infoSlot, "info", 12)
    infoIcon:SetPoint("CENTER", infoSlot, "CENTER", 0, 0)
    -- CreateIcon's idleAlpha default is 0.7; set explicitly so a future default change
    -- cannot silently drift this icon's opacity.
    infoIcon:SetAlpha(1)
    infoSlot:Hide()

    local nameLabel = row:CreateFontString(nil, "ARTWORK")
    nameLabel:SetFontObject(WildThingsFontBold13)
    nameLabel:SetPoint("LEFT", mark, "RIGHT", ROSTER_ROW_GAP_X, 0)
    nameLabel:SetPoint("RIGHT", infoSlot, "LEFT", -ROSTER_ROW_GAP_X, 0)
    nameLabel:SetJustifyH("LEFT")
    nameLabel:SetWordWrap(false)

    function row:SetPlayer(key, status, index, specID, mainKey)
        local color = KeyClassColor(key)
        nameLabel:SetText(DisplayKey(key))
        nameLabel:SetTextColor(color.r, color.g, color.b, color.a)

        -- Spec icon when the spec resolves, the class-colored disc when it does not. The
        -- dot branch is the sane fallback an unknown spec needs -- the same population
        -- ResolveColumn already defaults into MELEE -- and is exactly the shipped behavior,
        -- so nothing regresses for a pug or a client with stale generated roster data.
        local icon
        if specID and GetSpecializationInfoByID then
            local _, _, _, specIconFile = GetSpecializationInfoByID(specID)
            icon = specIconFile
        end

        if icon then
            specIcon:SetTexture(icon)
            specIcon:Show()
            dot:Hide()
        else
            specIcon:Hide()
            dot:SetRoundedFillColor(color.r, color.g, color.b, color.a)
            dot:Show()
        end

        local stripe = (index % 2 == 1) and Theme.ROW_ODD or Theme.ROW_EVEN
        bg:SetColorTexture(stripe.r, stripe.g, stripe.b, stripe.a)

        pill:SetStatus(status)
        row:SetAlpha(status == "bench" and ROSTER_BENCHED_ALPHA or 1.0)

        -- G-5nd-01 (D-04): mainKey is nil for everything that is not a selected alt --
        -- that IS the entire gating decision, computed once at the PopulateColumns call
        -- site. This row never learns about mainRanks.
        if type(mainKey) ~= "string" then
            -- The empty string is AddTooltip's supported suppression (its OnEnter
            -- early-returns on it), so no teardown and no stale tooltip is possible.
            WTUI:AddTooltip(infoSlot, "")
            infoSlot:Hide()
        else
            -- Read the main's class/spec from the LOCAL generated table, guarded hop by
            -- hop exactly as ResolveSpecID/KeyClassColor do. Do NOT call
            -- ResolveSpecID(payload, idx) for the main -- its first hop is the ALT's spec,
            -- and the main is not necessarily in payload.players at all.
            local mainCharInfo = Private.RosterAlts and Private.RosterAlts.charInfo
                and Private.RosterAlts.charInfo[mainKey]
            local mainSpecID = mainCharInfo and mainCharInfo.specID
            local iconEscape = ""
            if mainSpecID and GetSpecializationInfoByID then
                local _, _, _, mainSpecIconFile = GetSpecializationInfoByID(mainSpecID)
                if mainSpecIconFile then
                    iconEscape = "|T" .. mainSpecIconFile .. ":14|t "
                end
            end
            local mainColor = KeyClassColor(mainKey)
            local body = iconEscape .. "|cff" .. RGBToHex(mainColor) .. DisplayKey(mainKey) .. "|r"
            WTUI:AddTooltip(infoSlot, "Main", body)
            infoSlot:Show()
        end
    end

    return row
end

-- AcquireRow(col, index) -- the per-column row pool. Index into col.rows and build only on
-- a miss; the caller populates 1..n and hides the remainder. Rows are NEVER recreated: the
-- bottom section survives every repaint, so rebuilding its rows would leak exactly the way
-- rebuilding the section would. Bounded by the payload's 60-player cap.
local function AcquireRow(col, index)
    local row = col.rows[index]
    if not row then
        row = CreateRosterRow(col.body.contentFrame)
        col.rows[index] = row
    end
    row:Show()
    return row
end

-- Bottom-section role columns in design order (DESIGN-NOTES §5). `key` is the bucket key
-- ResolveColumn returns; the badge color/tint pairs are the design's semantic
-- tokens verbatim (Tank INFO, Heal OK/ACCENT, Melee DANGER, Ranged WARN).
-- `atlas` carries the game's own role icon so the badge shows it instead of a letter. WoW
-- ships exactly THREE role icons, so Melee and Ranged necessarily share the dps icon -- an
-- accepted consequence, not a defect: the two stay distinguishable by their per-column badge
-- tint (DANGER_A13 vs WARN_A13) and by the role label beside the badge. `letter` is kept as
-- the fallback glyph, not dead data.
--
-- Atlas, NOT GetTexCoordsForRoleSmallCircle + UI-LFG-ICON-ROLES: that helper is absent from
-- this client, so the previous attempt silently fell through to the letter fallback every
-- time. Confirmed against multiple reference addons -- both use these atlas names, and
-- NOTHING installed still calls the old helper.
local ROLE_COLUMNS = {
    { key = "TANK",   letter = "T", atlas = "roleicon-tiny-tank",   label = "Tank",   color = Theme.INFO,   tint = Theme.INFO_A13 },
    { key = "HEAL",   letter = "H", atlas = "roleicon-tiny-healer", label = "Heal",   color = Theme.OK,     tint = Theme.ACCENT_A13 },
    { key = "MELEE",  letter = "M", atlas = "roleicon-tiny-dps",    label = "Melee",  color = Theme.DANGER, tint = Theme.DANGER_A13 },
    { key = "RANGED", letter = "R", atlas = "roleicon-tiny-dps",    label = "Ranged", color = Theme.WARN,   tint = Theme.WARN_A13 },
}

-- CreateBottomPanel(parent) -- the chrome every bottom-section card shares: a rounded
-- SURFACE_1 fill with a 1px BORDER stroke and a BOTTOM_HEADER_HEIGHT STRIP_BG header band
-- closed by a BORDER hairline (DESIGN-NOTES §5). The model panel and all four role columns
-- are the same card with different header contents, so the chrome is built once here
-- rather than five times. Returns the panel with `panel.header` attached; the caller
-- anchors and sizes it.
local function CreateBottomPanel(parent)
    local panel = CreateFrame("Frame", nil, parent)
    WTUI:AddRoundedFill(panel, Theme.RADIUS_MD, Theme.SURFACE_1)
    WTUI:AddRoundedBorder(panel, Theme.RADIUS_MD)
    local border = Theme.BORDER
    panel:SetBorderColor(border.r, border.g, border.b, border.a)

    local header = CreateFrame("Frame", nil, panel)
    header:SetPoint("TOPLEFT", panel, "TOPLEFT", BOTTOM_CARD_INSET, -BOTTOM_CARD_INSET)
    header:SetPoint("TOPRIGHT", panel, "TOPRIGHT", -BOTTOM_CARD_INSET, -BOTTOM_CARD_INSET)
    header:SetHeight(BOTTOM_HEADER_HEIGHT)

    local strip = header:CreateTexture(nil, "BACKGROUND")
    strip:SetAllPoints(header)
    local sb = Theme.STRIP_BG
    strip:SetColorTexture(sb.r, sb.g, sb.b, sb.a)

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

    panel.header = header
    return panel
end

-- ApplyColumnContentWidth(col, columnWidth) -- one column's scroll content width. The
-- arithmetic is unchanged: the body is inset 1px on each side of the column, and
-- scrollBarReserve is the widget's own published horizontal reserve (WR-07: read it, never
-- re-derive it). What changed is that the reserve is now LIVE -- these column bodies opt
-- into reclaimHiddenScrollBarWidth, so it reads 0 whenever that column's bar is hidden and
-- the rows reach the column's inner edge instead of stopping short of an absent scrollbar.
--
-- Factored out because it now has two callers: section:Layout, and the per-column
-- scrollbar-visibility callback that fires after the reserve flips.
local function ApplyColumnContentWidth(col, columnWidth)
    local content = col.body.contentFrame
    content:SetWidth(math.max(1, columnWidth - 2 - (col.body.scrollBarReserve or 0)))
end

-- ShrunkColumnHeight(col, slotHeight) -- the height a role column needs to show all of its
-- content without scrolling, for the cards stacked ABOVE the one that fills a slot.
--
-- The terms mirror how the card is assembled: the header band, the 2px gap the body is
-- anchored below it with, the scroll widget's own vertical inset, the laid-out content, and
-- the 2px the body is inset from the card's bottom.
--
-- `contentHeight` is whatever PopulateColumns last measured. It is nil before the first
-- populate, which is exactly when Layout runs first -- the floor covers that pass, and the
-- Layout call at the end of PopulateColumns settles the real height.
--
-- Clamped both ways. The floor keeps an empty column readable: header plus the SELECTED
-- label must still fit, or a boss with no tank renders as an unreadable sliver. The ceiling
-- is half the slot, so a freak roster (a malformed payload resolving everyone to TANK)
-- cannot squeeze the card below it to nothing -- past the cap the column simply scrolls,
-- which is what its scroll body is for.
local function ShrunkColumnHeight(col, slotHeight)
    -- Everything between the card's outer edge and its scrollable viewport, derived from
    -- the same constants the anchors use rather than re-stated as literals -- restating
    -- them is exactly what dropped BOTTOM_CARD_INSET and left this 1px short, which is all
    -- it takes for the column to keep a scrollbar with visible room to spare.
    -- verticalInset is the scroll widget's own published figure (WR-07: read it, never
    -- re-derive it).
    local chrome = BOTTOM_CARD_INSET + BOTTOM_HEADER_HEIGHT + BOTTOM_BODY_TOP_GAP
        + BOTTOM_BODY_BOTTOM_GAP + (col.body.verticalInset or 0)

    local floor = chrome + ROSTER_BODY_PAD_Y + ROSTER_SECTION_LABEL_HEIGHT + ROSTER_BODY_PAD_Y

    -- Rounded UP: content height accumulates off a column width that is a fraction of the
    -- row, so it can land on fractional pixels -- and a sub-pixel shortfall trips the same
    -- unscrollable-by-a-hair check.
    local needed = math.ceil(chrome + (col.contentHeight or 0))

    local ceiling = (slotHeight - BOTTOM_COLUMN_GAP) / 2
    if ceiling < floor then
        return floor
    end
    return math.max(floor, math.min(needed, ceiling))
end

-- CreateBottomSection(root) -- the persistent per-boss detail row (DESIGN-NOTES §5): a
-- live 3D boss-model panel on the left plus four role columns on the right.
--
-- CALLED AT MOST ONCE PER SESSION. See the `bottomSection` module-local's comment for why
-- this must never be rebuilt per repaint or enter renderedFrames. Everything below is
-- constructed empty and driven afterwards through section:Update().
local function CreateBottomSection(root)
    local section = CreateFrame("Frame", nil, root)
    section:SetPoint("TOPLEFT", root, "TOPLEFT", Theme.PAD_WINDOW, -BOTTOM_SECTION_TOP)
    section:SetPoint("BOTTOMRIGHT", root, "BOTTOMRIGHT", -Theme.PAD_WINDOW, Theme.PAD_WINDOW)
    section:Hide()

    -- ---------------------------------------------------------------- model panel (left)
    local panel = CreateBottomPanel(section)
    panel:SetPoint("TOPLEFT", section, "TOPLEFT", 0, 0)
    panel:SetPoint("BOTTOMLEFT", section, "BOTTOMLEFT", 0, 0)
    panel:SetWidth(BOTTOM_MODEL_MIN_WIDTH)

    local text = Theme.TEXT
    local nameLabel = panel.header:CreateFontString(nil, "ARTWORK")
    nameLabel:SetFontObject(WildThingsFontBold13)
    nameLabel:SetTextColor(text.r, text.g, text.b, text.a)
    nameLabel:SetPoint("TOPLEFT", panel.header, "TOPLEFT", 8, -4)
    nameLabel:SetPoint("TOPRIGHT", panel.header, "TOPRIGHT", -8, -4)
    nameLabel:SetJustifyH("LEFT")
    nameLabel:SetWordWrap(false)

    local stateLabel = panel.header:CreateFontString(nil, "ARTWORK")
    stateLabel:SetFontObject(WildThingsFont12)
    stateLabel:SetPoint("TOPLEFT", nameLabel, "BOTTOMLEFT", 0, -2)
    stateLabel:SetJustifyH("LEFT")

    local viewport = CreateFrame("Frame", nil, panel)
    viewport:SetPoint("TOPLEFT", panel.header, "BOTTOMLEFT", 0, 0)
    viewport:SetPoint("BOTTOMRIGHT", panel, "BOTTOMRIGHT", -1, 1)

    -- Recorded design deviation: the mock's background is a radial gradient
    -- (radial-gradient(120% 120% at 50% 12%, #1D2A20, #070A08)). WoW textures have no
    -- radial gradient primitive -- SetGradient is linear only -- so this approximates it
    -- with the same two stops on a vertical axis, lighter at the top where the radial's
    -- focal point sits.
    local viewportBG = viewport:CreateTexture(nil, "BACKGROUND")
    viewportBG:SetAllPoints(viewport)
    viewportBG:SetColorTexture(1, 1, 1, 1)
    viewportBG:SetGradient(
        "VERTICAL",
        CreateColor(0x07/255, 0x0A/255, 0x08/255, 1),
        CreateColor(0x1D/255, 0x2A/255, 0x20/255, 1))

    local watermark = namespace:CreateIcon(viewport, "skull", 96)
    watermark:SetPoint("CENTER", viewport, "CENTER", 0, 0)
    watermark:ClearTint()
    watermark:SetAlpha(0.05)

    -- Placeholder (D-01's graceful degradation): the DEFAULT state, shown whenever no
    -- display ID resolves -- an EJ-cold client, an unknown/future boss id, or the
    -- /wt timelinetest harness's synthetic id. The panel is never empty.
    local placeholder = CreateFrame("Frame", nil, viewport)
    placeholder:SetAllPoints(viewport)

    local initials = placeholder:CreateFontString(nil, "ARTWORK")
    initials:SetFontObject(WildThingsFontBold34)
    initials:SetTextColor(text.r, text.g, text.b, text.a)
    initials:SetPoint("CENTER", placeholder, "CENTER", 0, 10)

    local disabled = Theme.TEXT_DISABLED
    local caption = placeholder:CreateFontString(nil, "ARTWORK")
    caption:SetFontObject(WildThingsFont12)
    caption:SetTextColor(disabled.r, disabled.g, disabled.b, disabled.a)
    caption:SetPoint("TOP", initials, "BOTTOM", 0, -6)
    caption:SetText("3D MODEL")

    -- EnableMouse(false): the model must never intercept clicks (EXBoss BossPage.lua:3722).
    -- Hidden at construction so the placeholder above is what renders until a display ID
    -- actually resolves.
    local model = CreateFrame("PlayerModel", nil, viewport)
    model:SetAllPoints(viewport)
    model:EnableMouse(false)
    model:Hide()

    -- ------------------------------------------------------------- role columns (right)
    section.columns = {}
    -- Keyed alongside the ordered list: PopulateColumns walks `columns`, Layout's slot
    -- table addresses them by role key.
    section.columnsByKey = {}
    local dim = Theme.TEXT_DIM
    for _, def in ipairs(ROLE_COLUMNS) do
        local col = CreateBottomPanel(section)
        col.key = def.key

        local badge = CreateFrame("Frame", nil, col.header)
        badge:SetSize(20, 20)
        badge:SetPoint("LEFT", col.header, "LEFT", 8, 0)
        WTUI:AddRoundedFill(badge, Theme.RADIUS_SM, def.tint)

        -- The game's own role icon. Guarded on the ATLAS resolving rather than on an API
        -- existing: SetAtlas silently draws nothing for an unknown atlas name, so probing
        -- the name up front is the only way this can fall back instead of rendering an
        -- empty badge. The original 11px bold letter stays as that fallback. The badge's
        -- tint fill, the role label and the count are untouched either way; only what sits
        -- inside the 20px badge changes.
        --
        -- SetSize after SetAtlas is deliberate: SetAtlas without useAtlasSize keeps the
        -- texture's current size, and the atlas art is smaller than the 16px this wants.
        if C_Texture and C_Texture.GetAtlasInfo and C_Texture.GetAtlasInfo(def.atlas) then
            local roleIcon = badge:CreateTexture(nil, "ARTWORK")
            roleIcon:SetAtlas(def.atlas)
            roleIcon:SetSize(16, 16)
            roleIcon:SetPoint("CENTER", badge, "CENTER", 0, 0)
        else
            local glyph = badge:CreateFontString(nil, "ARTWORK")
            glyph:SetFontObject(WildThingsFontBold11)
            glyph:SetTextColor(def.color.r, def.color.g, def.color.b, def.color.a)
            glyph:SetPoint("CENTER", badge, "CENTER", 0, 0)
            glyph:SetText(def.letter)
        end

        local countLabel = col.header:CreateFontString(nil, "ARTWORK")
        countLabel:SetFontObject(WildThingsFontNum12)
        countLabel:SetTextColor(dim.r, dim.g, dim.b, dim.a)
        countLabel:SetPoint("RIGHT", col.header, "RIGHT", -8, 0)
        countLabel:SetText("0")

        local roleLabel = col.header:CreateFontString(nil, "ARTWORK")
        roleLabel:SetFontObject(WildThingsFontBold14)
        roleLabel:SetTextColor(text.r, text.g, text.b, text.a)
        roleLabel:SetPoint("LEFT", badge, "RIGHT", Theme.GAP, 0)
        roleLabel:SetPoint("RIGHT", countLabel, "LEFT", -Theme.GAP, 0)
        roleLabel:SetJustifyH("LEFT")
        roleLabel:SetWordWrap(false)
        roleLabel:SetText(def.label)

        -- The only caller in the addon that opts into the reclaim behavior: these four
        -- columns are narrow enough that the ~24px reserved for a scrollbar reads as a
        -- visibly short row whenever the column has no scrollbar at all.
        local body = namespace:CreateScrollFrame(col, { reclaimHiddenScrollBarWidth = true })
        body:SetPoint("TOPLEFT", col.header, "BOTTOMLEFT", 0, -BOTTOM_BODY_TOP_GAP)
        body:SetPoint("BOTTOMRIGHT", col, "BOTTOMRIGHT",
            -BOTTOM_CARD_INSET, BOTTOM_BODY_BOTTOM_GAP)

        -- The two section labels are built once per column here and merely repositioned
        -- (and, for BENCHED, shown/hidden) on each update -- same create-once rule as the
        -- rows below them.
        local section1 = Theme.TEXT_SECTION
        local selectedLabel = body.contentFrame:CreateFontString(nil, "ARTWORK")
        selectedLabel:SetFontObject(WildThingsFont12)
        selectedLabel:SetTextColor(section1.r, section1.g, section1.b, section1.a)
        selectedLabel:SetText("SELECTED")

        local benchedLabel = body.contentFrame:CreateFontString(nil, "ARTWORK")
        benchedLabel:SetFontObject(WildThingsFont12)
        benchedLabel:SetTextColor(section1.r, section1.g, section1.b, section1.a)
        benchedLabel:SetText("BENCHED")
        benchedLabel:Hide()

        col.countLabel = countLabel
        col.body = body

        -- Re-apply this column's content width whenever its scrollbar appears or disappears,
        -- since the published reserve flips with it. By the time visibility resolves (the
        -- FullUpdate at the end of PopulateColumns) the column has a real width, so reading
        -- it here is safe; the guard mirrors Layout's own, for the case where it does not.
        -- This cannot loop: scrollbar visibility depends only on content HEIGHT versus
        -- viewport height, which a width change does not feed back into.
        body.onScrollBarVisibilityChanged = function()
            local width = col:GetWidth() or 0
            if width >= 1 then
                ApplyColumnContentWidth(col, width)
            end
        end

        -- Re-settle the scrollbar whenever this body's rect actually changes.
        --
        -- FullUpdate decides scrollability against the viewport height AT THE MOMENT IT
        -- RUNS, and a frame's new rect is not readable in the same frame its anchors were
        -- changed. PopulateColumns' post-layout FullUpdate therefore still judges a
        -- shrink-to-fit card against its PREVIOUS height -- which is how the Tank column
        -- kept a scrollbar with visible room to spare. Nothing else re-evaluates visibility
        -- on a later resize, so the stale bar just stayed up.
        --
        -- Cannot loop: FullUpdate only ever changes the scrollbar's visibility and (via the
        -- reserve callback) the CONTENT frame's width. Neither resizes this body, whose
        -- rect comes from its anchors to the column card.
        body:SetScript("OnSizeChanged", function()
            body:FullUpdate()
            if body.ApplyScrollBarReserve then
                body.ApplyScrollBarReserve()
            end
        end)
        col.selectedLabel = selectedLabel
        col.benchedLabel = benchedLabel
        -- The per-column row pool: rows are indexed into and built only on a miss,
        -- never recreated. Bounded by the payload's 60-player cap.
        col.rows = {}
        table.insert(section.columns, col)
        section.columnsByKey[def.key] = col
    end

    -- Layout slots. Tank and Heal share ONE slot stacked vertically; Melee and Ranged keep
    -- a slot each. Tank is almost always 2-3 players against Ranged's 11, so giving it a
    -- full-height quarter of the row wasted the most space of any column on the screen.
    --
    -- Built by KEY, not by position, so reordering ROLE_COLUMNS cannot silently pair the
    -- wrong two columns. Each slot is a top-to-bottom list; a 1-entry slot fills its whole
    -- height, and in a 2-entry slot the FIRST shrinks to its content and the second takes
    -- whatever is left.
    section.slots = {
        { section.columnsByKey.TANK, section.columnsByKey.HEAL },
        { section.columnsByKey.MELEE },
        { section.columnsByKey.RANGED },
    }

    -- section:Layout() -- the model panel takes max(120px, 15% of the row) and the role
    -- columns split the remainder (DESIGN-NOTES §5). Three SLOTS, not four columns: Tank
    -- and Heal are stacked in the first, so the row reads
    -- [model] [Tank / Heal] [Melee] [Ranged].
    --
    -- Called from Update, from OnSizeChanged, and again at the end of PopulateColumns.
    -- The first two can run before a frame has a resolved width or before any content has
    -- been measured; whichever pass has real numbers wins, and the others are cheap no-ops.
    function section:Layout()
        local width = section:GetWidth() or 0
        if width < 1 then
            return
        end

        -- The model panel is opt-out (Settings > Raid night). Disabled, it is hidden
        -- outright and contributes neither width nor gap, so the four role columns split
        -- the whole row instead of leaving a hole where the panel used to be. Read here
        -- rather than cached, so the Settings toggle takes effect on the next Layout.
        local showModel = WildThingsSavedVars.timeline.show_boss_model ~= false
        local panelWidth, panelGap = 0, 0
        if showModel then
            panelWidth = math.max(BOTTOM_MODEL_MIN_WIDTH, width * BOTTOM_MODEL_WIDTH_FRACTION)
            panelGap = BOTTOM_MODEL_GAP
            panel:SetWidth(panelWidth)
            panel:Show()
        else
            panel:Hide()
        end

        local slotCount = #section.slots
        local remaining = width - panelWidth - panelGap
            - BOTTOM_COLUMN_GAP * (slotCount - 1)
        local columnWidth = remaining / slotCount
        if columnWidth < 1 then
            return
        end

        local x = panelWidth + panelGap
        for _, slot in ipairs(section.slots) do
            for slotIndex, col in ipairs(slot) do
                col:ClearAllPoints()
                col:SetWidth(columnWidth)

                if slotIndex == 1 then
                    col:SetPoint("TOPLEFT", section, "TOPLEFT", x, 0)
                else
                    col:SetPoint("TOPLEFT", slot[slotIndex - 1], "BOTTOMLEFT", 0, -BOTTOM_COLUMN_GAP)
                end

                if slotIndex == #slot then
                    -- The last card in a slot is anchored to the section's own bottom, so it
                    -- absorbs whatever the shrunk cards above it did not take.
                    col:SetPoint("BOTTOMLEFT", section, "BOTTOMLEFT", x, 0)
                else
                    col:SetHeight(ShrunkColumnHeight(col, section:GetHeight() or 0))
                end

                -- Scroll content width is derived from the column width rather than measured
                -- off the scroll frame, so it is correct on the very first pass -- a freshly
                -- created frame's own rect is not resolved yet. Rows anchor TOPLEFT/TOPRIGHT
                -- to that frame, so they follow it without extra work.
                ApplyColumnContentWidth(col, columnWidth)
            end

            x = x + columnWidth + BOTTOM_COLUMN_GAP
        end
    end

    section:SetScript("OnSizeChanged", function()
        section:Layout()
    end)

    -- section:SetBoss(...) -- EXBoss's exact ApplyPortrait ordering (RESEARCH Q1), which
    -- all three prior-art addons agree on:
    --   1. ClearModel() BEFORE SetDisplayInfo when the creature actually changes.
    --   2. camera/zoom/position/facing AFTER SetDisplayInfo -- setting them first is a no-op.
    --   3. an _appliedDisplayID memo so re-selecting the showing boss (or any repaint that
    --      doesn't change the selection, e.g. every ENCOUNTER_END) is a no-op instead of a
    --      visible model reload.
    -- Every model method is guarded on its own existence, the house style for external API
    -- surface, so a client missing one of them degrades instead of erroring.
    function section:SetBoss(name, displayID, stateText, stateColor)
        nameLabel:SetText(name or "")
        stateLabel:SetText(stateText or "")
        if stateColor then
            stateLabel:SetTextColor(stateColor.r, stateColor.g, stateColor.b, stateColor.a)
        end

        local id = tonumber(displayID)
        if id and id > 0 and model.SetDisplayInfo then
            if section._appliedDisplayID ~= id then
                if model.ClearModel then model:ClearModel() end
                model:SetDisplayInfo(id)
                section._appliedDisplayID = id
            end
            if model.SetPortraitZoom then model:SetPortraitZoom(0.85) end
            if model.SetCamDistanceScale then model:SetCamDistanceScale(1.05) end
            if model.SetPosition then model:SetPosition(0, 0, 0) end
            if model.SetFacing then model:SetFacing(0) end
            model:Show()
            placeholder:Hide()
        else
            section._appliedDisplayID = nil
            model:Hide()
            initials:SetText(BossInitials(name))
            placeholder:Show()
        end
    end

    -- Re-arm the display-ID memo whenever the section goes away (WR-02 review fix). The
    -- memo exists to make a repaint that does not change the selection a no-op, but it is
    -- set unconditionally next to SetDisplayInfo -- so a load that silently failed would
    -- otherwise be latched for the rest of the session, leaving a gradient-only panel with
    -- the initials fallback suppressed. Dropping it on hide means the next Show re-applies.
    section:SetScript("OnHide", function()
        section._appliedDisplayID = nil
    end)

    -- Same re-arm for the panel itself, which Layout hides on its own whenever the Settings
    -- toggle turns the model off. Without this the memo would still hold the last display ID
    -- when the panel came back, and SetBoss would skip the SetDisplayInfo that repopulates it.
    panel:SetScript("OnHide", function()
        section._appliedDisplayID = nil
    end)

    -- section:PopulateColumns(payload, boss) -- fills the four role columns for one boss.
    --
    -- An index present in boss.list is SELECTED. Every other index is either BENCHED or,
    -- when it is grouped with a selected or already-represented person (G-5nd-01, D-02/D-03),
    -- not rendered at all -- an owner pre-pass runs above the bucket fill and one condition
    -- on that fill's single insert leaves the degrade case (no payload.mains/mainRanks)
    -- byte-for-byte what it always was: every index lands in exactly one column.
    function section:PopulateColumns(payload, boss)
        -- G-5nd-01 (D-06): a single line references both fields so the degrade path is
        -- visibly gated on both being present -- when either is absent every owner-grouping
        -- step below is skipped and the bucket-fill loop falls through to its unconditional
        -- insert, unchanged from before this feature.
        local resolveOwners = type(payload.mains) == "table" and type(payload.mainRanks) == "table"
        local mains = payload.mains
        local ranks = payload.mainRanks

        local selectedSet = {}
        -- G-5nd-01 (D-02): global to the boss, across all four columns -- a person's main
        -- can be a TANK while their alt is RANGED, so this set has to span every column or
        -- the alt still renders on that other column's bench (RESEARCH I1).
        local selectedOwners = {}
        for _, idx in ipairs(boss.list) do
            selectedSet[idx] = true
            if resolveOwners then
                selectedOwners[OwnerOf(mains, idx)] = true
            end
        end

        -- G-5nd-01 (D-03): the bench representative, also global to the boss (RESEARCH I2).
        -- For every index that is neither selected nor owned by a selected owner, keep the
        -- lowest-ranked index per owner -- rank 1 is the main, so the main wins whenever it
        -- is part of the night at all. An equal rank (a tie, or malformed data) keeps the
        -- first seen, i.e. the lowest player index.
        local benchRep = {}
        if resolveOwners then
            for idx in ipairs(payload.players) do
                local owner = OwnerOf(mains, idx)
                if not selectedSet[idx] and not selectedOwners[owner] then
                    local current = benchRep[owner]
                    if not current or ranks[idx] < ranks[current] then
                        benchRep[owner] = idx
                    end
                end
            end
        end

        -- ResolveColumn only ever returns one of these four keys, so the bucket lookup
        -- below can never hit a nil bucket, whatever a hostile payload puts in `roles`.
        local buckets = {}
        for _, def in ipairs(ROLE_COLUMNS) do
            buckets[def.key] = { selected = {}, benched = {} }
        end

        -- Buckets hold player INDICES, not keys: a row now needs both the key
        -- (payload.players[idx]) and the spec (ResolveSpecID(payload, idx)), and the index
        -- resolves both without allocating a wrapper table per player.
        --
        -- A SELECTED index is inserted unconditionally, exactly as before -- including two
        -- characters of the same person in one boss.list (the plan is the plan; the
        -- selected bucket is never deduped). A non-selected index is inserted only when it
        -- is its owner's bench representative; `not resolveOwners` short-circuits ahead of
        -- the benchRep read so the degrade case falls through to the same unconditional
        -- insert this loop has always done.
        for idx in ipairs(payload.players) do
            local bucket = buckets[ResolveColumn(payload, idx)]
            local isSelected = selectedSet[idx]
            if isSelected then
                table.insert(bucket.selected, idx)
            elseif not resolveOwners or benchRep[OwnerOf(mains, idx)] == idx then
                table.insert(bucket.benched, idx)
            end
        end

        for _, col in ipairs(section.columns) do
            local bucket = buckets[col.key]
            local content = col.body.contentFrame
            local y = -ROSTER_BODY_PAD_Y
            local rowIndex = 0

            local function LayoutGroup(indices, status)
                for _, idx in ipairs(indices) do
                    rowIndex = rowIndex + 1
                    local row = AcquireRow(col, rowIndex)
                    row:ClearAllPoints()
                    row:SetPoint("TOPLEFT", content, "TOPLEFT", 0, y)
                    row:SetPoint("TOPRIGHT", content, "TOPRIGHT", 0, y)
                    local specID = ResolveSpecID(payload, idx)
                    -- G-5nd-01 (D-04): the owner key only when this row is SELECTED and
                    -- this index's rank is greater than 1 -- nil in the degrade case (no
                    -- resolveOwners) and nil for every bench row (CONTEXT's discretion
                    -- note: a bench alt's tooltip would name a main who is not playing).
                    local mainKey = (status == "in" and resolveOwners and ranks[idx]
                        and ranks[idx] > 1) and mains[idx] or nil
                    row:SetPlayer(payload.players[idx], status, rowIndex, specID, mainKey)
                    y = y - ROSTER_ROW_HEIGHT
                end
            end

            -- SELECTED is always present, even when empty -- a column with no tank reads as
            -- "no tank in this role", not as a broken column.
            col.selectedLabel:ClearAllPoints()
            col.selectedLabel:SetPoint("TOPLEFT", content, "TOPLEFT", ROSTER_ROW_PAD_X, y)
            y = y - ROSTER_SECTION_LABEL_HEIGHT
            LayoutGroup(bucket.selected, "in")

            -- BENCHED is appended only when this column actually has benched players.
            if #bucket.benched > 0 then
                y = y - ROSTER_BODY_PAD_Y
                col.benchedLabel:ClearAllPoints()
                col.benchedLabel:SetPoint("TOPLEFT", content, "TOPLEFT", ROSTER_ROW_PAD_X, y)
                col.benchedLabel:Show()
                y = y - ROSTER_SECTION_LABEL_HEIGHT
                LayoutGroup(bucket.benched, "bench")
            else
                col.benchedLabel:Hide()
            end

            -- Hide the pool's tail rather than destroying it: the next boss may need them.
            for i = rowIndex + 1, #col.rows do
                col.rows[i]:Hide()
            end

            -- The header count is the SELECTED entries only (DESIGN-NOTES §5) -- it answers
            -- "how many of this role are in", not "how many exist".
            col.countLabel:SetText(tostring(#bucket.selected))

            col.contentHeight = math.max(-y + ROSTER_BODY_PAD_Y, 1)
            content:SetHeight(col.contentHeight)
        end

        -- Re-layout now that every column has measured its content. Layout runs BEFORE this
        -- (section:Update calls it first, and it has to -- the columns need their width to
        -- lay rows out at all), so on that pass the stacked Tank card falls back to its
        -- floor height. This second pass is what actually shrinks it to the roster in hand.
        -- Cheap and idempotent: pure anchor/size assignment, no frame creation.
        section:Layout()

        -- FullUpdate strictly AFTER the re-layout, in its own pass. It is what settles
        -- whether a column needs a scrollbar, and it answers that against the viewport
        -- height AT THE MOMENT IT RUNS -- so calling it inside the loop above judged the
        -- stacked Tank card while it was still at its floor height, found its content
        -- overflowing, and showed a scrollbar that the shrink-to-fit then made wrong.
        -- Nothing re-evaluates visibility on a later resize, so the bar stayed.
        --
        -- The reserve re-apply rides along for the same reason it always did: a bar that
        -- was already shown (or already hidden) fires no OnShow/OnHide, so the very first
        -- paint would otherwise never evaluate the reserve at all. Guarded -- the method
        -- exists only on the opt-in path.
        for _, col in ipairs(section.columns) do
            col.body:FullUpdate()
            if col.body.ApplyScrollBarReserve then
                col.body.ApplyScrollBarReserve()
            end
        end
    end

    -- section:Update(payload, boss, cacheEntry, selfKey) -- repoints the whole section at
    -- one boss. The kill/prog/not-pulled state is derived exactly as CreateBossCell derives
    -- it, from the same two accessors -- no pull-count or current-boss logic is recomputed
    -- here (C-04: the UI only renders what Timeline.lua's accessors return).
    function section:Update(payload, boss, cacheEntry, selfKey)
        section:Layout()

        local isKilled, isCurrentBoss = DeriveBossState(boss)

        local stateText, stateColor
        if isKilled then
            stateText, stateColor = "Killed", Theme.OK
        elseif isCurrentBoss then
            local pullCount = Private:GetTimelinePullCount(boss.id)
            stateText = pullCount == 1 and (pullCount .. " pull") or (pullCount .. " pulls")
            stateColor = Theme.WARN
        else
            stateText, stateColor = "Not pulled", Theme.NEUTRAL
        end

        section:SetBoss(boss.name, cacheEntry and cacheEntry.bossDisplayID, stateText, stateColor)
        section:PopulateColumns(payload, boss)
    end

    return section
end

-- RenderInto(root) -- the single render path shared by CreateRaidTimelineScreen (first
-- build) and RefreshTimelineScreen (repaint on a new/relayed payload). Tears down any
-- previously rendered placeholder/cells, then rebuilds from the current
-- WildThingsSavedVars.timeline.payload. Never errors when the payload is nil (locked
-- decision, CONTEXT.md "no-op-safe when there is no timeline yet").
local function RenderInto(root)
    ClearRendered()

    local payload = WildThingsSavedVars.timeline and WildThingsSavedVars.timeline.payload

    if not payload then
        -- Hide (never SetParent(nil)) the persistent bottom section: with no plan there is
        -- no boss to detail, but the frame set stays intact for the next payload.
        if bottomSection then
            bottomSection:Hide()
        end

        -- G-37-1: build the header (title + identity + officer-gated Share control)
        -- even with no plan yet -- this empty state is precisely when the first share
        -- is needed, and CreateHeaderRow is now nil-payload-safe.
        CreateHeaderRow(root, nil, nil, nil)

        local placeholder = root:CreateFontString(nil, "ARTWORK")
        placeholder:SetFontObject(WildThingsFont13)
        placeholder:SetPoint("CENTER", root, "CENTER", 0, 0)
        placeholder:SetTextColor(1, 1, 1, 0.53)
        placeholder:SetText("No raid night plan has been shared yet.")
        table.insert(renderedFrames, placeholder)
        return
    end

    -- Default the selection so the detail row is never empty on a fresh screen. Without
    -- this, selectedBossID is nil until the user clicks a tile -- so after a /reload the
    -- bottom half of the screen was blank, which reads as broken rather than as "nothing
    -- picked yet".
    --
    -- The current boss (first non-defeated, Timeline.lua's own accessor) is the same boss
    -- the strip already auto-centres on, so opening the screen lands on where the raid
    -- actually is. Falls back to the first boss when every boss is down, since a finished
    -- night still has to show something. Assigned BEFORE the strip is built, because
    -- CreateBossCell reads selectedBossID to decide which tile draws the selected ring.
    if not selectedBossID and payload.bosses[1] then
        selectedBossID = Private:GetTimelineCurrentBossID() or payload.bosses[1].id
    end

    local selfKey = Private:GetSelfKey()
    local cache = BuildInstanceLookupCache()
    local groups = GroupBossesByInstance(payload.bosses, cache)

    CreateHeaderRow(root, payload, groups, selfKey)

    -- Divider (1px) between the header row and the timeline strip.
    local divider = root:CreateTexture(nil, "ARTWORK")
    divider:SetHeight(1)
    local border = Theme.BORDER_STRONG
    divider:SetColorTexture(border.r, border.g, border.b, border.a)
    divider:SetSnapToPixelGrid(false)
    divider:SetTexelSnappingBias(0)
    -- WR-03 review fix: read the shared constants instead of re-deriving the same
    -- arithmetic here. The constant block claims the strip and the bottom section can
    -- never disagree about where the strip ends; that only holds if this is their only
    -- consumer chain, which re-deriving quietly broke.
    divider:SetPoint("TOPLEFT", root, "TOPLEFT", Theme.PAD_WINDOW, -DIVIDER_Y)
    divider:SetPoint("TOPRIGHT", root, "TOPRIGHT", -Theme.PAD_WINDOW, -DIVIDER_Y)
    table.insert(renderedFrames, divider)

    -- Screen-local horizontal-pan strip (RESEARCH.md Pitfall 1): a clipped viewport
    -- over a wider content frame holding the instance-grouped columns, panned by wheel.
    local stripViewport = CreateFrame("Frame", nil, root)
    stripViewport:SetPoint("TOPLEFT", root, "TOPLEFT", Theme.PAD_WINDOW, -STRIP_TOP)
    stripViewport:SetPoint("TOPRIGHT", root, "TOPRIGHT", -Theme.PAD_WINDOW, -STRIP_TOP)
    stripViewport:SetHeight(STRIP_HEIGHT)
    table.insert(renderedFrames, stripViewport)

    local stripContent = CreateFrame("Frame", nil, stripViewport)
    stripContent:SetHeight(STRIP_HEIGHT)
    table.insert(renderedFrames, stripContent)

    -- Instance groups in first-seen order (pull order preserved within/across groups).
    local x = 0
    local currentCellX, currentCellWidth
    for _, group in ipairs(groups) do
        local width, groupCurrentX, groupCurrentWidth =
            CreateInstanceGroup(stripContent, group, payload, selfKey, x, cache)
        if groupCurrentX then
            currentCellX, currentCellWidth = x + groupCurrentX, groupCurrentWidth
        end
        x = x + width + INSTANCE_GAP
    end
    local contentWidth = math.max(x - INSTANCE_GAP, 1)
    stripContent:SetWidth(contentWidth)

    -- Auto-centre the current-progression boss on open (DESIGN-NOTES §7). One-shot: the
    -- flag is cleared here, so subsequent repaints (every ENCOUNTER_END, every received
    -- payload) preserve whatever the raid lead has panned to. AttachHorizontalPan below
    -- clamps this to the same min/max range it enforces for a wheel pan, so a centre
    -- request near either end simply lands at that end.
    -- WR-04 review fix: consumption is keyed on "the geometry was resolved, so this pass
    -- had its shot", NOT on "a centre was actually applied". Clearing only on the success
    -- path left the flag armed when there was no current boss to centre on, so a later
    -- ENCOUNTER_END repaint could still fire it and yank a deliberately panned strip --
    -- the exact thing the one-shot discipline exists to prevent. A pass with no resolved
    -- viewport width yet (a freshly created stripViewport's rect may not be resolved)
    -- genuinely had no shot, so that one still defers.
    if pendingCenterOnCurrent then
        local viewportWidth = stripViewport:GetWidth() or 0
        if viewportWidth > 0 then
            if currentCellX then
                panOffsetX = -(currentCellX + currentCellWidth / 2 - viewportWidth / 2)
            end
            pendingCenterOnCurrent = false
        end
    end

    AttachHorizontalPan(stripViewport, stripContent, contentWidth)

    -- Persistent bottom section (DESIGN-NOTES §5). Resolved from selectedBossID against
    -- THIS payload, so a selection carried over from a payload that no longer contains
    -- that boss simply hides the section instead of detailing a boss nobody can see.
    -- Created lazily on the first selection and reused for the rest of the session --
    -- deliberately NOT added to renderedFrames, and hidden rather than reparented.
    local selectedBoss
    if selectedBossID then
        for _, boss in ipairs(payload.bosses) do
            if boss.id == selectedBossID then
                selectedBoss = boss
                break
            end
        end
    end

    if selectedBoss then
        bottomSection = bottomSection or CreateBottomSection(root)
        -- Show BEFORE Update (WR-02 review fix): Update -> SetBoss issues SetDisplayInfo,
        -- and the very first one of a session would otherwise be issued into a section
        -- that is still Hide()n. A model load that fails in that state is permanent,
        -- because SetBoss memoizes the display ID and has already hidden the initials
        -- placeholder -- exactly the empty panel D-01 forbids.
        bottomSection:Show()
        bottomSection:Update(payload, selectedBoss, cache[selectedBoss.id], selfKey)
    elseif bottomSection then
        bottomSection:Hide()
    end
end

-- Private:CreateRaidTimelineScreen(contentHost) -- the screen builder (PAGE-01),
-- registered unconditionally via RegisterScreen("timeline", ...) in WildThings.lua's
-- OpenRosterShell (Task 2). Mirrors InviteScreen.lua's shipped-screen scaffolding shape.
function Private:CreateRaidTimelineScreen(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)

    screenRoot = root

    -- Repaint on the way back into the screen (CR-01/CR-02 fix). Two jobs:
    --   1. Freshness. RefreshTimelineScreen now correctly skips repaints while the
    --      screen is not visible, so a kill or an incoming TIMELINE payload that lands
    --      while the raider is on another page would otherwise leave stale frames here.
    --   2. Teardown. RenderInto -> ClearRendered unregisters the GLOBAL_MOUSE_DOWN
    --      listener of any roster panel that was left expanded when the raider
    --      navigated away, which is the only reliable point that can do it -- a
    --      panel-level OnHide cannot (ancestor hides do not propagate OnHide).
    --
    -- HookScript, not SetScript, so this never clobbers a future OnShow handler.
    -- Fires on the Shell.lua:306 `entry.frame:Show()` of a screen switch back here.
    -- It does NOT double-render at build time: CreateFrame returns an already-shown
    -- frame, so Shell's first Show() is a no-op that raises no OnShow -- which is why
    -- the initial RenderInto below is still required.
    root:HookScript("OnShow", function()
        -- Re-arm the auto-centre so returning to the screen puts "where we left off" back
        -- under the raid lead's eye (DESIGN-NOTES §7), exactly as a fresh open does.
        pendingCenterOnCurrent = true
        RenderInto(root)
    end)

    RenderInto(root)

    return root
end

-- Private:RefreshTimelineScreen() -- no-op-safe repaint seam consumed by Timeline.lua's
-- already-shipped ApplyTimelinePayload (guarded call at Timeline.lua:120). Pure no-op if
-- the screen was never built or is not currently visible.
--
-- CR-01 (38-REVIEW.md): this guard used IsShown(), which reports only the frame's OWN
-- shown flag. Shell.lua's close button hides the OUTER window (Shell.lua:182,
-- `window:Hide()`) and never touches screenRoot, so with the Wild Things window closed
-- screenRoot:IsShown() stayed true and every ENCOUNTER_END and every received TIMELINE
-- payload ran a full ClearRendered + ~30 CreateFrame rebuild into a window nobody could
-- see. WoW never reclaims CreateFrame'd frames, so that was unbounded growth.
-- IsVisible() is true only when this frame AND every ancestor is shown, which is what
-- both this comment and every caller actually mean. It covers both hide paths: the
-- screen-switch one (Shell.lua:302 hides screenRoot itself) and the window-close one.
--
-- Skipping repaints while hidden means the screen can go stale while the raider is on
-- another page; the OnShow hook installed in CreateRaidTimelineScreen repaints on the
-- way back in, so returning to the page always shows current data.
function Private:RefreshTimelineScreen()
    if not screenRoot or not screenRoot:IsVisible() then
        return
    end

    RenderInto(screenRoot)
end

-- Private:ResetTimelineView() -- drops the screen's module-local view state.
--
-- WR-12 (38-REVIEW.md): selectedBossID and panOffsetX are deliberately declared outside
-- RenderInto/ClearRendered so they survive a REPAINT -- that is what keeps the bottom
-- section pointed at the same boss across a refresh. Surviving a PAYLOAD REPLACEMENT is a
-- different thing and was unintended: ApplyTimelinePayload already resets tl.defeated and
-- pullCounts when the boss plan genuinely changes, but a boss.id recurring across nights
-- would then auto-open a detail row the raider never clicked, on a strip still scrolled to
-- the previous night's offset. Called from ApplyTimelinePayload's isNewNight branch,
-- guarded exactly like the RefreshTimelineScreen call beside it (this file may not be
-- loaded yet).
--
-- Safe to call while the screen has never been built: both values are plain module locals.
function Private:ResetTimelineView()
    selectedBossID = nil
    panOffsetX = 0
    pendingCenterOnCurrent = true
end

-- Private:AdvanceTimelineSelectionAfterKill() -- move the detail row onto the new current
-- boss whenever one is killed, so the roster on screen is always the one the raid is about
-- to pull rather than one that is already down.
--
-- UNCONDITIONAL, by explicit choice. An earlier revision only advanced when the selection
-- was sitting on the boss that just died, to avoid yanking a lead who had clicked ahead to
-- plan; the call was that following the raid matters more, so a kill re-points the detail
-- row wherever the selection happened to be.
--
-- Must be called AFTER tl.defeated is stamped: GetTimelineCurrentBossID is "first
-- non-defeated", so it only names the NEXT boss once this kill is recorded. Returns nil
-- when the whole plan is cleared, in which case the selection stays where it is -- a
-- finished night still has to show something rather than blanking the detail row.
--
-- Safe before the screen has ever been built: selectedBossID is a plain module local.
--
-- Re-arms the strip's auto-centre too, so the newly selected tile is actually on screen.
-- Without it the selection could jump to a boss scrolled out of view -- the detail row and
-- the strip would disagree about where the raid is. Safe to point at the CURRENT boss
-- rather than the selected one: this function has just made them the same boss.
function Private:AdvanceTimelineSelectionAfterKill()
    selectedBossID = Private:GetTimelineCurrentBossID() or selectedBossID
    pendingCenterOnCurrent = true
end
