local ADDON_NAME, RPAL = ...

-- ============================================================
-- Chronicle UI: deliberately self-contained so UI.lua stays below WoW's
-- per-chunk local-variable ceiling.
-- ============================================================

local C={
    bg={0.022,0.016,0.034,0.98},
    panel={0.050,0.034,0.064,0.98},
    panel2={0.080,0.052,0.075,0.98},
    border={0.30,0.22,0.32,1},button={0.080,0.052,0.075,1},buttonHover={0.14,0.09,0.15,1},
    accent={0.20,0.12,0.04,1},accentHover={0.28,0.18,0.07,1},
    gold={0.93,0.72,0.27,1},
    pale={0.94,0.90,0.82,1},
    muted={0.62,0.59,0.63,1},
    green={0.40,0.88,0.58,1},
    red={0.92,0.38,0.38,1},
}
local FONT=STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF"
local DISPLAY=FONT
local TEX="Interface\\AddOns\\ODYN\\Textures\\"
local SPARK=TEX.."Talent\\sparkle.tga"
local TB=TEX.."TravelBookTheme\\"
local activeTab="Scenes"
local bondSearchText=""

local function CopyInto(dst,src)
    if not dst or not src then return end
    for i=1,4 do dst[i]=src[i] or dst[i] or 1 end
end

local function ApplyPalette()
    if not RPAL.GetCurrentThemePalette then return end
    local p=RPAL.GetCurrentThemePalette()
    if not p then return end
    CopyInto(C.bg,p.main);CopyInto(C.panel,p.panel);CopyInto(C.panel2,p.panel2)
    CopyInto(C.border,p.border);CopyInto(C.button,p.button);CopyInto(C.buttonHover,p.buttonHover)
    CopyInto(C.accent,p.accent);CopyInto(C.accentHover,p.accentHover)
    CopyInto(C.gold,p.gold);CopyInto(C.pale,p.pale);CopyInto(C.muted,p.muted)
end

local function Scaled(n)
    return RPAL.GetScaledFontSize and RPAL.GetScaledFontSize(n) or n
end

local function Text(parent,value,size,color,font)
    local f=parent:CreateFontString(nil,"OVERLAY")
    local fontPath=font or FONT
    local base=size or 12
    f:SetFont(fontPath,Scaled(base),"")
    if RPAL.RegisterScalableFontObject then RPAL.RegisterScalableFontObject(f,fontPath,base,"") end
    f:SetText(tostring(value or ""))
    color=color or C.pale
    f:SetTextColor(color[1],color[2],color[3],color[4] or 1)
    f:SetShadowColor(0,0,0,1);f:SetShadowOffset(1,-1)
    return f
end

local function Backdrop(frame,bg,border)
    frame:SetBackdrop({bgFile="Interface\\Tooltips\\UI-Tooltip-Background",edgeFile="Interface\\Tooltips\\UI-Tooltip-Border",tile=false,tileSize=16,edgeSize=16,insets={left=4,right=4,top=4,bottom=4}})
    bg=bg or C.panel;border=border or C.gold
    frame:SetBackdropColor(bg[1],bg[2],bg[3],bg[4] or 1)
    frame:SetBackdropBorderColor(border[1],border[2],border[3],border[4] or 1)
end

local function Button(parent,label,w,h,accent)
    local b=CreateFrame("Button",nil,parent,"BackdropTemplate")
    b:SetSize(w or 100,h or 30)
    Backdrop(b,accent and C.accent or C.button,accent and C.gold or C.border)
    local paper=b:CreateTexture(nil,"BACKGROUND",nil,-1);paper:SetTexture(TB.."UI_TravelBook_Frame01a.tga");paper:SetPoint("TOPLEFT",2,-2);paper:SetPoint("BOTTOMRIGHT",-2,2);paper:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],0.18);paper:SetAlpha(accent and 0.24 or 0.12)
    b.label=Text(b,label,10,accent and C.gold or C.pale)
    b.label:SetPoint("CENTER")
    b:SetScript("OnEnter",function(self)
        local c=accent and C.accentHover or C.buttonHover;self:SetBackdropColor(c[1],c[2],c[3],c[4] or 1)
        self:SetBackdropBorderColor(C.gold[1],C.gold[2],C.gold[3],0.88)
    end)
    b:SetScript("OnLeave",function(self)
        Backdrop(self,accent and C.accent or C.button,accent and C.gold or C.border)
    end)
    if RPAL.AttachButtonMotion then RPAL.AttachButtonMotion(b,0.018) end
    if RPAL.AttachFocusMark then RPAL.AttachFocusMark(b,C.gold) end
    return b
end

local function PromptCheck(parent,label)
    local b=CreateFrame("Button",nil,parent,"BackdropTemplate")
    b:SetHeight(24)
    b:SetBackdrop({bgFile="Interface\\Tooltips\\UI-Tooltip-Background",edgeFile="Interface\\Tooltips\\UI-Tooltip-Border",tile=false,tileSize=16,edgeSize=12,insets={left=3,right=3,top=3,bottom=3}})
    b:SetBackdropColor(C.button[1],C.button[2],C.button[3],0.62)
    b:SetBackdropBorderColor(C.border[1],C.border[2],C.border[3],0.82)
    b.box=CreateFrame("Frame",nil,b,"BackdropTemplate")
    b.box:SetSize(16,16);b.box:SetPoint("LEFT",6,0)
    b.box:SetBackdrop({bgFile="Interface\\Tooltips\\UI-Tooltip-Background",edgeFile="Interface\\Tooltips\\UI-Tooltip-Border",tile=false,tileSize=16,edgeSize=10,insets={left=2,right=2,top=2,bottom=2}})
    b.box:SetBackdropColor(0.02,0.02,0.02,0.95);b.box:SetBackdropBorderColor(C.border[1],C.border[2],C.border[3],1)
    b.tick=b.box:CreateTexture(nil,"OVERLAY");b.tick:SetTexture("Interface\\Buttons\\UI-CheckBox-Check");b.tick:SetSize(20,20);b.tick:SetPoint("CENTER");b.tick:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],1)
    b.text=Text(b,label,8.5,C.muted);b.text:SetPoint("LEFT",b.box,"RIGHT",7,0);b.text:SetJustifyH("LEFT")
    function b:SetChecked(v)
        self.checked=not not v;self.tick:SetShown(self.checked)
        if self.checked then self.box:SetBackdropBorderColor(C.gold[1],C.gold[2],C.gold[3],1)
        else self.box:SetBackdropBorderColor(C.border[1],C.border[2],C.border[3],1) end
    end
    function b:GetChecked() return self.checked end
    b:SetScript("OnClick",function(self)self:SetChecked(not self:GetChecked())end)
    b:SetScript("OnEnter",function(self)self:SetBackdropBorderColor(C.gold[1],C.gold[2],C.gold[3],0.82)end)
    b:SetScript("OnLeave",function(self)self:SetBackdropBorderColor(C.border[1],C.border[2],C.border[3],0.82)end)
    b:SetChecked(false)
    return b
end

local function ClearChildren(frame)
    if not frame or not frame.GetChildren then return end
    local children={frame:GetChildren()}
    for _,child in ipairs(children) do child:Hide();child:SetParent(nil) end
    if frame.GetRegions then
        local regions={frame:GetRegions()}
        for _,r in ipairs(regions) do if r.Hide then r:Hide() end end
    end
end

local function FormatDuration(sec)
    sec=math.max(0,math.floor(tonumber(sec) or 0))
    if sec<60 then return tostring(sec).." sec" end
    local m=math.floor(sec/60)
    if m<60 then return tostring(m).." min" end
    local h=math.floor(m/60);m=m%60
    return tostring(h).."h "..tostring(m).."m"
end

local function DateText(ts)
    if not ts then return "" end
    return date("%d %b %Y  %H:%M",ts)
end

local function CountMap(t)
    local n=0
    for _ in pairs(t or {}) do n=n+1 end
    return n
end

local function SumMap(t)
    local n=0
    for _,v in pairs(t or {}) do n=n+(tonumber(v) or 0) end
    return n
end

local function Modal(title,w,h)
    if RPAL.CreateModal then
        local shade,f=RPAL.CreateModal(title,w or 620,h or 500)
        shade._rpalEscapeFailsafe=true
        return shade,f
    end
    RPAL._sceneModalSerial=(tonumber(RPAL._sceneModalSerial) or 0)+1
    local shade=CreateFrame("Frame","RPAL_SceneModal_"..tostring(RPAL._sceneModalSerial),UIParent,"BackdropTemplate")
    shade:SetAllPoints();shade:SetFrameStrata("FULLSCREEN_DIALOG");shade:SetFrameLevel(9400);shade:EnableMouse(true)
    shade:SetBackdrop({bgFile="Interface\\Buttons\\WHITE8X8"});shade:SetBackdropColor(0,0,0,0.65)
    local f=CreateFrame("Frame",nil,shade,"BackdropTemplate")
    f:SetSize(w or 620,h or 500);f:SetPoint("CENTER");Backdrop(f,C.bg,C.gold)
    local paper=f:CreateTexture(nil,"BACKGROUND",nil,-2);paper:SetTexture(TB.."UI_TravelBook_Popup01a.tga");paper:SetPoint("TOPLEFT",4,-4);paper:SetPoint("BOTTOMRIGHT",-4,4);paper:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],1);paper:SetAlpha(0.15)
    local glow=f:CreateTexture(nil,"BACKGROUND",nil,-1);glow:SetTexture(SPARK);glow:SetBlendMode("ADD");glow:SetSize((w or 620)*0.72,(h or 500)*0.46);glow:SetPoint("TOP",0,20);glow:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],0.35);glow:SetAlpha(0.15)
    local t=Text(f,title,20,C.gold,DISPLAY);t:SetPoint("TOPLEFT",20,-18)
    local close=Button(f,"×",34,28,false);close:SetPoint("TOPRIGHT",-12,-12);close.label:SetFont(DISPLAY,Scaled(18),"");close:SetScript("OnClick",function()shade:Hide()end)
    if RPAL.RegisterEscapeFrame then RPAL.RegisterEscapeFrame(shade) end
    shade._rpalEscapeFailsafe=true
    shade:SetAlpha(0);local age=0
    shade:SetScript("OnUpdate",function(self,elapsed)age=age+(elapsed or 0);local k=math.min(1,age/0.18);self:SetAlpha(1-((1-k)*(1-k)));if k>=1 then self:SetScript("OnUpdate",nil) end end)
    return shade,f
end

-- ------------------------------------------------------------
-- Compact TRP status feedback
-- ------------------------------------------------------------
local sessionStatusToast
local oocSessionPrompt
local manualSessionPrompt
local icSessionChoicePrompt
local sessionActivityPrompt
local sessionStartPrompt

function RPAL.ShowSessionStatusToast(message,status)
    ApplyPalette()
    local f=sessionStatusToast
    if not f then
        f=CreateFrame("Frame","RPAL_SessionStatusToast",UIParent,"BackdropTemplate")
        sessionStatusToast=f
        f:SetSize(452,50);f:SetPoint("TOP",UIParent,"TOP",0,-96)
        f:SetFrameStrata("FULLSCREEN_DIALOG");f:SetFrameLevel(9340);f:EnableMouse(false);f:Hide()
        f.glow=f:CreateTexture(nil,"BACKGROUND",nil,-1);f.glow:SetTexture(SPARK);f.glow:SetBlendMode("ADD");f.glow:SetSize(330,72);f.glow:SetPoint("CENTER",12,-1)
        f.seal=f:CreateTexture(nil,"ARTWORK");f.seal:SetTexture(SPARK);f.seal:SetBlendMode("ADD");f.seal:SetSize(34,34);f.seal:SetPoint("LEFT",10,0)
        f.rule=f:CreateTexture(nil,"ARTWORK");f.rule:SetTexture("Interface\\Buttons\\WHITE8X8");f.rule:SetSize(1,28);f.rule:SetPoint("LEFT",48,0);f.rule:SetBlendMode("ADD")
        f.body=Text(f,"",11,C.pale,DISPLAY);f.body:SetPoint("LEFT",60,0);f.body:SetWidth(372);f.body:SetJustifyH("LEFT");f.body:SetWordWrap(false)
    end

    local accent=(status=="OOC") and C.red or ((status=="PAUSED") and C.gold or C.green)
    Backdrop(f,C.panel,accent)
    f.glow:SetVertexColor(accent[1],accent[2],accent[3],0.48)
    f.seal:SetVertexColor(accent[1],accent[2],accent[3],0.90)
    f.rule:SetVertexColor(accent[1],accent[2],accent[3],0.54)
    f.body:SetTextColor(C.pale[1],C.pale[2],C.pale[3],1)
    f.body:SetText(tostring(message or ""))

    -- Short status notes retain the compact 452px silhouette.  Longer TRP,
    -- import and Session messages grow to their natural width; only messages
    -- that would approach the screen edge wrap onto a second line.
    local naturalWidth=0
    if f.body.GetUnboundedStringWidth then
        local ok,value=pcall(f.body.GetUnboundedStringWidth,f.body)
        if ok then naturalWidth=tonumber(value) or 0 end
    end
    if naturalWidth<=0 and f.body.GetStringWidth then naturalWidth=tonumber(f.body:GetStringWidth()) or 0 end
    local screenWidth=(UIParent and UIParent.GetWidth and tonumber(UIParent:GetWidth())) or 1280
    local maxFrameWidth=math.max(452,math.min(720,screenWidth-48))
    local desiredWidth=math.min(maxFrameWidth,math.max(452,math.ceil(naturalWidth)+88))
    local wraps=(naturalWidth+88)>maxFrameWidth
    f:SetWidth(desiredWidth)
    f.body:SetWidth(desiredWidth-80)
    f.body:SetWordWrap(wraps)
    if f.body.SetMaxLines then f.body:SetMaxLines(wraps and 2 or 1) end
    f:SetHeight(wraps and 64 or 50)
    f._rpalMessage=tostring(message or "")
    f._rpalStatus=status
    if RPAL.ApplyToastLayout then RPAL.ApplyToastLayout(f) end
    local baseScale=(RPAL.GetToastLayoutScale and RPAL.GetToastLayoutScale()) or 1
    f._age=0;f:SetAlpha(0);f:SetScale(baseScale*0.97);f:Show()
    f:SetScript("OnUpdate",function(self,elapsed)
        self._age=(self._age or 0)+(elapsed or 0)
        local t=self._age
        local enter=math.min(1,t/0.20)
        local alpha=1-((1-enter)*(1-enter))
        if t>3.25 then alpha=math.max(0,1-((t-3.25)/0.42)) end
        self:SetAlpha(alpha);self:SetScale(baseScale*(0.97+(enter*0.03)))
        self.glow:SetAlpha((0.10+((math.sin(t*2.4)+1)*0.035))*alpha)
        if t>=3.67 then self:Hide();self:SetScript("OnUpdate",nil) end
    end)
    return f
end

function RPAL.CloseOOCSessionPrompt()
    if oocSessionPrompt then oocSessionPrompt:Hide() end
end

function RPAL.CloseICSessionChoicePrompt()
    if icSessionChoicePrompt then icSessionChoicePrompt:Hide() end
end

function RPAL.CloseSessionActivityPrompt()
    if sessionActivityPrompt then sessionActivityPrompt:Hide() end
end

function RPAL.CloseSessionStartPrompt()
    if sessionStartPrompt then sessionStartPrompt:Hide() end
end

-- Movement reminders are intentionally a side card, not a modal. Only the
-- card itself receives mouse input; the world, camera and every other panel
-- remain available while the player decides.
function RPAL.ShowSessionActivityPrompt(reason,message)
    ApplyPalette()
    local f=sessionActivityPrompt
    if not f then
        f=CreateFrame("Frame","RPAL_SessionActivityPrompt",UIParent,"BackdropTemplate")
        sessionActivityPrompt=f
        f:SetSize(404,202);f:SetPoint("RIGHT",UIParent,"RIGHT",-24,36)
        f:SetFrameStrata("DIALOG");f:SetFrameLevel(9360);f:SetClampedToScreen(true);f:EnableMouse(true);f:Hide()
        Backdrop(f,C.panel,C.gold)

        f.glow=f:CreateTexture(nil,"BACKGROUND",nil,-1);f.glow:SetTexture(SPARK);f.glow:SetBlendMode("ADD");f.glow:SetPoint("TOPLEFT",-20,20);f.glow:SetPoint("BOTTOMRIGHT",20,-20);f.glow:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],.16)
        f.eyebrow=Text(f,"SESSION CHECK-IN",8,C.gold,DISPLAY);f.eyebrow:SetPoint("TOPLEFT",17,-15)
        f.body=Text(f,"",12,C.pale,DISPLAY);f.body:SetPoint("TOPLEFT",17,-37);f.body:SetWidth(368);f.body:SetWordWrap(true);f.body:SetJustifyH("LEFT")
        f.reason=Text(f,"",8,C.muted);f.reason:SetPoint("TOPLEFT",17,-88);f.reason:SetWidth(368);f.reason:SetWordWrap(false)
        f.suppress=PromptCheck(f,"Don't show until the next session");f.suppress:SetPoint("TOPLEFT",17,-111);f.suppress:SetPoint("TOPRIGHT",-17,-111)

        f.continue=Button(f,"Continue RP",130,32,true);f.continue:SetPoint("BOTTOMLEFT",17,14)
        f.finish=Button(f,"End Session",130,32,false);f.finish:SetPoint("BOTTOMRIGHT",-17,14);f.finish.label:SetTextColor(C.red[1],C.red[2],C.red[3],1)
        f.continue:SetScript("OnClick",function()
            local suppress=f.suppress and f.suppress:GetChecked() or false
            if RPAL.AcknowledgeSessionActivityPrompt then RPAL.AcknowledgeSessionActivityPrompt(600,suppress) end
            f:Hide()
        end)
        f.finish:SetScript("OnClick",function()
            f:Hide()
            local ok,result=RPAL.EndScene and RPAL.EndScene()
            if not ok then print("|cffff7777ODYN:|r "..tostring(result or "Could not end Session.")) end
        end)
    end

    Backdrop(f,C.panel,C.gold)
    f.glow:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],.16)
    f.eyebrow:SetTextColor(C.gold[1],C.gold[2],C.gold[3],1)
    f.body:SetTextColor(C.pale[1],C.pale[2],C.pale[3],1)
    f.reason:SetTextColor(C.muted[1],C.muted[2],C.muted[3],1)
    f.body:SetText(tostring(message or "Are you still RPing? If not, better stop the session!"))
    f.reason:SetText(tostring(reason or "Out-of-character activity detected."))
    if f.suppress then f.suppress:SetChecked(false) end
    f:SetAlpha(0);f._age=0;f:Show();f:Raise()
    f:SetScript("OnUpdate",function(self,elapsed)
        self._age=(self._age or 0)+(elapsed or 0)
        local k=math.min(1,self._age/.18);self:SetAlpha(1-((1-k)*(1-k)))
        if k>=1 then self:SetScript("OnUpdate",nil) end
    end)
    return f
end

function RPAL.ShowSessionStartPrompt(otherName,reason,message)
    ApplyPalette()
    if RPAL.IsSceneActive and RPAL.IsSceneActive() then return nil end
    local f=sessionStartPrompt
    if not f then
        f=CreateFrame("Frame","RPAL_SessionStartPrompt",UIParent,"BackdropTemplate")
        sessionStartPrompt=f
        f:SetSize(404,202);f:SetPoint("RIGHT",UIParent,"RIGHT",-24,36)
        f:SetFrameStrata("DIALOG");f:SetFrameLevel(9361);f:SetClampedToScreen(true);f:EnableMouse(true);f:Hide()
        Backdrop(f,C.panel,C.gold)

        f.glow=f:CreateTexture(nil,"BACKGROUND",nil,-1);f.glow:SetTexture(SPARK);f.glow:SetBlendMode("ADD");f.glow:SetPoint("TOPLEFT",-20,20);f.glow:SetPoint("BOTTOMRIGHT",20,-20);f.glow:SetVertexColor(C.green[1],C.green[2],C.green[3],.13)
        f.eyebrow=Text(f,"SESSION CHECK-IN",8,C.green,DISPLAY);f.eyebrow:SetPoint("TOPLEFT",17,-15)
        f.body=Text(f,"",12,C.pale,DISPLAY);f.body:SetPoint("TOPLEFT",17,-37);f.body:SetWidth(368);f.body:SetWordWrap(true);f.body:SetJustifyH("LEFT")
        f.reason=Text(f,"",8,C.muted);f.reason:SetPoint("TOPLEFT",17,-88);f.reason:SetWidth(368);f.reason:SetWordWrap(false)
        f.suppress=PromptCheck(f,"Don't show until the next session");f.suppress:SetPoint("TOPLEFT",17,-111);f.suppress:SetPoint("TOPRIGHT",-17,-111)

        f.start=Button(f,"Start Session",130,32,true);f.start:SetPoint("BOTTOMLEFT",17,14)
        f.notNow=Button(f,"Not Now",130,32,false);f.notNow:SetPoint("BOTTOMRIGHT",-17,14)
        f.start:SetScript("OnClick",function()
            f:Hide()
            local ok,result=RPAL.StartScene and RPAL.StartScene()
            if not ok then print("|cffff7777ODYN:|r "..tostring(result or "Could not start Session.")) end
        end)
        f.notNow:SetScript("OnClick",function()
            local suppress=f.suppress and f.suppress:GetChecked() or false
            if RPAL.AcknowledgeSessionStartPrompt then RPAL.AcknowledgeSessionStartPrompt(suppress) end
            f:Hide()
        end)
    end

    Backdrop(f,C.panel,C.gold)
    f.glow:SetVertexColor(C.green[1],C.green[2],C.green[3],.13)
    f.eyebrow:SetTextColor(C.green[1],C.green[2],C.green[3],1)
    f.body:SetTextColor(C.pale[1],C.pale[2],C.pale[3],1)
    f.reason:SetTextColor(C.muted[1],C.muted[2],C.muted[3],1)
    local who=tostring(otherName or ""):gsub("^%s+",""):gsub("%s+$","")
    local body=tostring(message or "You seem to be roleplaying with someone. Start a Session?")
    if who~="" then body="You seem to be roleplaying with "..who..". Start a Session?" end
    f.body:SetText(body)
    f.reason:SetText(tostring(reason or "Direct IC interaction detected."))
    if f.suppress then f.suppress:SetChecked(false) end
    if sessionActivityPrompt and sessionActivityPrompt:IsShown() then sessionActivityPrompt:Hide() end
    f:SetAlpha(0);f._age=0;f:Show();f:Raise()
    f:SetScript("OnUpdate",function(self,elapsed)
        self._age=(self._age or 0)+(elapsed or 0)
        local k=math.min(1,self._age/.18);self:SetAlpha(1-((1-k)*(1-k)))
        if k>=1 then self:SetScript("OnUpdate",nil) end
    end)
    return f
end

function RPAL.PromptICSessionChoice(scene,message)
    ApplyPalette()
    if not scene then return nil end
    if icSessionChoicePrompt and icSessionChoicePrompt:IsShown() then
        if tostring(icSessionChoicePrompt._rpalPendingSceneID or "")==tostring(scene.id or "") then return icSessionChoicePrompt end
        icSessionChoicePrompt:Hide()
    end
    if oocSessionPrompt and oocSessionPrompt:IsShown() then oocSessionPrompt:Hide() end

    local question=tostring(message or "Would you like to start a new session, or continue from the last paused session?")
    local shade,f=Modal("BACK IN CHARACTER",660,320)
    icSessionChoicePrompt=shade
    shade._rpalSessionDecisionMode="ic-return"
    shade._rpalPendingSceneID=scene.id
    shade._rpalMessage=question

    local eyebrow=Text(f,"YOUR PAUSED SESSION IS STILL SAFE",8,C.green)
    eyebrow:SetPoint("TOPLEFT",22,-60)
    local body=Text(f,question,13,C.pale,DISPLAY)
    body:SetPoint("TOPLEFT",22,-82);body:SetWidth(616);body:SetWordWrap(true)

    local title=(scene.title and scene.title~="") and scene.title or ("Session in "..tostring(scene.zone or "Unknown Place"))
    local paused=Text(f,title.."  •  paused "..DateText(scene.pausedAt or scene.lastPausedAt),8.5,C.muted)
    paused:SetPoint("TOPLEFT",22,-128);paused:SetWidth(616);paused:SetWordWrap(false)

    local startCard=CreateFrame("Frame",nil,f,"BackdropTemplate")
    startCard:SetPoint("TOPLEFT",22,-157);startCard:SetSize(299,104);Backdrop(startCard,C.panel,C.border)
    local startTitle=Text(startCard,"START A NEW SESSION",11,C.pale,DISPLAY);startTitle:SetPoint("TOPLEFT",14,-13)
    local startHint=Text(startCard,"Keep the paused record stored and begin a fresh RP Session now.",8,C.muted);startHint:SetPoint("TOPLEFT",14,-36);startHint:SetWidth(270);startHint:SetWordWrap(true)
    local start=Button(startCard,"Start New",112,30,false);start:SetPoint("BOTTOMLEFT",14,10)

    local continueCard=CreateFrame("Frame",nil,f,"BackdropTemplate")
    continueCard:SetPoint("TOPRIGHT",-22,-157);continueCard:SetSize(299,104);Backdrop(continueCard,C.panel,C.gold)
    local continueTitle=Text(continueCard,"CONTINUE PAUSED SESSION",11,C.gold,DISPLAY);continueTitle:SetPoint("TOPLEFT",14,-13)
    local continueHint=Text(continueCard,"Resume its same route, people, interactions, Renown and active timer.",8,C.muted);continueHint:SetPoint("TOPLEFT",14,-36);continueHint:SetWidth(270);continueHint:SetWordWrap(true)
    local continue=Button(continueCard,"Continue",112,30,true);continue:SetPoint("BOTTOMLEFT",14,10)

    start:SetScript("OnClick",function()
        local ok,result=RPAL.ResolveICSessionChoice and RPAL.ResolveICSessionChoice(scene.id,"new")
        if not ok then print("|cffff7777ODYN:|r "..tostring(result or "Could not start a new Session."));return end
        shade:Hide()
        if RPAL.ShowSessionStatusToast then RPAL.ShowSessionStatusToast((RPAL.SESSION_STATUS_MESSAGES and RPAL.SESSION_STATUS_MESSAGES.started) or "Session record started, enjoy the RP! <3","IC") end
    end)
    continue:SetScript("OnClick",function()
        local ok,result=RPAL.ResolveICSessionChoice and RPAL.ResolveICSessionChoice(scene.id,"continue")
        if not ok then print("|cffff7777ODYN:|r "..tostring(result or "Could not resume the paused Session."));return end
        shade:Hide()
    end)

    shade._rpalNewButton=start
    shade._rpalContinueButton=continue
    shade:HookScript("OnHide",function(self)if icSessionChoicePrompt==self then icSessionChoicePrompt=nil end end)
    return shade
end

local function PauseActiveSession(shade,reason)
    if shade then shade:Hide() end
    local ok,result=RPAL.PauseAndStoreScene and RPAL.PauseAndStoreScene(reason)
    if not ok then print("|cffff7777ODYN:|r "..tostring(result or "Could not store Session.")) end
    return ok,result
end

local function EndActiveSession(shade)
    if shade then shade:Hide() end
    local ok,result=RPAL.EndScene and RPAL.EndScene()
    if not ok then print("|cffff7777ODYN:|r "..tostring(result or "Could not end Session.")) end
    return ok,result
end

-- Manual End Session is a deliberate two-path menu: pausing preserves the
-- live record for later, while ending finalizes it and opens the normal recap.
function RPAL.RequestEndScene()
    ApplyPalette()
    if not (RPAL.IsSceneActive and RPAL.IsSceneActive()) then return false,"No Session is in progress." end
    if manualSessionPrompt and manualSessionPrompt:IsShown() then return true,manualSessionPrompt end
    if oocSessionPrompt and oocSessionPrompt:IsShown() then oocSessionPrompt:Hide() end
    if sessionActivityPrompt and sessionActivityPrompt:IsShown() then sessionActivityPrompt:Hide() end

    local shade,f=Modal("SESSION OPTIONS",650,330)
    manualSessionPrompt=shade
    shade._rpalSessionDecisionMode="manual"

    local eyebrow=Text(f,"HOW WOULD YOU LIKE TO LEAVE THIS SESSION?",8,C.gold)
    eyebrow:SetPoint("TOPLEFT",22,-59)
    local body=Text(f,"Pause it intact for another day, or finish it now and create the permanent Session record.",10,C.muted)
    body:SetPoint("TOPLEFT",22,-80);body:SetWidth(606);body:SetWordWrap(true)

    local pauseCard=CreateFrame("Frame",nil,f,"BackdropTemplate")
    pauseCard:SetPoint("TOPLEFT",22,-112);pauseCard:SetSize(294,148);Backdrop(pauseCard,C.panel,C.gold)
    local pauseGlow=pauseCard:CreateTexture(nil,"BACKGROUND",nil,-1);pauseGlow:SetTexture(SPARK);pauseGlow:SetBlendMode("ADD");pauseGlow:SetSize(170,100);pauseGlow:SetPoint("CENTER");pauseGlow:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],0.14)
    local pauseTitle=Text(pauseCard,"PAUSE & STORE",14,C.gold,DISPLAY);pauseTitle:SetPoint("TOPLEFT",16,-16)
    local pauseText=Text(pauseCard,"Keep the route, people, interactions, Renown and active time exactly as they are. Resume from Paused Sessions whenever you want.",9,C.muted)
    pauseText:SetPoint("TOPLEFT",16,-44);pauseText:SetWidth(262);pauseText:SetWordWrap(true)
    local pause=Button(pauseCard,"Pause & Store",132,34,true);pause:SetPoint("BOTTOMLEFT",16,14)
    pause:SetScript("OnClick",function()PauseActiveSession(shade)end)

    local endCard=CreateFrame("Frame",nil,f,"BackdropTemplate")
    endCard:SetPoint("TOPRIGHT",-22,-112);endCard:SetSize(294,148);Backdrop(endCard,C.panel,C.border)
    local endTitle=Text(endCard,"END & RECORD",14,C.red,DISPLAY);endTitle:SetPoint("TOPLEFT",16,-16)
    local endText=Text(endCard,"Finalize this Session, add it to Session History and open the recap where you can title it or keep it as a Saved Session.",9,C.muted)
    endText:SetPoint("TOPLEFT",16,-44);endText:SetWidth(262);endText:SetWordWrap(true)
    local finish=Button(endCard,"End & Record",132,34,false);finish:SetPoint("BOTTOMLEFT",16,14);finish.label:SetTextColor(C.red[1],C.red[2],C.red[3],1)
    finish:SetScript("OnClick",function()EndActiveSession(shade)end)

    local cancel=Button(f,"Back to Session",126,30,false);cancel:SetPoint("BOTTOMLEFT",22,18);cancel:SetScript("OnClick",function()shade:Hide()end)
    shade._rpalPauseButton=pause;shade._rpalEndButton=finish;shade._rpalCancelButton=cancel
    shade:HookScript("OnHide",function(self)if manualSessionPrompt==self then manualSessionPrompt=nil end end)
    return true,shade
end

function RPAL.PromptStopSessionForOOC(message)
    ApplyPalette()
    if oocSessionPrompt and oocSessionPrompt:IsShown() then return oocSessionPrompt end
    if manualSessionPrompt and manualSessionPrompt:IsShown() then manualSessionPrompt:Hide() end
    if icSessionChoicePrompt and icSessionChoicePrompt:IsShown() then icSessionChoicePrompt:Hide() end

    local question=tostring(message or (RPAL.SESSION_STATUS_MESSAGES and RPAL.SESSION_STATUS_MESSAGES.askStop) or "Would you like to stop recording session since you gone out of character(OOC)?")
    local shade,f=Modal("OUT OF CHARACTER",620,285)
    oocSessionPrompt=shade
    shade._rpalOOCMessage=question
    shade._rpalSessionDecisionMode="ooc"
    f._rpalOOCMessage=question

    local eyebrow=Text(f,"SESSION RECORDING IS STILL ACTIVE",8,C.red)
    eyebrow:SetPoint("TOPLEFT",22,-61)
    local body=Text(f,question,13,C.pale,DISPLAY)
    body:SetPoint("TOPLEFT",22,-84);body:SetWidth(576);body:SetWordWrap(true);body:SetJustifyH("LEFT")
    local hint=Text(f,"Pause & Store keeps everything intact for later. End Session finalizes it now; Keep Recording changes nothing.",8.5,C.muted)
    hint:SetPoint("TOPLEFT",22,-135);hint:SetWidth(576);hint:SetWordWrap(true)

    local keep=Button(f,"Keep Recording",132,34,false)
    keep:SetPoint("BOTTOMLEFT",22,20)
    keep:SetScript("OnClick",function()shade:Hide()end)

    local pause=Button(f,"Pause & Store",132,34,true)
    pause:SetPoint("BOTTOM",0,20)
    pause:SetScript("OnClick",function()PauseActiveSession(shade,"ooc")end)

    local stop=Button(f,"End Session",126,34,true)
    stop:SetPoint("BOTTOMRIGHT",-22,20)
    stop.label:SetTextColor(C.red[1],C.red[2],C.red[3],1)
    stop:SetScript("OnClick",function()EndActiveSession(shade)end)

    shade._rpalKeepButton=keep;shade._rpalPauseButton=pause;shade._rpalEndButton=stop

    shade:HookScript("OnHide",function(self)
        if oocSessionPrompt==self then oocSessionPrompt=nil end
    end)
    return shade
end

-- ------------------------------------------------------------
-- Scene Spark thought overlay
-- ------------------------------------------------------------

local sparkFrame
function RPAL.ShowSceneSpark(message,thoughtRow)
    if RPAL.DMKit and RPAL.DMKit.ShouldSuppressProgressionUI and RPAL.DMKit.ShouldSuppressProgressionUI() then return end
    if not sparkFrame then
        -- Inner thoughts are meant to feel like something surfacing from the
        -- back of the mind: no hard toast frame, no title card, and no heavy
        -- emblem. A few soft rings remain, while firefly-like motes gather
        -- first, then fade out as the thought itself settles in.
        local f=CreateFrame("Frame","RPAL_SceneSpark",UIParent)
        sparkFrame=f
        f:SetSize(820,118);f:SetPoint("TOP",UIParent,"TOP",0,-170);f:SetFrameStrata("FULLSCREEN_DIALOG");f:SetFrameLevel(9300);f:EnableMouse(false);f:Hide()

        f.mistA=f:CreateTexture(nil,"BACKGROUND",nil,-3)
        f.mistA:SetTexture(TEX.."Atmospheres\\atmosphere_moonmist.tga")
        f.mistA:SetSize(620,190);f.mistA:SetPoint("CENTER",-130,0)
        f.mistA:SetBlendMode("ADD");f.mistA:SetVertexColor(C.pale[1],C.pale[2],C.pale[3],.28)

        f.mistB=f:CreateTexture(nil,"BACKGROUND",nil,-3)
        f.mistB:SetTexture(TEX.."Atmospheres\\atmosphere_moonmist.tga")
        f.mistB:SetTexCoord(1,0,0,1)
        f.mistB:SetSize(620,190);f.mistB:SetPoint("CENTER",130,-4)
        f.mistB:SetBlendMode("ADD");f.mistB:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],.24)

        -- Soft rings are enough to imply the thought forming; the old central
        -- sigil/nebula is deliberately removed to avoid a crown-like emblem.
        f.ringA=f:CreateTexture(nil,"BACKGROUND",nil,-2)
        f.ringA:SetTexture(TEX.."Celestial\\starlight_glow.tga")
        f.ringA:SetBlendMode("ADD");f.ringA:SetSize(360,116);f.ringA:SetPoint("CENTER",0,-1)
        f.ringA:SetVertexColor(C.pale[1],C.pale[2],C.pale[3],.10)

        f.ringB=f:CreateTexture(nil,"BACKGROUND",nil,-2)
        f.ringB:SetTexture(TEX.."Celestial\\starlight_glow.tga")
        f.ringB:SetBlendMode("ADD");f.ringB:SetSize(500,154);f.ringB:SetPoint("CENTER",0,-1)
        f.ringB:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],.07)

        f.grain=f:CreateTexture(nil,"BACKGROUND",nil,-2)
        f.grain:SetTexture(TEX.."Talent\\atlas_stars.tga")
        f.grain:SetBlendMode("ADD");f.grain:SetSize(710,154);f.grain:SetPoint("CENTER",0,-2)
        f.grain:SetVertexColor(C.pale[1],C.pale[2],C.pale[3],.08)

        f.traceL=f:CreateTexture(nil,"ARTWORK",nil,-1)
        f.traceL:SetTexture(TEX.."Celestial\\starlight_comet_1.tga")
        f.traceL:SetBlendMode("ADD");f.traceL:SetSize(78,30);f.traceL:SetPoint("CENTER",-245,-7)
        f.traceL:SetVertexColor(C.pale[1],C.pale[2],C.pale[3],.12)
        f.traceR=f:CreateTexture(nil,"ARTWORK",nil,-1)
        f.traceR:SetTexture(TEX.."Celestial\\starlight_comet_2.tga")
        f.traceR:SetBlendMode("ADD");f.traceR:SetSize(64,24);f.traceR:SetPoint("CENTER",250,8)
        f.traceR:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],.09)

        f.echoA=Text(f,"",18,C.pale,DISPLAY);f.echoA:SetPoint("CENTER",-2,1);f.echoA:SetWidth(720);f.echoA:SetJustifyH("CENTER");f.echoA:SetWordWrap(true);f.echoA:SetAlpha(.08)
        f.echoB=Text(f,"",18,C.pale,DISPLAY);f.echoB:SetPoint("CENTER",2,-1);f.echoB:SetWidth(720);f.echoB:SetJustifyH("CENTER");f.echoB:SetWordWrap(true);f.echoB:SetAlpha(.05)
        f.echoC=Text(f,"",18,C.pale,DISPLAY);f.echoC:SetPoint("CENTER",0,3);f.echoC:SetWidth(720);f.echoC:SetJustifyH("CENTER");f.echoC:SetWordWrap(true);f.echoC:SetAlpha(.025)
        f.body=Text(f,"",18,C.pale,DISPLAY);f.body:SetPoint("CENTER",0,0);f.body:SetWidth(720);f.body:SetJustifyH("CENTER");f.body:SetJustifyV("MIDDLE");f.body:SetWordWrap(true)
        f.body:SetShadowColor(0,0,0,.92);f.body:SetShadowOffset(1,-2)

        f.quoteL=Text(f,"“",46,C.gold,DISPLAY);f.quoteL:SetPoint("LEFT",34,7);f.quoteL:SetAlpha(.10)
        f.quoteR=Text(f,"”",46,C.gold,DISPLAY);f.quoteR:SetPoint("RIGHT",-34,-9);f.quoteR:SetAlpha(.09)

        f.sparkL=f:CreateTexture(nil,"ARTWORK");f.sparkL:SetTexture(SPARK);f.sparkL:SetBlendMode("ADD");f.sparkL:SetSize(42,42);f.sparkL:SetPoint("LEFT",74,0);f.sparkL:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],.25)
        f.sparkR=f:CreateTexture(nil,"ARTWORK");f.sparkR:SetTexture(SPARK);f.sparkR:SetBlendMode("ADD");f.sparkR:SetSize(32,32);f.sparkR:SetPoint("RIGHT",-92,-4);f.sparkR:SetVertexColor(C.pale[1],C.pale[2],C.pale[3],.18)

        -- Invisible hit region: the thought stays visually frameless, but the
        -- player can hover the words to ask the most important Persona Beta
        -- question: why did this thought surface now?
        f.whyHit=CreateFrame("Frame",nil,f)
        f.whyHit:SetPoint("CENTER",f,"CENTER",0,0)
        f.whyHit:SetSize(720,58)
        f.whyHit:EnableMouse(true)
        f.whyHit:SetFrameLevel(f:GetFrameLevel()+8)
        f.whyHit:SetScript("OnEnter",function(self)
            local owner=self:GetParent();if owner then owner._hovered=true end
            local row=owner and owner._whyRow
            local why=RPAL.GetPersonaThoughtWhy and RPAL.GetPersonaThoughtWhy(row) or nil
            if type(why)~="table" then return end
            GameTooltip:SetOwner(self,"ANCHOR_BOTTOM")
            GameTooltip:AddLine("WHY THIS THOUGHT?",1,.82,.34)
            if why.trigger and tostring(why.trigger)~="" then GameTooltip:AddLine(tostring(why.trigger),.92,.92,.92,true) end
            if why.appraisal and tostring(why.appraisal)~="" then GameTooltip:AddDoubleLine("READ AS",tostring(why.appraisal),.62,.65,.72,.82,.87,.96) end
            if why.state and tostring(why.state)~="" then GameTooltip:AddDoubleLine("LIVING STATE",tostring(why.state),.62,.65,.72,.82,.87,.96) end
            if tonumber(why.salience) and tonumber(why.salience)>0 then GameTooltip:AddDoubleLine("CONCERN WEIGHT",tostring(math.floor(tonumber(why.salience)+.5)).." / 100",.62,.65,.72,.82,.87,.96) end
            if tonumber(why.confidence) and tonumber(why.confidence)<.999 then GameTooltip:AddDoubleLine("EVENT CONFIDENCE",tostring(math.floor(tonumber(why.confidence)*100+.5)).."%",.62,.65,.72,.82,.87,.96) end
            if tonumber(why.sarcasm) and tonumber(why.sarcasm)>=.35 then GameTooltip:AddDoubleLine("SARCASM READ",tostring(math.floor(tonumber(why.sarcasm)*100+.5)).."%",.62,.65,.72,.82,.87,.96) end
            if tonumber(why.slangHits) and tonumber(why.slangHits)>0 then GameTooltip:AddDoubleLine("SLANG NORMALIZED",tostring(math.floor(tonumber(why.slangHits))).." signal(s)",.62,.65,.72,.82,.87,.96) end
            if why.evidence and tostring(why.evidence)~="" then
                GameTooltip:AddLine(" ")
                GameTooltip:AddLine("What Persona noticed:",.62,.65,.72)
                GameTooltip:AddLine("“"..tostring(why.evidence):sub(1,210).."”",.86,.86,.90,true)
            end
            GameTooltip:AddLine(" ")
            GameTooltip:AddLine("This explanation is local Persona Beta data.",.48,.52,.60,true)
            GameTooltip:Show()
        end)
        f.whyHit:SetScript("OnLeave",function()
            if f then f._hovered=false end
            if GameTooltip and GameTooltip:IsOwned(f.whyHit) then GameTooltip:Hide() end
        end)

        f.fireflies={}
        local fireflyPoints={
            {-210,-20,13},{-160,14,10},{-108,-10,8},{-60,24,7},{-12,-18,9},{36,14,8},{84,-8,11},{136,20,7},{188,-16,9},{232,12,8},
        }
        for i,pt in ipairs(fireflyPoints) do
            local tex=f:CreateTexture(nil,"ARTWORK",nil,1)
            tex:SetTexture(SPARK);tex:SetBlendMode("ADD")
            tex:SetSize(pt[3],pt[3])
            tex:SetPoint("CENTER",pt[1],pt[2])
            local alpha=(i%2==0) and .30 or .22
            if i%3==0 then
                tex:SetVertexColor(C.pale[1],C.pale[2],C.pale[3],alpha)
            else
                tex:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],alpha)
            end
            f.fireflies[i]=tex
        end
    end
    local f=sparkFrame
    if RPAL.ApplyToastLayout then RPAL.ApplyToastLayout(f) end

    local text=tostring(message or "")
    -- Prefer the exact row supplied by the generator. Legacy callers are
    -- allowed to omit it; only borrow the latest history row when its text
    -- actually matches, so a tooltip can never explain the wrong thought.
    local whyRow=thoughtRow
    if not whyRow and RPAL.GetPersonalityHistory then
        local hist=RPAL.GetPersonalityHistory(1) or {}
        local last=hist[#hist]
        if type(last)=="table" and (tostring(last.text or "")==text or tostring(last.rawText or "")==text) then whyRow=last end
    end
    f._whyRow=whyRow
    f.body:SetText(text);f.echoA:SetText(text);f.echoB:SetText(text);f.echoC:SetText(text)

    local natural=0
    if f.body.GetUnboundedStringWidth then
        local ok,value=pcall(f.body.GetUnboundedStringWidth,f.body)
        if ok then natural=tonumber(value) or 0 end
    end
    if natural<=0 and f.body.GetStringWidth then natural=tonumber(f.body:GetStringWidth()) or 0 end
    local screenWidth=(UIParent and UIParent.GetWidth and tonumber(UIParent:GetWidth())) or 1280
    local maxFrameW=math.max(650,math.min(930,screenWidth-90))
    local frameW=math.min(maxFrameW,math.max(560,math.ceil(natural)+180))
    local bodyW=math.max(400,frameW-150)
    local wraps=(natural+150)>maxFrameW
    f:SetWidth(frameW);f.body:SetWidth(bodyW);f.echoA:SetWidth(bodyW);f.echoB:SetWidth(bodyW);f.echoC:SetWidth(bodyW)
    f.body:SetWordWrap(wraps);f.echoA:SetWordWrap(wraps);f.echoB:SetWordWrap(wraps);f.echoC:SetWordWrap(wraps)
    if f.body.SetMaxLines then f.body:SetMaxLines(wraps and 3 or 1);f.echoA:SetMaxLines(wraps and 3 or 1);f.echoB:SetMaxLines(wraps and 3 or 1);f.echoC:SetMaxLines(wraps and 3 or 1) end
    local bodyH=(f.body.GetStringHeight and tonumber(f.body:GetStringHeight())) or 24
    f:SetHeight(math.max(100,math.min(166,64+bodyH)))
    if f.whyHit then
        f.whyHit:SetWidth(math.max(360,bodyW))
        f.whyHit:SetHeight(math.max(42,math.min(104,bodyH+26)))
    end

    local wordCount=0;for _ in text:gmatch("%S+")do wordCount=wordCount+1 end
    local readSeconds=math.max(8,math.min(16,2+(wordCount/190)*60))
    f._textFullAt=1.16;f._textFadeStart=f._textFullAt+readSeconds;f._textFadeEnd=f._textFadeStart+.88;f._fireflyFadeEnd=f._textFadeEnd+.56
    f._hovered=false;f:SetAlpha(1);f._age=0;f:Show()
    f:SetScript("OnUpdate",function(self,elapsed)
        self._age=(self._age or 0)+(elapsed or 0)
        local t=self._age
        if self._hovered and t>=(self._textFullAt or 1.16)then
            self._age=math.max(self._textFullAt or 1.16,self._age-(elapsed or 0));t=self._age
        end

        -- The mental atmosphere arrives before the words, but it does not
        -- abandon them. Fireflies remain present for the complete thought and
        -- become the final thing to dissolve, roughly half a second after the
        -- text has fully vanished.
        local prelude=math.min(1,t/0.52)
        local textStart=0.68
        local textFadeStart=self._textFadeStart or 9.16
        local textFadeEnd=self._textFadeEnd or(textFadeStart+.88)
        local fireflyFadeEnd=self._fireflyFadeEnd or(textFadeEnd+.56)
        local textEnter=math.max(0,math.min(1,(t-textStart)/0.48))
        local textAlpha=1-((1-textEnter)*(1-textEnter))
        if t>textFadeStart then textAlpha=math.max(0,1-((t-textFadeStart)/(textFadeEnd-textFadeStart))) end
        local a=textAlpha
        local ffIn=math.min(1,t/0.28)
        local ffOut=1
        if t>textFadeEnd then ffOut=math.max(0,1-((t-textFadeEnd)/(fireflyFadeEnd-textFadeEnd))) end
        local ffAlphaBase=ffIn*ffOut
        local atmosphereAlpha=math.max(a,ffAlphaBase*.72)

        local rise=-4+(math.min(1,math.max(0,(t-textStart))/0.95)*6)
        local mindDrift=math.sin((t+0.18)*.71)*1.8
        self.body:ClearAllPoints();self.body:SetPoint("CENTER",mindDrift,rise)
        self.echoA:ClearAllPoints();self.echoA:SetPoint("CENTER",-3+mindDrift*.35,rise+1.5)
        self.echoB:ClearAllPoints();self.echoB:SetPoint("CENTER",3-mindDrift*.25,rise-1.5)
        self.echoC:ClearAllPoints();self.echoC:SetPoint("CENTER",mindDrift*.15,rise+3.5)

        local drift=math.sin(t*.55)*12
        self.mistA:ClearAllPoints();self.mistA:SetPoint("CENTER",-130+drift,math.sin(t*.42)*3)
        self.mistB:ClearAllPoints();self.mistB:SetPoint("CENTER",130-drift,-4-math.sin(t*.47)*3)
        self.mistA:SetAlpha((.08+((math.sin(t*.60)+1)*.022))*atmosphereAlpha)
        self.mistB:SetAlpha((.07+((math.sin(t*.52+1.4)+1)*.020))*atmosphereAlpha)

        local pulse=.5+.5*math.sin(t*.82)
        self.ringA:SetSize(342+pulse*30,110+pulse*12)
        self.ringA:SetAlpha((.020+pulse*.030)*atmosphereAlpha)
        self.ringB:SetSize(472+pulse*42,146+pulse*18)
        self.ringB:SetAlpha((.012+pulse*.022)*atmosphereAlpha)
        self.grain:ClearAllPoints();self.grain:SetPoint("CENTER",math.sin(t*.29)*9,-2+math.cos(t*.33)*2)
        self.grain:SetAlpha((.015+((math.sin(t*.41+1.2)+1)*.008))*atmosphereAlpha)
        self.traceL:ClearAllPoints();self.traceL:SetPoint("CENTER",-248+(t*7)%42,-7+math.sin(t*.62)*4)
        self.traceR:ClearAllPoints();self.traceR:SetPoint("CENTER",252-((t*6)%36),8+math.sin(t*.57+1.1)*3)
        self.traceL:SetAlpha((.022+((math.sin(t*.83)+1)*.010))*a)
        self.traceR:SetAlpha((.016+((math.sin(t*.74+1.8)+1)*.009))*a)

        self.body:SetAlpha((.90+((math.sin(t*.66)+1)*.035))*a)
        self.echoA:SetAlpha((.045+((math.sin(t*1.0)+1)*.012))*a)
        self.echoB:SetAlpha((.025+((math.sin(t*.88+2.0)+1)*.010))*a)
        self.echoC:SetAlpha((.012+((math.sin(t*.61+2.7)+1)*.007))*a)
        self.quoteL:SetAlpha((.060+((math.sin(t*.78)+1)*.016))*a)
        self.quoteR:SetAlpha((.050+((math.sin(t*.74+1.7)+1)*.014))*a)
        self.sparkL:SetAlpha((.055+((math.sin(t*1.18)+1)*.026))*a)
        self.sparkR:SetAlpha((.040+((math.sin(t*.96+1.2)+1)*.022))*a)

        for i,tex in ipairs(self.fireflies or {}) do
            local bx=((i-1)*0.77)
            local dx=math.sin(t*(1.20+bx)+i)*6 + math.cos(t*(0.64+bx))*2
            local dy=math.cos(t*(1.55+bx)+i*0.35)*4 + math.sin(t*(0.82+bx))*2
            local sz=(i%3==0) and (7+math.sin(t*2.1+i)*1.0) or (9+math.sin(t*1.8+i)*1.6)
            tex:SetSize(sz,sz)
            local pointX,pointY=0,0
            local p=({{-210,-20},{-160,14},{-108,-10},{-60,24},{-12,-18},{36,14},{84,-8},{136,20},{188,-16},{232,12}})[i]
            pointX,pointY=p[1],p[2]
            tex:ClearAllPoints();tex:SetPoint("CENTER",pointX+dx,pointY+dy)
            tex:SetAlpha(ffAlphaBase*((i%2==0) and .85 or .70))
        end

        if t>=fireflyFadeEnd then
            if GameTooltip and self.whyHit and GameTooltip:IsOwned(self.whyHit) then GameTooltip:Hide() end
            self._whyRow=nil
            self:Hide();self:SetScript("OnUpdate",nil)
        end
    end)
end

local function SceneSummaryLine(scene)
    if not scene then return "" end
    local people=scene.participantNames or (RPAL.GetSceneParticipantNames and RPAL.GetSceneParticipantNames(scene)) or {}
    local active,total=0,#people
    if RPAL.GetSceneParticipantCounts then
        active,total=RPAL.GetSceneParticipantCounts(scene)
    else
        active=#(scene.activeParticipantNames or {})
    end
    local duration=scene.duration or (RPAL.GetSceneDuration and RPAL.GetSceneDuration(scene)) or 0
    local locations=RPAL.GetSceneLocations and RPAL.GetSceneLocations(scene) or (scene.locations or {})
    local route=(#locations>1) and ("  •  "..tostring(#locations).." stops") or ""
    return FormatDuration(duration).."  •  "..tostring(active).." active RP / "..tostring(total).." present  •  "..tostring(SumMap(scene.interactions)).." interactions"..route.."  •  +"..tostring(math.floor(tonumber(scene.renownGained) or 0)).." Renown"
end

local sceneRecapWindow

-- Chronicle itself sits very high in FULLSCREEN_DIALOG. Modals opened from
-- Chronicle must be explicitly lifted above it or they can render behind it.
local function RaiseChronicleModal(shade,frame)
    if shade then
        shade:SetFrameStrata("FULLSCREEN_DIALOG")
        shade:SetFrameLevel(9800)
        shade:SetToplevel(true)
        shade:EnableMouse(true)
        if shade.Raise then shade:Raise() end
    end
    if frame then
        frame:SetFrameStrata("FULLSCREEN_DIALOG")
        frame:SetFrameLevel(9820)
        frame:SetToplevel(true)
        frame:EnableMouse(true)
        if frame.Raise then frame:Raise() end
    end
end

local function LocationLabel(loc)
    if not loc then return "Unknown Place" end
    if loc.customName and tostring(loc.customName)~="" then return tostring(loc.customName) end
    return tostring(loc.label or loc.subZone or loc.zone or "Unknown Place")
end

local function OpenScenePlaceRename(scene,index,onSaved)
    local locations=RPAL.GetSceneLocations and RPAL.GetSceneLocations(scene) or (scene and scene.locations) or {}
    local loc=locations[index];if not loc then return end
    local shade,f=Modal("NAME THIS PLACE",480,235);RaiseChronicleModal(shade,f)
    local hint=Text(f,"Give this stop a name that means something to the session. Leave it blank to use ODYN's detected location.",9,C.muted)
    hint:SetPoint("TOPLEFT",20,-60);hint:SetWidth(440);hint:SetWordWrap(true)
    local edit=CreateFrame("EditBox",nil,f,"BackdropTemplate");edit:SetPoint("TOPLEFT",20,-112);edit:SetSize(440,38);Backdrop(edit,C.panel2,C.border);edit:SetFont(FONT,Scaled(12),"");edit:SetTextColor(C.pale[1],C.pale[2],C.pale[3]);edit:SetTextInsets(10,10,0,0);edit:SetAutoFocus(true);edit:SetText(loc.customName or "")
    local detected=Text(f,"Detected: "..tostring(loc.label or loc.zone or "Unknown Place"),8,C.muted);detected:SetPoint("TOPLEFT",22,-157)
    local save=Button(f,"Save Name",102,32,true);save:SetPoint("BOTTOMRIGHT",-20,18);save:SetScript("OnClick",function()
        if RPAL.RenameSceneLocation then RPAL.RenameSceneLocation(scene.id,index,edit:GetText()) else loc.customName=edit:GetText() end
        shade:Hide();if onSaved then onSaved() end
    end)
end

local function RenderSceneRoute(parent,scene,onChanged)
    local locations=RPAL.GetSceneLocations and RPAL.GetSceneLocations(scene) or (scene and scene.locations) or {}
    local panel=CreateFrame("Frame",nil,parent,"BackdropTemplate");panel:SetSize(720,142);Backdrop(panel,C.panel,C.border)
    local title=Text(panel,"SESSION ROUTE",8,C.gold);title:SetPoint("TOPLEFT",12,-9)
    local hint=Text(panel,"Click a stop to rename it",8,C.muted);hint:SetPoint("TOPRIGHT",-12,-9)
    if #locations==0 then
        local empty=Text(panel,"No route information was recorded for this Session.",10,C.muted);empty:SetPoint("CENTER",0,-8)
        return panel
    end
    local max=math.min(12,#locations);local cols=6
    local positions={}
    for i=1,max do
        local row=math.floor((i-1)/cols);local raw=(i-1)%cols;local col=(row%2==0) and raw or (cols-1-raw)
        local x=34+(col*112);local y=-52-(row*62);positions[i]={x=x,y=y,row=row}
    end
    local function Dash(x,y,w,h)
        local d=panel:CreateTexture(nil,"ARTWORK");d:SetTexture("Interface\\Buttons\\WHITE8X8");d:SetSize(w,h);d:SetPoint("CENTER",panel,"TOPLEFT",x,y);d:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],0.30)
    end
    for i=1,max-1 do
        local a,b=positions[i],positions[i+1]
        if a.row==b.row then
            local left,right=math.min(a.x,b.x)+17,math.max(a.x,b.x)-17
            for x=left,right,12 do Dash(x,a.y,6,2) end
        else
            for y=a.y-8,b.y+8,-10 do Dash(a.x,y,2,5) end
        end
    end
    for i=1,max do
        local loc=locations[i];local pos=positions[i]
        local locationIndex=i;local location=loc
        local node=CreateFrame("Button",nil,panel,"BackdropTemplate");node:SetSize(88,42);node:SetPoint("CENTER",panel,"TOPLEFT",pos.x,pos.y);Backdrop(node,C.panel2,C.border)
        local dot=node:CreateTexture(nil,"ARTWORK");dot:SetTexture(SPARK);dot:SetBlendMode("ADD");dot:SetSize(16,16);dot:SetPoint("TOP",0,3);dot:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],0.75)
        local n=Text(node,tostring(i),7,C.gold);n:SetPoint("CENTER",dot,0,0)
        node.label=Text(node,LocationLabel(loc),7.5,C.pale);node.label:SetPoint("TOP",0,-15);node.label:SetWidth(82);node.label:SetJustifyH("CENTER");node.label:SetWordWrap(false)
        node:SetScript("OnEnter",function(self)self:SetScale(1.045);self:SetBackdropBorderColor(C.gold[1],C.gold[2],C.gold[3],1)end)
        node:SetScript("OnLeave",function(self)self:SetScale(1);self:SetBackdropBorderColor(C.border[1],C.border[2],C.border[3],C.border[4] or 1)end)
        node:SetScript("OnClick",function()OpenScenePlaceRename(scene,locationIndex,function()node.label:SetText(LocationLabel(location));if onChanged then onChanged() end end)end)
    end
    if #locations>max then
        local more=Text(panel,"+"..tostring(#locations-max).." more recorded stops",8,C.muted);more:SetPoint("BOTTOMRIGHT",-10,5)
    end
    return panel
end

-- ------------------------------------------------------------
-- Recap editor. Ending a scene already saves it; this screen only asks the
-- player what, if anything, deserves a human note or a Moment.
-- ------------------------------------------------------------

function RPAL.OpenSceneRecap(scene,options)
    ApplyPalette()
    if not scene then return end
    options=options or {}
    local readOnly=options.readOnly and true or false
    if sceneRecapWindow and sceneRecapWindow:IsShown() then sceneRecapWindow:Hide() end

    local shade,f=Modal(readOnly and "SAVED SESSION" or "SESSION RECORDED",960,760)
    shade._rpalSceneRecapMode=readOnly and "saved-view" or "history-edit"
    shade._rpalSceneID=scene.id
    sceneRecapWindow=shade;RaiseChronicleModal(shade,f)
    shade:HookScript("OnHide",function(self)
        if sceneRecapWindow==self then sceneRecapWindow=nil end
        -- The Session Recorded recap must always be seen first. Persona's
        -- reflection panel is deliberately deferred until this screen closes.
        if not readOnly and RPAL.MaybeOpenPendingPersonaSessionReflection then
            local sceneID=scene and scene.id
            C_Timer.After(0.18,function()if RPAL.MaybeOpenPendingPersonaSessionReflection then RPAL.MaybeOpenPendingPersonaSessionReflection(sceneID)end end)
        end
    end)

    local revealIndex=0
    local function Reveal(frame,delay,duration)
        revealIndex=revealIndex+1
        frame:SetAlpha(0);frame:SetScale(0.985)
        C_Timer.After(delay or ((revealIndex-1)*0.045),function()
            if not frame or not frame:IsShown() then return end
            duration=duration or 0.12
            -- FontString objects do not support OnUpdate.  Use a few tiny
            -- timer steps instead so the same quick fade works for both
            -- FontStrings and Frames without generating Blizzard UI errors.
            local steps=4
            local stepDelay=duration/steps
            for step=1,steps do
                C_Timer.After(stepDelay*step,function()
                    if not frame or not frame:IsShown() then return end
                    local k=math.min(1,step/steps)
                    local eased=1-((1-k)*(1-k))
                    frame:SetAlpha(eased)
                    frame:SetScale(0.985+(0.015*eased))
                    if step==steps then frame:SetScale(1) end
                end)
            end
        end)
    end

    local function Card(parent,w,h)
        local c=CreateFrame("Frame",nil,parent,"BackdropTemplate");c:SetSize(w,h);Backdrop(c,C.panel,C.border)
        local line=c:CreateTexture(nil,"ARTWORK");line:SetTexture("Interface\\Buttons\\WHITE8X8");line:SetSize(w-26,1);line:SetPoint("BOTTOM",0,7);line:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],0.20)
        return c
    end

    local leftX=20
    local leftW=630
    local rightX=665
    local rightW=275

    local titleKicker=Text(f,readOnly and "SESSIONS  •  SAVED MEMORY" or "SESSIONS  •  SESSION COMPLETE",8,C.gold);titleKicker:SetPoint("TOPLEFT",leftX,-48);Reveal(titleKicker,0.02)
    local titlePreview=Text(f,scene.title or "A Session",20,C.pale,DISPLAY);titlePreview:SetPoint("TOPLEFT",leftX,-66);titlePreview:SetWidth(leftW);titlePreview:SetWordWrap(false);Reveal(titlePreview,0.06)
    local where=Text(f,(scene.zone or "Unknown Place").."  •  "..DateText(scene.endedAt or scene.startedAt),9.5,C.muted);where:SetPoint("TOPLEFT",leftX,-92);Reveal(where,0.10)

    local people={}
    for key,p in pairs(scene.participants or {}) do
        local name=RPAL.GetSceneParticipantDisplayName and RPAL.GetSceneParticipantDisplayName(p,p.unitName or p.name or key) or tostring(p.trpName or p.name or key)
        if name~="" then
            local met=(p.met==true) or ((scene.metCharacters or {})[key]~=nil)
            local trpName=tostring(p.trpName or "")
            local activeRP=RPAL.IsSceneParticipantActive and RPAL.IsSceneParticipantActive(p) or p.activeRP==true or met or (tonumber(p.interactions) or 0)>0
            people[#people+1]={key=key,name=name,unitName=tostring(p.unitName or ""),trpName=trpName,met=met,presence=p.presence==true,activeRP=activeRP,interactions=tonumber(p.interactions) or 0}
        end
    end
    table.sort(people,function(a,b)
        if a.activeRP~=b.activeRP then return a.activeRP end
        if a.met~=b.met then return a.met end
        if a.interactions~=b.interactions then return a.interactions>b.interactions end
        return a.name:lower()<b.name:lower()
    end)
    local activePeople,otherPeople={},{}
    for _,person in ipairs(people) do
        if person.activeRP then activePeople[#activePeople+1]=person else otherPeople[#otherPeople+1]=person end
    end

    local duration=scene.duration or (RPAL.GetSceneDuration and RPAL.GetSceneDuration(scene)) or 0
    local interactions=SumMap(scene.interactions)
    local locations=RPAL.GetSceneLocations and RPAL.GetSceneLocations(scene) or (scene.locations or {})
    local newFaces=CountMap(scene.newCharacters)
    local renown=math.floor(tonumber(scene.renownGained) or 0)

    local stats={{"TIME",FormatDuration(duration)},{"CHARACTERS",tostring(#people)},{"INTERACTIONS",tostring(interactions)},{"RENOWN","+"..tostring(renown)}}
    for i,item in ipairs(stats) do
        local x=leftX+((i-1)*158)
        local card=Card(f,148,56);card:SetPoint("TOPLEFT",x,-112)
        local lab=Text(card,item[1],7.5,C.muted);lab:SetPoint("TOPLEFT",11,-9)
        local val=Text(card,item[2],15,C.gold,DISPLAY);val:SetPoint("BOTTOMLEFT",11,13)
        Reveal(card,0.14+((i-1)*0.035))
    end

    local l1=Text(f,"SESSION NAME",8,C.muted);l1:SetPoint("TOPLEFT",leftX,-180);Reveal(l1,0.30)
    local title=CreateFrame("EditBox",nil,f,"BackdropTemplate");title:SetPoint("TOPLEFT",leftX,-198);title:SetSize(leftW,36);Backdrop(title,C.panel2,C.border);title:SetFont(FONT,Scaled(13),"");title:SetTextColor(C.pale[1],C.pale[2],C.pale[3]);title:SetTextInsets(11,11,0,0);title:SetAutoFocus(false);title:SetText(scene.title or "");Reveal(title,0.33)

    local l2=Text(f,"SUMMARY  (OPTIONAL)",8,C.muted);l2:SetPoint("TOPLEFT",leftX,-244);Reveal(l2,0.37)
    local note=CreateFrame("EditBox",nil,f,"BackdropTemplate");note:SetPoint("TOPLEFT",leftX,-262);note:SetSize(leftW,78);Backdrop(note,C.panel2,C.border);note:SetFont(FONT,Scaled(11.5),"");note:SetTextColor(C.pale[1],C.pale[2],C.pale[3]);note:SetTextInsets(11,11,9,9);note:SetMultiLine(true);note:SetAutoFocus(false);note:SetText(scene.note or "");Reveal(note,0.40)
    if readOnly then
        title:EnableMouse(false);note:EnableMouse(false)
        if title.SetEnabled then title:SetEnabled(false) end
        if note.SetEnabled then note:SetEnabled(false) end
    end

    local peopleHeader=Text(f,"PEOPLE IN THIS SESSION",9,C.gold);peopleHeader:SetPoint("TOPLEFT",leftX,-356);Reveal(peopleHeader,0.46)
    local peopleSub=Text(f,tostring(#activePeople).." ACTIVE RP  •  "..tostring(#people).." TOTAL PRESENT",7.5,C.muted);peopleSub:SetPoint("TOPRIGHT",leftX+leftW,-356);peopleSub:SetJustifyH("RIGHT");Reveal(peopleSub,0.49)

    local peoplePanel=CreateFrame("Frame",nil,f,"BackdropTemplate");peoplePanel:SetSize(leftW,252);peoplePanel:SetPoint("TOPLEFT",leftX,-370);Backdrop(peoplePanel,C.panel,C.border);Reveal(peoplePanel,0.52)
    local peopleScroll=CreateFrame("ScrollFrame",nil,peoplePanel,"UIPanelScrollFrameTemplate");peopleScroll:SetPoint("TOPLEFT",8,-8);peopleScroll:SetPoint("BOTTOMRIGHT",-24,8)
    if RPAL.SkinScrollFrame then RPAL.SkinScrollFrame(peopleScroll) end
    local peopleContent=CreateFrame("Frame",nil,peopleScroll);peopleContent:SetWidth(leftW-40);peopleContent:SetHeight(1);peopleScroll:SetScrollChild(peopleContent)
    local peopleY=0
    local renderedPeople=0
    local function RenderPeopleSection(label,hint,list,isActive)
        if #list==0 and not isActive then return end
        local section=Text(peopleContent,label.."  •  "..tostring(#list),7.5,isActive and C.gold or C.muted)
        section:SetPoint("TOPLEFT",2,-peopleY);section:SetWidth(190)
        local sectionHint=Text(peopleContent,hint,6.5,C.muted);sectionHint:SetPoint("TOPRIGHT",-2,-peopleY);sectionHint:SetJustifyH("RIGHT")
        peopleY=peopleY+20
        local limit=math.min(#list,24-renderedPeople)
        for i=1,limit do
            local person=list[i];local col=(i-1)%2;local row=math.floor((i-1)/2)
            local card=CreateFrame("Frame",nil,peopleContent,"BackdropTemplate");card:SetSize(290,46);card:SetPoint("TOPLEFT",col*298,-peopleY-(row*54));Backdrop(card,C.panel2,isActive and C.gold or C.border)
            local icon=card:CreateTexture(nil,"ARTWORK");icon:SetTexture(RPAL.ICONS and RPAL.ICONS.person or "Interface\\TargetingFrame\\UI-TargetingFrame-Skull");icon:SetSize(23,23);icon:SetPoint("LEFT",8,0);icon:SetVertexColor((isActive and C.gold or C.muted)[1],(isActive and C.gold or C.muted)[2],(isActive and C.gold or C.muted)[3],isActive and 0.96 or 0.70)
            local displayName=Text(card,person.name,10,C.pale,DISPLAY);displayName:SetPoint("TOPLEFT",39,-7);displayName:SetWidth(172);displayName:SetWordWrap(false)
            local subText=isActive and ((person.interactions>0 and (tostring(person.interactions).." tracked interaction"..(person.interactions==1 and "" or "s"))) or (person.met and "Character met during roleplay" or "Direct roleplay signal")) or "Present nearby • no direct RP signal"
            local sub=Text(card,subText,7.2,isActive and C.gold or C.muted);sub:SetPoint("TOPLEFT",39,-25);sub:SetWidth(205);sub:SetWordWrap(false)
            local tag=Text(card,isActive and "ACTIVE RP" or "PRESENT",7,isActive and C.green or C.muted);tag:SetPoint("TOPRIGHT",-8,-8)
            Reveal(card,0.55+((renderedPeople+i-1)*0.025))
        end
        renderedPeople=renderedPeople+limit
        peopleY=peopleY+(math.max(1,math.ceil(limit/2))*54)+8
    end
    RenderPeopleSection("ACTIVE ROLEPLAYERS","direct speech, emotes or interactions",activePeople,true)
    RenderPeopleSection("OTHER PEOPLE PRESENT","nearby context, kept separately",otherPeople,false)
    peopleContent:SetHeight(math.max(1,peopleY))
    shade._rpalActiveParticipantCount=#activePeople
    shade._rpalPresentParticipantCount=#people
    shade._rpalParticipantSections={active=#activePeople,other=#otherPeople}
    if #people>24 then
        local more=Text(peoplePanel,"+"..tostring(#people-24).." more recorded characters",7,C.muted);more:SetPoint("BOTTOMRIGHT",-28,7);Reveal(more,1.10)
    elseif #people==0 then
        local empty=Text(peoplePanel,"No characters were recorded during this Session.",9,C.muted);empty:SetPoint("CENTER");Reveal(empty,0.56)
    end

    local routePanel=CreateFrame("Frame",nil,f,"BackdropTemplate");routePanel:SetSize(rightW,638);routePanel:SetPoint("TOPLEFT",rightX,-48);Backdrop(routePanel,C.panel,C.border);Reveal(routePanel,0.18)
    local routeTitle=Text(routePanel,"SESSION ROUTE",10,C.gold);routeTitle:SetPoint("TOPLEFT",14,-12)
    local routeHint=Text(routePanel,#locations>0 and (readOnly and "Recorded in order" or "Recorded in order  •  click a stop to rename") or "No route information recorded",7.5,C.muted);routeHint:SetPoint("TOPLEFT",14,-31);routeHint:SetWidth(rightW-28);routeHint:SetWordWrap(false)
    local routeScroll=CreateFrame("ScrollFrame",nil,routePanel,"UIPanelScrollFrameTemplate");routeScroll:SetPoint("TOPLEFT",10,-52);routeScroll:SetPoint("BOTTOMRIGHT",-24,12)
    if RPAL.SkinScrollFrame then RPAL.SkinScrollFrame(routeScroll) end
    local routeContent=CreateFrame("Frame",nil,routeScroll);routeContent:SetWidth(rightW-44);routeContent:SetHeight(math.max(1,#locations*62));routeScroll:SetScrollChild(routeContent)
    if #locations>0 then
        local line=routeContent:CreateTexture(nil,"ARTWORK");line:SetTexture("Interface\\Buttons\\WHITE8X8");line:SetWidth(2);line:SetPoint("TOPLEFT",18,-16);line:SetPoint("BOTTOMLEFT",18,18);line:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],0.34)
        for i,loc in ipairs(locations) do
            local index=i
            local node=CreateFrame("Button",nil,routeContent,"BackdropTemplate");node:SetSize(rightW-58,52);node:SetPoint("TOPLEFT",30,-((i-1)*62));Backdrop(node,C.panel2,C.border)
            local dot=node:CreateTexture(nil,"ARTWORK");dot:SetTexture(SPARK);dot:SetBlendMode("ADD");dot:SetSize(20,20);dot:SetPoint("LEFT",-21,0);dot:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],0.78)
            local n=Text(node,string.format("%02d",i),7.5,C.gold);n:SetPoint("LEFT",-29,0)
            local label=Text(node,LocationLabel(loc),9.5,C.pale,DISPLAY);label:SetPoint("TOPLEFT",11,-8);label:SetWidth(rightW-92);label:SetWordWrap(false)
            local timeLine=Text(node,(loc.firstAt and date("%H:%M",loc.firstAt) or "")..((loc.lastAt and loc.firstAt and loc.lastAt~=loc.firstAt) and ("  •  "..date("%H:%M",loc.lastAt)) or ""),7,C.muted);timeLine:SetPoint("TOPLEFT",11,-29)
            if readOnly then
                node:EnableMouse(false)
            else
                node:SetScript("OnEnter",function(self)self:SetScale(1.02);self:SetBackdropBorderColor(C.gold[1],C.gold[2],C.gold[3],0.95)end)
                node:SetScript("OnLeave",function(self)self:SetScale(1);self:SetBackdropBorderColor(C.border[1],C.border[2],C.border[3],C.border[4] or 1)end)
                node:SetScript("OnClick",function()OpenScenePlaceRename(scene,index,function()label:SetText(LocationLabel(loc));if routeHint then routeHint:SetText("Recorded in order  •  click a stop to rename") end end)end)
            end
            Reveal(node,0.22+((i-1)*0.035))
        end
    else
        local empty=Text(routeContent,"Move through different places during a Session and ODYN will build the route automatically.",9,C.muted);empty:SetPoint("TOPLEFT",10,-18);empty:SetWidth(rightW-64);empty:SetWordWrap(true);Reveal(empty,0.25)
    end

    local healthChanges=scene.healthChanges or {}
    local eventLine=tostring(newFaces).." new faces  •  "..tostring(#(scene.talentProcs or {})).." Talent procs  •  "..tostring(#locations).." recorded stops"
    if #healthChanges>0 then eventLine=eventLine.."\nHEALTH: "..table.concat(healthChanges,"  •  ") end
    local events=Text(f,eventLine,8,C.muted);events:SetPoint("TOPLEFT",leftX,-636);events:SetWidth(leftW);events:SetWordWrap(true);Reveal(events,1.08)
    local explain=Text(f,readOnly and "Read-only Saved Session snapshot. Its original History log may be edited or deleted without changing this memory." or "Your Session has been recorded. Save the title and summary below, or keep it as a Saved Session.",8,C.muted);explain:SetPoint("BOTTOMLEFT",leftX,62);explain:SetWidth(560);explain:SetWordWrap(true);Reveal(explain,1.12)

    if readOnly then
        local close=Button(f,"Close",104,34,true);close:SetPoint("BOTTOMRIGHT",-20,20);close:SetScript("OnClick",function()shade:Hide()end);Reveal(close,1.18)
        shade._rpalCloseButton=close
    else
        local discard=Button(f,"Delete Log",104,34,false);discard:SetPoint("BOTTOMLEFT",leftX,20);discard.label:SetTextColor(C.red[1],C.red[2],C.red[3],1);Reveal(discard,1.16)
        discard:SetScript("OnClick",function()
            if RPAL.DeleteScene then RPAL.DeleteScene(scene.id) end
            shade:Hide();if RPAL.RefreshChronicle then RPAL.RefreshChronicle() end
        end)
        local save=Button(f,"Save Session",118,34,false);save:SetPoint("BOTTOMRIGHT",-20,20);Reveal(save,1.20)
        save:SetScript("OnClick",function()RPAL.UpdateScene(scene.id,{title=title:GetText(),note=note:GetText()});shade:Hide();if RPAL.RefreshChronicle then RPAL.RefreshChronicle() end end)
        local moment=Button(f,"Save + Saved Session",166,34,true);moment:SetPoint("RIGHT",save,"LEFT",-8,0);Reveal(moment,1.24)
        moment:SetScript("OnClick",function()
            local newTitle,newNote=title:GetText(),note:GetText();RPAL.UpdateScene(scene.id,{title=newTitle,note=newNote});RPAL.CreateMomentFromScene(scene,newTitle,newNote)
            shade:Hide();if RPAL.OpenChronicle then RPAL.OpenChronicle("Moments") end
        end)
        shade._rpalDeleteButton=discard;shade._rpalSaveButton=save;shade._rpalMomentButton=moment
    end
    return shade,f
end

function RPAL.OpenSavedSessionView(moment)
    if not moment then return nil end
    local endedAt=tonumber(moment.endedAt) or tonumber(moment.at) or time()
    local duration=math.max(0,tonumber(moment.duration) or 0)
    local snapshot={
        id=tostring(moment.sceneID or moment.id or "saved-session"),
        title=tostring(moment.title or "A Saved Session"),note=tostring(moment.text or ""),
        zone=tostring(moment.zone or ""),startedAt=tonumber(moment.startedAt) or (endedAt-duration),endedAt=endedAt,duration=duration,
        interactions={saved=math.max(0,tonumber(moment.interactions) or 0)},renownGained=math.max(0,tonumber(moment.renownGained) or 0),
        participantNames={},participants={},metCharacters={},newCharacters={},talentProcs={},locations=moment.locations or {},
    }
    for index,person in ipairs(moment.peopleDetails or {}) do
        local name=tostring(person.name or "")
        if name~="" then
            local key=tostring(person.key or ("saved-"..tostring(index)))
            snapshot.participantNames[#snapshot.participantNames+1]=name
            snapshot.participants[key]={
                name=name,unitName=tostring(person.unitName or ""),trpName=tostring(person.trpName or ""),
                met=person.met==true,presence=person.presence==true,activeRP=person.activeRP==true,
                activeSource=tostring(person.activeSource or ""),interactions=tonumber(person.interactions) or 0,
            }
            if person.met then snapshot.metCharacters[key]=name end
        end
    end
    if #snapshot.participantNames==0 then
        for index,name in ipairs(moment.people or {}) do
            name=tostring(name or "")
            if name~="" then
                local key="saved-"..tostring(index)
                snapshot.participantNames[#snapshot.participantNames+1]=name
                snapshot.participants[key]={name=name,presence=true,activeRP=false,interactions=0}
            end
        end
    end
    local shade,frame=RPAL.OpenSceneRecap(snapshot,{readOnly=true})
    if shade then shade._rpalSavedMomentID=moment.id end
    return shade,frame
end

-- Keep the already-open Sessions page live when a Session starts from the
-- desk/settings controls. The page used to wait until navigation changed.
RPAL.OnSceneStarted=function(scene)
    if RPAL.IsChronicleEmbeddedOpen and RPAL.IsChronicleEmbeddedOpen() then
        RPAL.RefreshChronicle()
    end
end

RPAL.OnScenePaused=function(scene)
    if RPAL.ShowSessionStatusToast then
        local message=(scene and scene.pauseReason=="ooc")
            and "Session paused and stored. ODYN will ask when you return IC."
            or "Session paused and stored. Resume it anytime from Paused Sessions."
        RPAL.ShowSessionStatusToast(message,"PAUSED")
    end
    if RPAL.IsChronicleEmbeddedOpen and RPAL.IsChronicleEmbeddedOpen() then
        activeTab="Stored"
        RPAL.RefreshChronicle()
    end
end

RPAL.OnSceneResumed=function(scene)
    if RPAL.ShowSessionStatusToast then
        RPAL.ShowSessionStatusToast("Stored Session resumed. Recording continues. <3","IC")
    end
    if RPAL.IsChronicleEmbeddedOpen and RPAL.IsChronicleEmbeddedOpen() then
        activeTab="Scenes"
        RPAL.RefreshChronicle()
    end
end

-- Ending a session immediately opens the recorded-session recap.
-- Keep this as a public callback so every EndScene caller gets the same UX.
RPAL.OnSceneEnded=function(scene)
    if RPAL.OpenSceneRecap then
        RPAL.OpenSceneRecap(scene)
    end
end

local CONTENT_W=750
local chronicle=nil

local function AttachChronicleToCurrentUI(f)
    if not f then return false end
    local ui=RPAL.UI
    local parent=ui and ui.content
    if not parent then return false end
    if f:GetParent()~=parent then f:SetParent(parent) end
    f:ClearAllPoints();f:SetAllPoints(parent)
    f:SetFrameLevel((parent:GetFrameLevel() or 1)+45)
    return true
end

local function EnsureChronicle()
    if chronicle then
        AttachChronicleToCurrentUI(chronicle)
        return chronicle
    end
    local ui=RPAL.UI
    local parent=(ui and ui.content) or UIParent

    local f=CreateFrame("Frame",nil,parent,"BackdropTemplate")
    chronicle=f
    f:SetAllPoints(parent)
    f:SetFrameLevel((parent.GetFrameLevel and parent:GetFrameLevel() or 1)+45)
    f:EnableMouse(true)
    Backdrop(f,C.bg,C.gold)
    f:Hide()

    f.title=Text(f,"SESSIONS",22,C.gold,DISPLAY);f.title:SetPoint("TOPLEFT",18,-15)
    f.subtitle=Text(f,"Pause for later, resume intact, or turn a finished Session into history.",10,C.muted);f.subtitle:SetPoint("TOPLEFT",19,-43)

    f.sceneStrip=CreateFrame("Frame",nil,f,"BackdropTemplate");f.sceneStrip:SetPoint("TOPLEFT",16,-67);f.sceneStrip:SetPoint("TOPRIGHT",-16,-67);f.sceneStrip:SetHeight(62);Backdrop(f.sceneStrip,C.panel,C.border)
    f.sceneStatus=Text(f.sceneStrip,"",12,C.pale,DISPLAY);f.sceneStatus:SetPoint("TOPLEFT",14,-11)
    f.sceneMeta=Text(f.sceneStrip,"",9,C.muted);f.sceneMeta:SetPoint("TOPLEFT",14,-34)
    f.sceneAction=Button(f.sceneStrip,"Start Session",112,30,true);f.sceneAction:SetPoint("RIGHT",-12,0)
    f.sparkToggle=Button(f.sceneStrip,"Thoughts: ON",88,26,false);f.sparkToggle:SetPoint("RIGHT",f.sceneAction,"LEFT",-7,0)

    f.tabs={}
    local tabs={{key="Scenes",label="History"},{key="Stored",label="Paused"},{key="Moments",label="Saved"},{key="Campaigns",label="Campaigns"},{key="Story Arcs",label="Story Arcs"},{key="Bonds",label="Bonds"},{key="Season",label="Season Tasks"}}
    local x=16
    for _,tab in ipairs(tabs) do
        local tabKey=tab.key
        local tabLabel=tab.label
        local b=Button(f,tabLabel,96,29,false);b:SetPoint("TOPLEFT",x,-140);x=x+101;f.tabs[tabKey]=b
        b:SetScript("OnClick",function()
            activeTab=tabKey;RPAL.RefreshChronicle();f.content:SetAlpha(0);local age=0
            f.content:SetScript("OnUpdate",function(self,elapsed)age=age+(elapsed or 0);local k=math.min(1,age/0.16);self:SetAlpha(k);if k>=1 then self:SetScript("OnUpdate",nil) end end)
        end)
    end

    f.scroll=CreateFrame("ScrollFrame",nil,f,"UIPanelScrollFrameTemplate");f.scroll:SetPoint("TOPLEFT",16,-178);f.scroll:SetPoint("BOTTOMRIGHT",-34,16)
    if RPAL.SkinScrollFrame then RPAL.SkinScrollFrame(f.scroll) end
    f.content=CreateFrame("Frame",nil,f.scroll);f.content:SetSize(CONTENT_W,1);f.scroll:SetScrollChild(f.content)

    f.sceneAction:SetScript("OnClick",function()
        if RPAL.IsSceneActive and RPAL.IsSceneActive() then
            if RPAL.RequestEndScene then RPAL.RequestEndScene() end
        else
            RPAL.StartScene()
        end
        RPAL.RefreshChronicle()
    end)
    f._sceneTick=0
    f:SetScript("OnUpdate",function(self,elapsed)
        if not self:IsShown() then return end
        self._sceneTick=(self._sceneTick or 0)+(elapsed or 0)
        if self._sceneTick<1 then return end
        self._sceneTick=0
        local scene=RPAL.GetActiveScene and RPAL.GetActiveScene()
        if scene then self.sceneMeta:SetText(SceneSummaryLine(scene)) end
        -- Sessions can award Renown while this page remains open. Keep the
        -- persistent progression strip live instead of waiting for a full UI
        -- navigation refresh before the marker/value advances.
        if RPAL.RefreshProgressionStrip then RPAL.RefreshProgressionStrip() end
    end)
    f.sparkToggle:SetScript("OnClick",function()
        RPAL.SetSceneSparkEnabled(not RPAL.GetSceneSparkEnabled())
        RPAL.RefreshChronicle()
    end)
    return f
end

local function Row(parent,y,height)
    local r=CreateFrame("Frame",nil,parent,"BackdropTemplate");r:SetPoint("TOPLEFT",0,-y);r:SetSize(CONTENT_W,height);Backdrop(r,C.panel2,C.border);return r
end

local function Empty(parent,message)
    local t=Text(parent,message,13,C.muted,DISPLAY);t:SetPoint("TOP",0,-55);t:SetWidth(650);t:SetJustifyH("CENTER");t:SetWordWrap(true);return 150
end

local function ChronicleExplainer(parent,y,title,body,tag)
    local box=CreateFrame("Frame",nil,parent,"BackdropTemplate")
    box:SetPoint("TOPLEFT",0,-y);box:SetSize(CONTENT_W,68);Backdrop(box,C.panel,C.border)
    local h=Text(box,title,12,C.gold,DISPLAY);h:SetPoint("TOPLEFT",14,-12)
    local b=Text(box,body,9,C.muted);b:SetPoint("TOPLEFT",14,-34);b:SetWidth(560);b:SetWordWrap(true)
    if tag then
        local pill=Text(box,string.upper(tag),8,C.pale);pill:SetPoint("TOPRIGHT",-14,-13)
    end
    return 78
end

local function ConfirmDeleteScene(scene)
    if not scene then return end
    local shade,f=Modal("DELETE SESSION HISTORY",520,270)
    RaiseChronicleModal(shade,f)
    local title=Text(f,scene.title or "A Session",15,C.pale,DISPLAY);title:SetPoint("TOPLEFT",20,-62);title:SetWidth(470)
    local body=Text(f,"This removes only the automatic Session History entry. Renown, Bonds, Talents and any Saved Sessions already kept from this session stay untouched.",10,C.muted)
    body:SetPoint("TOPLEFT",20,-100);body:SetWidth(470);body:SetWordWrap(true)
    local cancel=Button(f,"Keep",88,32,false);cancel:SetPoint("BOTTOMLEFT",20,20);cancel:SetScript("OnClick",function()shade:Hide()end)
    local del=Button(f,"Delete Log",104,32,false);del:SetPoint("BOTTOMRIGHT",-20,20);del.label:SetTextColor(1.0,0.55,0.55)
    del:SetScript("OnClick",function()
        if RPAL.DeleteScene then RPAL.DeleteScene(scene.id) end
        shade:Hide();RPAL.RefreshChronicle()
    end)
end

local function RenderScenes(f)
    local y=0
    f._rpalSessionCreateArcButtonCount=0
    y=y+ChronicleExplainer(f.content,y,"SESSION HISTORY","Automatic private session logs: where you were, who was there, how long it lasted and what ODYN tracked. Delete these whenever you want to tidy the history.","PRIVATE LOG")
    local scenes=RPAL.GetScenes and RPAL.GetScenes() or {}
    if #scenes==0 then
        local t=Text(f.content,"Start a Session. ODYN will automatically remember the place, people, interactions and Renown while you roleplay.",12,C.muted,DISPLAY)
        t:SetPoint("TOP",0,-y-45);t:SetWidth(650);t:SetJustifyH("CENTER");t:SetWordWrap(true)
        return y+150
    end
    for _,scene in ipairs(scenes) do
        local currentScene=scene
        local r=Row(f.content,y,96)
        local badge=Text(r,"AUTO LOG",8,C.muted);badge:SetPoint("TOPLEFT",14,-12)
        local title=Text(r,scene.title or "A Session",14,C.pale,DISPLAY);title:SetPoint("TOPLEFT",14,-31);title:SetWidth(455);title:SetWordWrap(false)
        local meta=Text(r,(scene.zone or "Unknown Place").."  •  "..DateText(scene.endedAt or scene.startedAt),9,C.muted);meta:SetPoint("TOPLEFT",14,-55)
        local summary=Text(r,SceneSummaryLine(scene),9,C.gold);summary:SetPoint("TOPLEFT",14,-75)

        local keep=Button(r,"Keep as Saved Session",148,28,true);keep:SetPoint("TOPRIGHT",-12,-12)
        keep:SetScript("OnClick",function()
            RPAL.CreateMomentFromScene(currentScene,currentScene.title,currentScene.note or "")
            activeTab="Moments";RPAL.RefreshChronicle()
        end)
        local arc=Button(r,"Create Arc",82,26,false);arc:SetPoint("BOTTOMRIGHT",-154,10)
        arc:SetScript("OnClick",function()
            local ok,result=RPAL.ConvertSessionToStoryArc and RPAL.ConvertSessionToStoryArc(currentScene)
            if not ok then print("|cffff7777ODYN:|r "..tostring(result or "Could not create Story Arc."));return end
            activeTab="Arcs";RPAL.RefreshChronicle();if RPAL.OpenStoryArcBoard then RPAL.OpenStoryArcBoard(result) end
        end)
        local view=Button(r,"View",58,26,false);view:SetPoint("BOTTOMRIGHT",-90,10);view:SetScript("OnClick",function()RPAL.OpenSceneRecap(currentScene)end)
        local del=Button(r,"Delete",64,26,false);del:SetPoint("BOTTOMRIGHT",-12,10);del:SetScript("OnClick",function()ConfirmDeleteScene(currentScene)end)
        r._rpalCreateArcButton=arc
        f._rpalSessionCreateArcButtonCount=f._rpalSessionCreateArcButtonCount+1
        y=y+104
    end
    return y
end

local function ConfirmDeleteStoredScene(scene)
    if not scene then return end
    local shade,f=Modal("DISCARD PAUSED SESSION",540,270)
    RaiseChronicleModal(shade,f)
    local title=Text(f,(scene.title and scene.title~="") and scene.title or ("Session in "..tostring(scene.zone or "Unknown Place")),15,C.pale,DISPLAY)
    title:SetPoint("TOPLEFT",20,-62);title:SetWidth(490)
    local body=Text(f,"This permanently removes the paused Session record and its resumable route, people and interaction history. Renown already awarded elsewhere is not rolled back.",10,C.muted)
    body:SetPoint("TOPLEFT",20,-100);body:SetWidth(490);body:SetWordWrap(true)
    local cancel=Button(f,"Keep Stored",104,32,false);cancel:SetPoint("BOTTOMLEFT",20,20);cancel:SetScript("OnClick",function()shade:Hide()end)
    local discard=Button(f,"Discard",96,32,false);discard:SetPoint("BOTTOMRIGHT",-20,20);discard.label:SetTextColor(C.red[1],C.red[2],C.red[3],1)
    discard:SetScript("OnClick",function()
        if RPAL.DeleteStoredScene then RPAL.DeleteStoredScene(scene.id) end
        shade:Hide();RPAL.RefreshChronicle()
    end)
end

local function ConfirmEndStoredScene(scene)
    if not scene then return nil end
    local shade,f=Modal("END PAUSED SESSION",560,285)
    RaiseChronicleModal(shade,f)
    shade._rpalStoredSessionDecisionMode="end-confirm"
    shade._rpalStoredSceneID=scene.id
    local title=Text(f,(scene.title and scene.title~="") and scene.title or ("Session in "..tostring(scene.zone or "Unknown Place")),15,C.pale,DISPLAY)
    title:SetPoint("TOPLEFT",20,-62);title:SetWidth(510);title:SetWordWrap(false)
    local confirmationText="Are you sure you want to end this paused Session? It will be finalized into Session History with its recorded active time, route, people, interactions and Renown intact."
    shade._rpalConfirmationText=confirmationText
    local body=Text(f,confirmationText,10,C.muted)
    body:SetPoint("TOPLEFT",20,-101);body:SetWidth(510);body:SetWordWrap(true)
    local keep=Button(f,"Keep Paused",108,32,false);keep:SetPoint("BOTTOMLEFT",20,20);keep:SetScript("OnClick",function()shade:Hide()end)
    local finish=Button(f,"End & Record",118,32,true);finish:SetPoint("BOTTOMRIGHT",-20,20);finish.label:SetTextColor(C.red[1],C.red[2],C.red[3],1)
    finish:SetScript("OnClick",function()
        shade:Hide()
        if icSessionChoicePrompt and tostring(icSessionChoicePrompt._rpalPendingSceneID or "")==tostring(scene.id or "") then icSessionChoicePrompt:Hide() end
        local ok,result=RPAL.EndStoredScene and RPAL.EndStoredScene(scene.id)
        if not ok then print("|cffff7777ODYN:|r "..tostring(result or "Could not end paused Session."));return end
        activeTab="Scenes"
        if RPAL.RefreshChronicle then RPAL.RefreshChronicle() end
    end)
    shade._rpalKeepPausedButton=keep;shade._rpalEndStoredButton=finish
    return shade
end
RPAL.ConfirmEndStoredScene=ConfirmEndStoredScene

local function RenderStoredScenes(f)
    local y=0
    f._rpalStoredEndButtonCount=0
    f._rpalStoredCreateArcButtonCount=0
    y=y+ChronicleExplainer(f.content,y,"PAUSED SESSIONS","Sessions kept intact for later. Their active timer is frozen while stored; resume or end any one here. An OOC pause asks whether to continue or start fresh when TRP returns to IC.","RESUMABLE")
    local stored=RPAL.GetStoredScenes and RPAL.GetStoredScenes() or {}
    if #stored==0 then
        local t=Text(f.content,"No paused Sessions are stored. Choose Pause & Store when ending a Session or when ODYN notices that you moved OOC.",12,C.muted,DISPLAY)
        t:SetPoint("TOP",0,-y-45);t:SetWidth(650);t:SetJustifyH("CENTER");t:SetWordWrap(true)
        return y+150
    end

    for _,scene in ipairs(stored) do
        local storedScene=scene
        local r=Row(f.content,y,106)
        local badge=Text(r,"PAUSED & READY",8,C.gold);badge:SetPoint("TOPLEFT",14,-12)
        local titleText=(scene.title and scene.title~="") and scene.title or ("Session in "..tostring(scene.zone or "Unknown Place"))
        local title=Text(r,titleText,14,C.pale,DISPLAY);title:SetPoint("TOPLEFT",14,-31);title:SetWidth(485);title:SetWordWrap(false)
        local pausedAt=scene.pausedAt or scene.lastPausedAt or scene.startedAt
        local meta=Text(r,(scene.zone or "Unknown Place").."  •  paused "..DateText(pausedAt),9,C.muted);meta:SetPoint("TOPLEFT",14,-56)
        local summary=Text(r,SceneSummaryLine(scene).."  •  resumed "..tostring(tonumber(scene.resumeCount) or 0).."x",9,C.gold);summary:SetPoint("TOPLEFT",14,-78)

        local resume=Button(r,"Resume",84,30,true);resume:SetPoint("TOPRIGHT",-12,-13)
        resume:SetScript("OnClick",function()
            local ok,err=RPAL.ResumeStoredScene and RPAL.ResumeStoredScene(storedScene.id)
            if ok then activeTab="Scenes" else print("|cffff7777ODYN:|r "..tostring(err or "Could not resume Session.")) end
            RPAL.RefreshChronicle()
        end)
        local arc=Button(r,"Arc Draft",82,26,false);arc:SetPoint("BOTTOMRIGHT",-204,11)
        arc:SetScript("OnClick",function()
            local ok,result=RPAL.ConvertSessionToStoryArc and RPAL.ConvertSessionToStoryArc(storedScene)
            if not ok then print("|cffff7777ODYN:|r "..tostring(result or "Could not create Story Arc."));return end
            activeTab="Arcs";RPAL.RefreshChronicle();if RPAL.OpenStoryArcBoard then RPAL.OpenStoryArcBoard(result) end
        end)
        f._rpalStoredCreateArcButtonCount=f._rpalStoredCreateArcButtonCount+1
        local finish=Button(r,"End Session",92,26,false);finish:SetPoint("BOTTOMRIGHT",-104,11);finish.label:SetTextColor(C.red[1],C.red[2],C.red[3],1)
        finish:SetScript("OnClick",function()ConfirmEndStoredScene(storedScene)end)
        f._rpalStoredEndButtonCount=f._rpalStoredEndButtonCount+1
        local discard=Button(r,"Discard",84,26,false);discard:SetPoint("BOTTOMRIGHT",-12,11);discard.label:SetTextColor(C.red[1],C.red[2],C.red[3],1)
        discard:SetScript("OnClick",function()ConfirmDeleteStoredScene(storedScene)end)
        r._rpalEndButton=finish;r._rpalCreateArcButton=arc
        y=y+114
    end
    return y
end

local RELATIONS={"Acquaintance","Friend","Trusted","Rival","Mentor","Family","Enemy","Complicated"}
local function OpenBondEditor(bond)
    if not bond then return end
    local shade,f=Modal("BOND — "..tostring(bond.name or "Character"),620,490)
    RaiseChronicleModal(shade,f)
    local l=Text(f,"WHAT ARE THEY TO THIS CHARACTER?",8,C.muted);l:SetPoint("TOPLEFT",20,-58)
    local chosen=bond.relationship or "Acquaintance"
    local buttons={}
    for i,name in ipairs(RELATIONS) do
        local relationName=name
        local b=Button(f,relationName,132,30,false);local col=(i-1)%4;local row=math.floor((i-1)/4);b:SetPoint("TOPLEFT",20+(col*145),-82-(row*38));buttons[relationName]=b
        b:SetScript("OnClick",function()chosen=relationName;for n,x in pairs(buttons)do x.label:SetTextColor(unpack(n==chosen and C.gold or C.pale))end end)
        if relationName==chosen then b.label:SetTextColor(unpack(C.gold)) end
    end
    local nlab=Text(f,"PRIVATE NOTE",8,C.muted);nlab:SetPoint("TOPLEFT",20,-178)
    local note=CreateFrame("EditBox",nil,f,"BackdropTemplate");note:SetPoint("TOPLEFT",20,-196);note:SetSize(580,180);Backdrop(note,C.panel2,C.border);note:SetFont(FONT,Scaled(11),"");note:SetTextColor(C.pale[1],C.pale[2],C.pale[3]);note:SetTextInsets(10,10,10,10);note:SetMultiLine(true);note:SetAutoFocus(false);note:SetText(bond.note or "")
    local save=Button(f,"Save Bond",110,34,true);save:SetPoint("BOTTOMRIGHT",-20,20);save:SetScript("OnClick",function()RPAL.UpdateBond(bond.name,chosen,note:GetText());shade:Hide();RPAL.RefreshChronicle()end)
end

local function OpenCampaignReview(record)
    if type(record)~="table" then return end
    local shade,f=Modal("CAMPAIGN REVIEW",820,640)
    RaiseChronicleModal(shade,f)

    local host=(record.hostTRP and record.hostTRP~="") and record.hostTRP or record.host or "Unknown DM"
    local kicker=Text(f,"CONCLUDED CAMPAIGN  •  READ ONLY",8,C.gold);kicker:SetPoint("TOPLEFT",22,-57)
    local title=Text(f,tostring(record.name or "Campaign"),22,C.pale,DISPLAY);title:SetPoint("TOPLEFT",22,-77);title:SetWidth(650);title:SetWordWrap(false)
    local meta=Text(f,"Hosted by "..tostring(host).."  •  "..DateText(record.concludedAt),9,C.muted);meta:SetPoint("TOPLEFT",22,-108);meta:SetWidth(650);meta:SetWordWrap(false)
    local rule=f:CreateTexture(nil,"ARTWORK");rule:SetTexture("Interface\\Buttons\\WHITE8X8");rule:SetHeight(1);rule:SetPoint("TOPLEFT",22,-132);rule:SetPoint("TOPRIGHT",-22,-132);rule:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],0.28)

    local function StatCard(x,y,w,label,value,sub)
        local card=CreateFrame("Frame",nil,f,"BackdropTemplate");card:SetPoint("TOPLEFT",x,y);card:SetSize(w or 176,82);Backdrop(card,C.panel2,C.border)
        local lab=Text(card,label,7.5,C.gold);lab:SetPoint("TOPLEFT",12,-10);lab:SetWidth((w or 176)-24);lab:SetWordWrap(false)
        local val=Text(card,tostring(value or "—"),23,C.pale,DISPLAY);val:SetPoint("BOTTOMLEFT",12,22);val:SetWidth((w or 176)-24);val:SetWordWrap(false)
        if sub then local hint=Text(card,sub,7,C.muted);hint:SetPoint("BOTTOMLEFT",12,8);hint:SetWidth((w or 176)-24);hint:SetWordWrap(false) end
        return card
    end

    local gap=10;local cardW=185
    StatCard(22,-148,cardW,"SESSIONS",math.max(1,tonumber(record.sessionCount) or 0),"Campaign sessions")
    StatCard(22+cardW+gap,-148,cardW,"ACTIVE TIME",FormatDuration(record.duration or 0),"Across all sessions")
    StatCard(22+((cardW+gap)*2),-148,cardW,"CAMPAIGN RENOWN","+"..tostring(record.renownEarned or 0),"Earned by this character")
    StatCard(22+((cardW+gap)*3),-148,cardW,"PLAYERS",tostring(#(record.participants or {})),"Characters involved")

    local section=Text(f,"CAMPAIGN OUTCOME",9,C.gold);section:SetPoint("TOPLEFT",22,-246)
    local outcomeW=176
    StatCard(22,-266,outcomeW,"NPCs ENCOUNTERED",record.npcsInteracted or 0,"Unique deployed NPCs")
    StatCard(22+outcomeW+10,-266,outcomeW,"NPCs DEFEATED",record.npcsKilled or 0,"Reduced to 0 HP")
    StatCard(22+((outcomeW+10)*2),-266,outcomeW,"NPCs SAVED",record.npcsSaved or 0,"Marked saved by the DM")
    StatCard(22+((outcomeW+10)*3),-266,outcomeW,"ROLL RANGE",((record.rollMin~=nil and tostring(record.rollMin) or "—").."  →  "..(record.rollMax~=nil and tostring(record.rollMax) or "—")),"Lowest → highest")

    local peopleTitle=Text(f,"PLAYED WITH",9,C.gold);peopleTitle:SetPoint("TOPLEFT",22,-367)
    local peoplePanel=CreateFrame("Frame",nil,f,"BackdropTemplate");peoplePanel:SetPoint("TOPLEFT",22,-388);peoplePanel:SetPoint("BOTTOMRIGHT",-22,60);Backdrop(peoplePanel,C.panel,C.border)
    local participants=record.participants or {}
    if #participants==0 then
        local empty=Text(peoplePanel,"No participant roster was recorded for this Campaign.",10,C.muted);empty:SetPoint("TOPLEFT",14,-16)
    else
        local scroll=CreateFrame("ScrollFrame",nil,peoplePanel,"UIPanelScrollFrameTemplate");scroll:SetPoint("TOPLEFT",8,-8);scroll:SetPoint("BOTTOMRIGHT",-28,8)
        if RPAL.SkinScrollFrame then RPAL.SkinScrollFrame(scroll) end
        local content=CreateFrame("Frame",nil,scroll);content:SetSize(700,math.max(1,#participants*38));scroll:SetScrollChild(content)
        for i,p in ipairs(participants) do
            local row=CreateFrame("Frame",nil,content,"BackdropTemplate");row:SetPoint("TOPLEFT",0,-((i-1)*38));row:SetPoint("TOPRIGHT",0,-((i-1)*38));row:SetHeight(32);Backdrop(row,C.panel2,C.border)
            local nm=(p.trp and p.trp~="") and p.trp or p.name or "Unknown Character"
            local name=Text(row,nm,10,C.pale,DISPLAY);name:SetPoint("LEFT",10,0);name:SetWidth(440);name:SetWordWrap(false)
            local role=Text(row,p.isHost and "DM / HOST" or "PARTICIPANT",7.5,p.isHost and C.gold or C.muted);role:SetPoint("RIGHT",-10,0);role:SetWidth(140);role:SetJustifyH("RIGHT")
        end
    end

    local readonly=Text(f,"This Campaign memory is read-only. Pinning only controls whether it appears on your public profile.",8,C.muted);readonly:SetPoint("BOTTOMLEFT",22,24);readonly:SetWidth(620);readonly:SetWordWrap(false)
    local pin=Button(f,record.pinned and "Unpin from Profile" or "Pin to Profile",136,30,record.pinned);pin:SetPoint("BOTTOMRIGHT",-22,18)
    pin:SetScript("OnClick",function()
        local ok,err=RPAL.SetDMCampaignPinned(record.id,not record.pinned)
        if not ok then print("|cffff7777ODYN:|r "..tostring(err)) return end
        shade:Hide();RPAL.RefreshChronicle()
    end)
end

local function RenderCampaigns(f)
    local records=RPAL.GetDMCampaignRecords and RPAL.GetDMCampaignRecords() or {}
    local y=0
    y=y+ChronicleExplainer(f.content,y,"COMPLETED CAMPAIGNS","Campaigns concluded through the DM Kit live here as permanent, read-only memories. Open a Campaign to review who took part and how the adventure unfolded. Up to three can be pinned to your public profile.","READ-ONLY CAMPAIGN")
    if #records==0 then
        local t=Text(f.content,"No concluded Campaigns yet. A Campaign appears here only after its DM uses Conclude Campaign — stopping or pausing a Campaign Session does not finish it.",11,C.muted,DISPLAY);t:SetPoint("TOP",0,-86);t:SetWidth(650);t:SetJustifyH("CENTER");t:SetWordWrap(true);return math.max(y,185)
    end
    for _,record in ipairs(records) do
        local current=record;local r=Row(f.content,y,108)
        local tag=Text(r,"COMPLETED CAMPAIGN",7,C.gold);tag:SetPoint("TOPLEFT",14,-10)
        local title=Text(r,tostring(record.name or "Campaign"),13,C.pale,DISPLAY);title:SetPoint("TOPLEFT",14,-27);title:SetWidth(445);title:SetWordWrap(false)
        local host=(record.hostTRP and record.hostTRP~="") and record.hostTRP or record.host or "Unknown DM"
        local meta=Text(r,"Hosted by "..tostring(host).."  •  "..DateText(record.concludedAt).."  •  "..FormatDuration(record.duration or 0),8,C.muted);meta:SetPoint("TOPLEFT",14,-51);meta:SetWidth(500);meta:SetWordWrap(false)
        local summary=Text(r,tostring(math.max(1,tonumber(record.sessionCount) or 0)).." sessions  •  "..tostring(record.npcsInteracted or 0).." NPCs  •  Rolls "..(record.rollMin~=nil and tostring(record.rollMin) or "—").."–"..(record.rollMax~=nil and tostring(record.rollMax) or "—").."  •  +"..tostring(record.renownEarned or 0).." Renown",7.5,C.gold)
        summary:SetPoint("BOTTOMLEFT",14,11);summary:SetWidth(505);summary:SetWordWrap(false)
        local view=Button(r,"View",66,28,true);view:SetPoint("TOPRIGHT",-92,-13);view:SetScript("OnClick",function()OpenCampaignReview(current)end)
        local pin=Button(r,record.pinned and "Unpin" or "Pin",72,28,record.pinned);pin:SetPoint("TOPRIGHT",-12,-13);pin:SetScript("OnClick",function()local ok,err=RPAL.SetDMCampaignPinned(current.id,not current.pinned);if not ok then print("|cffff7777ODYN:|r "..tostring(err)) end;RPAL.RefreshChronicle()end)
        y=y+116
    end
    return y
end

local function RenderBonds(f)
    local bonds=RPAL.GetBonds and RPAL.GetBonds() or {}
    if #bonds==0 then return Empty(f.content,"Bonds grow automatically from tracked Sessions. There is nothing to maintain before you begin roleplaying.") end

    local searchPanel=CreateFrame("Frame",nil,f.content,"BackdropTemplate");searchPanel:SetPoint("TOPLEFT",0,0);searchPanel:SetSize(CONTENT_W,42);Backdrop(searchPanel,C.panel,C.border)
    local searchLabel=Text(searchPanel,"SEARCH BONDS",7,C.muted);searchLabel:SetPoint("LEFT",12,0)
    local search=CreateFrame("EditBox",nil,searchPanel,"BackdropTemplate");search:SetPoint("LEFT",105,0);search:SetSize(420,28);Backdrop(search,C.panel2,C.border)
    search:SetFont(FONT,Scaled(10),"");search:SetTextColor(C.pale[1],C.pale[2],C.pale[3]);search:SetTextInsets(8,8,0,0);search:SetAutoFocus(false);search:SetText(bondSearchText)
    local count=Text(searchPanel,"",8,C.muted);count:SetPoint("RIGHT",-12,0);count:SetWidth(180);count:SetJustifyH("RIGHT")
    local noMatch=Text(f.content,"No Bonds match that search.",11,C.muted,DISPLAY);noMatch:SetPoint("TOP",0,-82);noMatch:SetWidth(650);noMatch:SetJustifyH("CENTER");noMatch:Hide()

    local cards={}
    for _,bond in ipairs(bonds) do
        local currentBond=bond
        local r=CreateFrame("Frame",nil,f.content,"BackdropTemplate");r:SetSize(370,58);Backdrop(r,C.panel2,C.border)
        local name=Text(r,bond.name or "Unknown",11.5,C.pale,DISPLAY);name:SetPoint("TOPLEFT",12,-8);name:SetWidth(270);name:SetWordWrap(false)
        local rel=Text(r,string.upper(bond.relationship or "Acquaintance"),7,C.gold);rel:SetPoint("TOPLEFT",12,-27);rel:SetWidth(270);rel:SetWordWrap(false)
        local meta=Text(r,tostring(bond.scenesTogether or 0).." sessions  •  "..tostring(bond.interactions or 0).." interactions  •  "..DateText(bond.lastSeen),7.2,C.muted);meta:SetPoint("BOTTOMLEFT",12,7);meta:SetWidth(285);meta:SetWordWrap(false)
        local edit=Button(r,"Edit",54,24,false);edit:SetPoint("RIGHT",-8,0);edit:SetScript("OnClick",function()OpenBondEditor(currentBond)end)
        r._rpalBond=bond;cards[#cards+1]=r
    end
    local function Matches(bond,query)
        if query=="" then return true end
        local value=table.concat({bond.name or "",bond.trpName or "",bond.relationship or "",bond.trpRelationship or "",bond.note or ""}," "):lower()
        return value:find(query,1,true)~=nil
    end
    local function ApplySearch()
        local query=tostring(search:GetText() or ""):lower():gsub("^%s+",""):gsub("%s+$","")
        bondSearchText=query
        local shown=0
        for _,card in ipairs(cards) do
            local visible=Matches(card._rpalBond or {},query)
            card:SetShown(visible)
            if visible then
                local col=shown%2;local row=math.floor(shown/2)
                card:ClearAllPoints();card:SetPoint("TOPLEFT",col*380,-50-(row*66))
                shown=shown+1
            end
        end
        local height=50+(math.ceil(shown/2)*66)
        noMatch:SetShown(shown==0)
        count:SetText(tostring(shown).." of "..tostring(#bonds).." Bonds")
        f.content:SetHeight(math.max(1,height+10))
        f._rpalBondVisibleCount=shown
        return height
    end
    search:SetScript("OnTextChanged",ApplySearch)
    search:SetScript("OnEnterPressed",function(self)self:ClearFocus()end)
    search:SetScript("OnEscapePressed",function(self)self:SetText("");self:ClearFocus()end)
    f._rpalBondSearch=search;f._rpalBondCards=cards;f._rpalApplyBondSearch=ApplySearch
    return ApplySearch()
end

local function RenderMoments(f)
    local y=0
    f._rpalSavedSessionViewButtonCount=0
    f._rpalSavedSessionCreateArcButtonCount=0
    y=y+ChronicleExplainer(f.content,y,"SAVED SESSIONS","Hand-picked memories you deliberately keep from Session History. A Saved Session survives even if its original Session log is deleted, and up to three can be pinned to your public profile.","CURATED MEMORY")
    local moments=RPAL.GetMoments and RPAL.GetMoments() or {}
    if #moments==0 then
        local t=Text(f.content,"When a Session matters, use “Keep as Saved Session.” Saved Sessions are the memorable highlights of your character's story — not every RP session.",12,C.muted,DISPLAY)
        t:SetPoint("TOP",0,-y-45);t:SetWidth(650);t:SetJustifyH("CENTER");t:SetWordWrap(true)
        return y+150
    end
    for _,m in ipairs(moments) do
        local currentMoment=m
        local r=Row(f.content,y,104)

        local iconGlow=r:CreateTexture(nil,"BACKGROUND",nil,-1)
        iconGlow:SetTexture(SPARK);iconGlow:SetBlendMode("ADD");iconGlow:SetSize(46,46);iconGlow:SetPoint("LEFT",8,0)
        iconGlow:SetVertexColor(C.gold[1],C.gold[2],C.gold[3],m.pinned and 0.18 or 0.07)
        local icon=r:CreateTexture(nil,"ARTWORK")
        icon:SetTexture(RPAL.ICONS.trophy);icon:SetSize(30,30);icon:SetPoint("LEFT",16,0)
        icon:SetVertexColor((m.pinned and C.gold or C.muted)[1],(m.pinned and C.gold or C.muted)[2],(m.pinned and C.gold or C.muted)[3],0.96)

        local title=Text(r,m.title or "A Saved Session",13,C.pale,DISPLAY)
        title:SetPoint("TOP",r,"TOP",-26,-11);title:SetWidth(535);title:SetJustifyH("CENTER");title:SetWordWrap(false)

        local badge=Text(r,m.pinned and "PINNED TO PROFILE" or "PRIVATE MEMORY",8,m.pinned and C.gold or C.muted)
        badge:SetPoint("TOP",r,"TOP",-26,-31);badge:SetWidth(535);badge:SetJustifyH("CENTER")

        local body=Text(r,(m.text and m.text~="") and m.text or "A small memory from the session.",9,C.muted)
        body:SetPoint("TOP",r,"TOP",-26,-50);body:SetWidth(535);body:SetJustifyH("CENTER");body:SetWordWrap(true)

        local savedActive,savedPresent=0,0
        if #(m.peopleDetails or {})>0 then
            for _,person in ipairs(m.peopleDetails or {}) do
                savedPresent=savedPresent+1
                if person.activeRP or person.met or (tonumber(person.interactions) or 0)>0 then savedActive=savedActive+1 end
            end
        else
            savedPresent=#(m.people or {})
        end
        local peopleMeta=(savedPresent>0) and ("  •  "..tostring(savedActive).." active RP / "..tostring(savedPresent).." present") or ""
        local meta=Text(r,(m.zone or "")..((m.zone and m.zone~="") and "  •  " or "")..DateText(m.at)..peopleMeta,8,C.muted)
        meta:SetPoint("BOTTOM",r,"BOTTOM",-26,10);meta:SetWidth(535);meta:SetJustifyH("CENTER")

        local pin=Button(r,m.pinned and "Unpin" or "Pin",68,28,m.pinned);pin:SetPoint("TOPRIGHT",-12,-12);pin:SetScript("OnClick",function()local ok,err=RPAL.SetMomentPinned(currentMoment.id,not currentMoment.pinned);if not ok then print("|cffff7777ODYN:|r "..tostring(err)) end;RPAL.RefreshChronicle()end)
        local arc=Button(r,"Arc",62,28,false);arc:SetPoint("BOTTOMRIGHT",-164,10)
        arc:SetScript("OnClick",function()
            local ok,result=RPAL.ConvertSessionToStoryArc and RPAL.ConvertSessionToStoryArc(currentMoment)
            if not ok then print("|cffff7777ODYN:|r "..tostring(result or "Could not create Story Arc."));return end
            activeTab="Arcs";RPAL.RefreshChronicle();if RPAL.OpenStoryArcBoard then RPAL.OpenStoryArcBoard(result) end
        end)
        local view=Button(r,"View",68,28,false);view:SetPoint("BOTTOMRIGHT",-88,10);view:SetScript("OnClick",function()if RPAL.OpenSavedSessionView then RPAL.OpenSavedSessionView(currentMoment) end end)
        f._rpalSavedSessionViewButtonCount=f._rpalSavedSessionViewButtonCount+1
        local del=Button(r,"Delete",68,28,false);del:SetPoint("BOTTOMRIGHT",-12,10);del:SetScript("OnClick",function()RPAL.DeleteMoment(currentMoment.id);RPAL.RefreshChronicle()end)
        r._rpalViewButton=view
        r._rpalCreateArcButton=arc
        f._rpalSavedSessionCreateArcButtonCount=f._rpalSavedSessionCreateArcButtonCount+1
        y=y+112
    end
    return y
end

local function OpenArcCreator()
    local shade,f=Modal("NEW STORY ARC",620,360)
    RaiseChronicleModal(shade,f)
    local l1=Text(f,"WHAT IS YOUR CHARACTER TRYING TO DO?",8,C.muted);l1:SetPoint("TOPLEFT",20,-60)
    local title=CreateFrame("EditBox",nil,f,"BackdropTemplate");title:SetPoint("TOPLEFT",20,-80);title:SetSize(580,38);Backdrop(title,C.panel2,C.border);title:SetFont(FONT,Scaled(12),"");title:SetTextColor(C.pale[1],C.pale[2],C.pale[3]);title:SetTextInsets(10,10,0,0);title:SetAutoFocus(true)
    local l2=Text(f,"OPTIONAL NOTE",8,C.muted);l2:SetPoint("TOPLEFT",20,-138)
    local note=CreateFrame("EditBox",nil,f,"BackdropTemplate");note:SetPoint("TOPLEFT",20,-158);note:SetSize(580,112);Backdrop(note,C.panel2,C.border);note:SetFont(FONT,Scaled(11),"");note:SetTextColor(C.pale[1],C.pale[2],C.pale[3]);note:SetTextInsets(10,10,10,10);note:SetMultiLine(true);note:SetAutoFocus(false)
    local create=Button(f,"Create & Open",116,34,true);create:SetPoint("BOTTOMRIGHT",-20,20);create:SetScript("OnClick",function()
        local ok,result=RPAL.CreateStoryArc(title:GetText(),note:GetText())
        if ok then shade:Hide();RPAL.RefreshChronicle();if RPAL.OpenStoryArcBoard then RPAL.OpenStoryArcBoard(result) end
        else print("|cffff7777ODYN:|r "..tostring(result)) end
    end)
end

local function RenderArcs(f)
    if RPAL.RenderStoryArcFiles then
        return RPAL.RenderStoryArcFiles(f.content,function()
            if RPAL.RefreshChronicle then RPAL.RefreshChronicle() end
        end)
    end
    local y=0
    local add=Button(f.content,"+ New Goal",108,30,true);add:SetPoint("TOPLEFT",0,0);add:SetScript("OnClick",OpenArcCreator);y=40
    local arcs=RPAL.GetStoryArcs and RPAL.GetStoryArcs() or {}
    if #arcs==0 then local t=Text(f.content,"Story Arcs are deliberately simple: one goal, three meaningful steps. No spreadsheets.",12,C.muted,DISPLAY);t:SetPoint("TOP",0,-85);t:SetWidth(640);t:SetJustifyH("CENTER");return 180 end
    for _,arc in ipairs(arcs) do
        local currentArc=arc
        local r=Row(f.content,y,94)
        local title=Text(r,(arc.completed and "✓  " or "")..tostring(arc.title or "Goal"),13,arc.completed and C.green or C.pale,DISPLAY);title:SetPoint("TOPLEFT",14,-12);title:SetWidth(465)
        local note=Text(r,arc.note or "",9,C.muted);note:SetPoint("TOPLEFT",14,-36);note:SetWidth(465);note:SetWordWrap(true)
        local progress=Text(r,"STEPS  "..tostring(arc.progress or 0).." / "..tostring(arc.target or 3),9,C.gold);progress:SetPoint("BOTTOMLEFT",14,10)
        local step=Button(r,"+ Step",68,28,true);step:SetPoint("TOPRIGHT",-88,-12);step:SetScript("OnClick",function()RPAL.AdvanceStoryArc(currentArc.id,1);RPAL.RefreshChronicle()end)
        local done=Button(r,arc.completed and "Reopen" or "Done",68,28,false);done:SetPoint("TOPRIGHT",-12,-12);done:SetScript("OnClick",function()RPAL.ToggleStoryArcComplete(currentArc.id);RPAL.RefreshChronicle()end)
        local del=Button(r,"Delete",68,24,false);del:SetPoint("BOTTOMRIGHT",-12,10);del:SetScript("OnClick",function()RPAL.DeleteStoryArc(currentArc.id);RPAL.RefreshChronicle()end)
        y=y+102
    end
    return y
end

local function SeasonTaskTimeText(snap)
    if not snap then return "NO SEASON DATA" end
    if snap.active then
        local seconds=math.max(0,math.floor(tonumber(snap.remaining) or 0))
        local days=math.floor(seconds/86400);local hours=math.floor((seconds%86400)/3600)
        return "SEASON ENDS IN "..tostring(days).."D "..tostring(hours).."H"
    elseif snap.ended then
        return "SEASON ENDED"
    end
    local seconds=math.max(0,math.floor(tonumber(snap.startsIn) or 0))
    local days=math.floor(seconds/86400);local hours=math.floor((seconds%86400)/3600)
    return "BEGINS IN "..tostring(days).."D "..tostring(hours).."H"
end

local function SeasonQuestCard(parent,quest,y,scope,bucket,refresh)
    local row=CreateFrame("Button",nil,parent,"BackdropTemplate")
    row:SetPoint("TOPLEFT",12,y);row:SetPoint("TOPRIGHT",-12,y);row:SetHeight(scope=="weekly" and 60 or 66)
    local complete=quest and quest.complete
    Backdrop(row,complete and {0.035,0.080,0.055,0.98} or C.panel2,complete and C.green or C.border)
    local marker=CreateFrame("Frame",nil,row,"BackdropTemplate");marker:SetSize(18,18);marker:SetPoint("TOPLEFT",10,-10);Backdrop(marker,{0.018,0.018,0.020,0.98},complete and C.green or C.gold)
    local tick=marker:CreateTexture(nil,"OVERLAY");tick:SetTexture("Interface\\Buttons\\UI-CheckBox-Check");tick:SetSize(23,23);tick:SetPoint("CENTER");tick:SetShown(complete and true or false)
    local title=Text(row,(quest and quest.title) or "Season Task",9.2,complete and C.green or C.pale,DISPLAY);title:SetPoint("TOPLEFT",36,-9);title:SetPoint("RIGHT",-74,0);title:SetJustifyH("LEFT");title:SetWordWrap(false)
    local desc=Text(row,(quest and quest.description) or "",7.8,C.muted);desc:SetPoint("TOPLEFT",36,-29);desc:SetPoint("RIGHT",-12,0);desc:SetJustifyH("LEFT");desc:SetWordWrap(false)
    local progress=math.max(0,math.floor(tonumber(quest and quest.progress) or 0));local target=math.max(1,math.floor(tonumber(quest and quest.target) or 1))
    local prog=Text(row,tostring(progress).." / "..tostring(target),8.2,complete and C.green or C.pale);prog:SetPoint("TOPRIGHT",-10,-9)
    local xp=Text(row,"+"..tostring(math.max(0,math.floor(tonumber(quest and quest.xp) or 0))).." SXP",7.2,C.gold);xp:SetPoint("BOTTOMRIGHT",-10,8)
    row:SetScript("OnEnter",function(self)
        self:SetBackdropBorderColor(C.gold[1],C.gold[2],C.gold[3],.95)
        if not complete and (tonumber(bucket and bucket.rerolls) or 0)>0 and SetCursor then SetCursor("INTERACT_CURSOR") end
    end)
    row:SetScript("OnLeave",function(self)
        if ResetCursor then ResetCursor() end
        local border=complete and C.green or C.border;self:SetBackdropBorderColor(border[1],border[2],border[3],border[4] or 1)
    end)
    row:SetScript("OnClick",function()
        if complete then return end
        if (tonumber(bucket and bucket.rerolls) or 0)<=0 then print("|cffffcc66ODYN Season:|r No "..tostring(scope).." rerolls left.");return end
        local ok,msg=RPAL.RerollSeasonQuest and RPAL.RerollSeasonQuest(scope,quest.id)
        if not ok and msg then print("|cffff7777ODYN Season:|r "..tostring(msg)) end
        if ok and refresh then refresh() end
    end)
end

local function RenderSeasonTaskColumn(parent,bucket,title,scope,x,width)
    local panel=CreateFrame("Frame",nil,parent,"BackdropTemplate");panel:SetPoint("TOPLEFT",x,-86);panel:SetSize(width,430);Backdrop(panel,C.panel,C.border)
    local h=Text(panel,title,13,C.gold,DISPLAY);h:SetPoint("TOPLEFT",14,-13)
    local rerolls=math.max(0,math.floor(tonumber(bucket and bucket.rerolls) or 0))
    local meta=Text(panel,(scope=="daily" and "Resets daily" or "Resets weekly").."  •  "..tostring(rerolls).." reroll"..(rerolls==1 and "" or "s").." left",7.7,C.muted);meta:SetPoint("TOPLEFT",14,-39)
    local hint=Text(panel,"Click an incomplete task to reroll it.",7.2,C.muted);hint:SetPoint("TOPLEFT",14,-56)
    local holder=CreateFrame("Frame",nil,panel);holder:SetPoint("TOPLEFT",6,-78);holder:SetPoint("BOTTOMRIGHT",-6,8)
    local y=-2
    local refresh=function() if RPAL.RefreshChronicle then RPAL.RefreshChronicle() end end
    for _,quest in ipairs((bucket and bucket.quests) or {}) do
        SeasonQuestCard(holder,quest,y,scope,bucket,refresh);y=y-(scope=="weekly" and 66 or 72)
    end
    return panel
end

local function RenderSeasonTasks(f)
    local snap=RPAL.GetSeasonSnapshot and RPAL.GetSeasonSnapshot() or nil
    local y=0
    local top=CreateFrame("Frame",nil,f.content,"BackdropTemplate");top:SetPoint("TOPLEFT",0,0);top:SetPoint("TOPRIGHT",0,0);top:SetHeight(72);Backdrop(top,C.panel,C.gold)
    local title=Text(top,(snap and snap.season and string.upper(tostring(snap.season.name or "SEASON")) or "SEASON").." TASKS",15,C.gold,DISPLAY);title:SetPoint("TOPLEFT",15,-12)
    local subtitle=Text(top,"Daily and Weekly objectives live here. Your reward path and season statistics remain in Seasons.",8.4,C.pale);subtitle:SetPoint("TOPLEFT",15,-37);subtitle:SetPoint("RIGHT",-170,0);subtitle:SetJustifyH("LEFT")
    local timer=Text(top,SeasonTaskTimeText(snap),8.0,C.gold,DISPLAY);timer:SetPoint("TOPRIGHT",-14,-16);timer:SetWidth(160);timer:SetJustifyH("RIGHT")
    if not snap then
        local empty=Text(f.content,"No Season is available for this character.",11,C.muted);empty:SetPoint("TOPLEFT",14,-100);return 160
    end
    if not snap.active then
        local msg=Text(f.content,snap.ended and "Season tasks are archived." or "Season tasks will activate when the season begins.",10,C.muted);msg:SetPoint("TOPLEFT",14,-100)
        return 160
    end
    RenderSeasonTaskColumn(f.content,snap.daily,"DAILY", "daily",0,365)
    RenderSeasonTaskColumn(f.content,snap.weekly,"WEEKLY","weekly",385,365)
    return 535
end

function RPAL.RefreshChronicle()
    ApplyPalette()
    local f=EnsureChronicle()
    local active=RPAL.GetActiveScene and RPAL.GetActiveScene()
    if active then
        f.sceneStatus:SetText("SESSION IN PROGRESS  —  "..tostring(active.zone or "Unknown Place"));f.sceneStatus:SetTextColor(unpack(C.green))
        f.sceneMeta:SetText(SceneSummaryLine(active));f.sceneAction.label:SetText("End Session")
    else
        local storedCount=RPAL.GetStoredSceneCount and RPAL.GetStoredSceneCount() or 0
        f.sceneStatus:SetText("NO SESSION IN PROGRESS");f.sceneStatus:SetTextColor(unpack(C.pale));f.sceneMeta:SetText(storedCount>0 and (tostring(storedCount).." paused Session"..(storedCount==1 and " is" or "s are").." ready to resume.") or "One click starts tracking. No title or setup required.");f.sceneAction.label:SetText("Start Session")
    end
    f.sparkToggle.label:SetText(RPAL.GetSceneSparkEnabled and RPAL.GetSceneSparkEnabled() and "Thoughts: ON" or "Thoughts: OFF")
    for name,b in pairs(f.tabs) do b.label:SetTextColor(unpack(name==activeTab and C.gold or C.pale)) end
    ClearChildren(f.content)
    f.content:SetSize(CONTENT_W,1)
    local height=1
    if activeTab=="Scenes" then height=RenderScenes(f)
    elseif activeTab=="Stored" then height=RenderStoredScenes(f)
    elseif activeTab=="Bonds" then height=RenderBonds(f)
    elseif activeTab=="Moments" then height=RenderMoments(f)
    elseif activeTab=="Campaigns" then height=RenderCampaigns(f)
    elseif activeTab=="Season" then height=RenderSeasonTasks(f)
    else height=RenderArcs(f) end
    f.content:SetHeight(math.max(1,height+10));f.scroll:SetVerticalScroll(0)
end

function RPAL.RefreshSeasonTasks()
    if chronicle and chronicle.IsShown and chronicle:IsShown() and activeTab=="Season" and RPAL.RefreshChronicle then
        RPAL.RefreshChronicle()
    end
end

function RPAL.OpenChronicle(tab)
    ApplyPalette()
    activeTab=tab or activeTab or "Scenes"
    local f=EnsureChronicle()
    local ui=RPAL.UI
    if not ui or not ui.main or not ui.content then
        print("|cffff7777ODYN:|r Chronicle could not open because the main interface is not ready yet.")
        return
    end
    AttachChronicleToCurrentUI(f)
    if ui and ui.main and not ui.main:IsShown() then ui.main:Show() end
    if ui then
        -- Chronicle owns the content surface. Clear mutually exclusive main
        -- modes without rebuilding Overview underneath it first.
        if RPAL.SetMainContentMode then RPAL.SetMainContentMode("chronicle",true) end
        if ui.statistics then ui.statistics:Hide() end
        if ui.ledger then ui.ledger:Hide() end
        if ui.editor then ui.editor:Hide() end
    end
    local wasShown=f:IsShown()
    f:Show()
    RPAL.RefreshChronicle()
    if not wasShown then
        if RPAL.AnimateFrameIn then RPAL.AnimateFrameIn(f,0.18,0) else f:SetAlpha(1) end
    end
    if not f._sceneClockTicker and C_Timer and C_Timer.NewTicker then
        f._sceneClockTicker=C_Timer.NewTicker(1,function()
            if not f:IsShown() then
                if f._sceneClockTicker and f._sceneClockTicker.Cancel then f._sceneClockTicker:Cancel() end
                f._sceneClockTicker=nil
                return
            end
            local scene=RPAL.GetActiveScene and RPAL.GetActiveScene()
            if scene and f.sceneMeta then f.sceneMeta:SetText(SceneSummaryLine(scene)) end
        end)
    elseif not C_Timer or not C_Timer.NewTicker then
        f:SetScript("OnUpdate",function(frame,dt)
            frame._sceneTick=(frame._sceneTick or 0)+(dt or 0);if frame._sceneTick<1 then return end;frame._sceneTick=0
            local scene=RPAL.GetActiveScene and RPAL.GetActiveScene();if scene and frame.sceneMeta then frame.sceneMeta:SetText(SceneSummaryLine(scene)) end
        end)
    end
    if RPAL.RefreshBookChrome then RPAL.RefreshBookChrome(true) end
    return f
end

function RPAL.HideChronicle(skipChromeRefresh)
    local wasShown=chronicle and chronicle:IsShown()
    if wasShown then chronicle:Hide() end
    if wasShown and not skipChromeRefresh and RPAL.RefreshBookChrome then RPAL.RefreshBookChrome(true) end
    return wasShown and true or false
end

function RPAL.ResetChronicleUI()
    if oocSessionPrompt then oocSessionPrompt:Hide();oocSessionPrompt=nil end
    if manualSessionPrompt then manualSessionPrompt:Hide();manualSessionPrompt=nil end
    if icSessionChoicePrompt then icSessionChoicePrompt:Hide();icSessionChoicePrompt=nil end
    if chronicle then
        chronicle:Hide()
        chronicle:SetScript("OnUpdate",nil)
        chronicle:SetParent(nil)
        chronicle=nil
    end
end

-- Chronicle caches theme colors in this chunk.  A full shell rebuild on an
-- actual theme switch is cheap, preserves the selected tab, and prevents its
-- static title/tabs/backdrops from retaining the previous palette.
function RPAL.RefreshChronicleTheme()
    ApplyPalette()
    if not chronicle then return false end
    local wasShown=chronicle:IsShown()
    local tab=activeTab
    RPAL.ResetChronicleUI()
    if wasShown then return RPAL.OpenChronicle(tab) end
    return true
end

function RPAL.IsChronicleEmbeddedOpen()
    return chronicle and chronicle:IsShown() or false
end

-- ------------------------------------------------------------
-- Main header hooks. These are called by UI.lua after the buttons exist.
-- ------------------------------------------------------------

function RPAL.RefreshSceneButtons()
    local ui=RPAL.UI
    if not ui then return end
    local active=RPAL.IsSceneActive and RPAL.IsSceneActive()
    if ui.sceneQuick and ui.sceneQuick.label then
        ui.sceneQuick.label:SetText(active and "End Session" or "Start Session")
        ui.sceneQuick.label:SetTextColor(unpack(active and C.green or C.gold))
        if ui.sceneQuick.SetBackdropBorderColor then ui.sceneQuick:SetBackdropBorderColor(unpack(active and C.green or C.gold)) end
    end
    if RPAL.RefreshBookChrome then RPAL.RefreshBookChrome() end
    if RPAL.UpdateSessionWidget then RPAL.UpdateSessionWidget() end
end

function RPAL.BindSceneButtons(sceneButton,chronicleButton)
    if sceneButton then
        sceneButton:SetScript("OnClick",function()
            if RPAL.IsSceneActive and RPAL.IsSceneActive() then
                if RPAL.RequestEndScene then RPAL.RequestEndScene() end
            else
                RPAL.StartScene()
            end
            RPAL.RefreshSceneButtons()
        end)
        sceneButton:HookScript("OnEnter",function()
            if RPAL.ShowSimpleCursorTooltip then
                RPAL.ShowSimpleCursorTooltip(RPAL.IsSceneActive and RPAL.IsSceneActive() and "Session Options" or "Start Session",RPAL.IsSceneActive and RPAL.IsSceneActive() and "Choose Pause & Store to continue later, or End & Record to finalize the Session and open its recap." or "One click starts tracking the place, people, interactions and Renown.",C.gold,"SESSIONS")
            end
        end)
        sceneButton:HookScript("OnLeave",function()if RPAL.HideCursorTooltip then RPAL.HideCursorTooltip() end end)
    end
    if chronicleButton then
        chronicleButton:SetScript("OnClick",function()RPAL.OpenChronicle("Scenes")end)
        chronicleButton:HookScript("OnEnter",function()if RPAL.ShowSimpleCursorTooltip then RPAL.ShowSimpleCursorTooltip("Chronicle","Resume Paused Sessions, review Session History, keep selected memories, and manage Bonds or Story Arcs.",C.gold,"ODYN") end end)
        chronicleButton:HookScript("OnLeave",function()if RPAL.HideCursorTooltip then RPAL.HideCursorTooltip() end end)
    end
    RPAL.RefreshSceneButtons()
    if RPAL.UpdateSessionWidget then RPAL.UpdateSessionWidget() end
end
