local ADDON_NAME, RPAL = ...
local DM = RPAL.DMKit
if not DM then return end

local WHITE="Interface\\Buttons\\WHITE8X8"
local STATUSBAR="Interface\\TargetingFrame\\UI-StatusBar"
local FONT=STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF"
local drawer,drawerTab,mainFrame,notesFrame,rollFrame,inviteFrame,encounterWidget,initiativeFrame,screenMessageFrame
local promptFrames={}
local W=330
local CloseDMContextMenus

local function Palette()
    local p=RPAL.GetCurrentThemePalette and RPAL.GetCurrentThemePalette()
    if p then return p end
    return {
        gold={.92,.76,.35,1},pale={.93,.88,.78,1},muted={.58,.54,.48,1},
        panel={.09,.06,.035,.98},panel2={.14,.095,.055,.98},border={.42,.29,.14,1},
        main={.055,.035,.02,.99},button={.12,.08,.045,1},buttonHover={.18,.12,.065,1},accent={.55,.37,.14,1}
    }
end

local function Backdrop(f,bg,border,edge)
    if not f or not f.SetBackdrop then return end
    f:SetBackdrop({bgFile=WHITE,edgeFile="Interface\\Tooltips\\UI-Tooltip-Border",tile=false,edgeSize=edge or 11,insets={left=3,right=3,top=3,bottom=3}})
    bg=bg or Palette().panel;border=border or Palette().border
    f:SetBackdropColor(bg[1],bg[2],bg[3],bg[4] or 1)
    f:SetBackdropBorderColor(border[1],border[2],border[3],border[4] or 1)
end

local function Layer(frame,role,offset,fallbackStrata,fallbackLevel)
    if RPAL.ApplyUILayer then
        RPAL.ApplyUILayer(frame,role,offset)
    else
        frame:SetFrameStrata(fallbackStrata or "FULLSCREEN_DIALOG")
        frame:SetFrameLevel(tonumber(fallbackLevel) or 8000)
        frame:SetToplevel(true)
    end
end

local function Text(parent,text,size,color,flags)
    local fs=parent:CreateFontString(nil,"OVERLAY")
    local baseSize=size or 11;local fontFlags=flags or ""
    fs:SetFont(FONT,RPAL.GetScaledFontSize and RPAL.GetScaledFontSize(baseSize) or baseSize,fontFlags)
    if RPAL.RegisterScalableFontObject then RPAL.RegisterScalableFontObject(fs,FONT,baseSize,fontFlags) end
    fs:SetText(text or "")
    color=color or Palette().pale
    fs:SetTextColor(color[1],color[2],color[3],color[4] or 1)
    fs:SetShadowColor(0,0,0,.9);fs:SetShadowOffset(1,-1)
    return fs
end

local function FitLine(fs,text,maxWidth,baseSize,minSize)
    if not fs then return end
    text=tostring(text or "")
    maxWidth=math.max(12,tonumber(maxWidth) or 120)
    baseSize=tonumber(baseSize) or 9
    minSize=tonumber(minSize) or math.max(6,baseSize-2.5)
    fs:SetWidth(maxWidth);fs:SetWordWrap(false)
    if fs.SetNonSpaceWrap then fs:SetNonSpaceWrap(false) end
    if fs.SetMaxLines then fs:SetMaxLines(1) end
    fs:SetText(text)
    local _,_,flags=fs:GetFont();flags=flags or ""
    local size=RPAL.GetScaledFontSize and RPAL.GetScaledFontSize(baseSize) or baseSize
    local min=RPAL.GetScaledFontSize and RPAL.GetScaledFontSize(minSize) or minSize
    while fs.GetStringWidth and fs:GetStringWidth()>maxWidth and size>min do
        size=math.max(min,size-.5);fs:SetFont(FONT,size,flags)
    end
    if fs.GetStringWidth and fs:GetStringWidth()>maxWidth and #text>3 then
        local suffix="...";local lo,hi=1,#text;local best=suffix
        while lo<=hi do
            local mid=math.floor((lo+hi)/2);local candidate=text:sub(1,mid)..suffix;fs:SetText(candidate)
            if fs:GetStringWidth()<=maxWidth then best=candidate;lo=mid+1 else hi=mid-1 end
        end
        fs:SetText(best)
    end
end

local function ApplyButtonVisual(b,hover)
    if not b then return end
    local p=Palette()
    local selected=b._dmSelected or b._dmAccent
    if hover then
        b:SetBackdropColor(p.buttonHover[1],p.buttonHover[2],p.buttonHover[3],1)
        b:SetBackdropBorderColor(p.gold[1],p.gold[2],p.gold[3],1)
    elseif selected then
        Backdrop(b,p.accent,p.gold,8)
    else
        Backdrop(b,p.button,p.border,8)
    end
    if b.text and b.text.SetTextColor then
        local c=p.pale
        b.text:SetTextColor(c[1],c[2],c[3],c[4] or 1)
    end
end

local function SetButtonSelected(b,selected)
    if not b then return end
    b._dmSelected=selected and true or false
    ApplyButtonVisual(b,false)
end

local function Button(parent,label,w,h,accent)
    local b=CreateFrame("Button",nil,parent,"BackdropTemplate")
    b._dmAccent=accent and true or false
    b._dmSelected=false
    b:SetSize(w or 100,h or 28)
    b.text=Text(b,"",10,Palette().pale,"OUTLINE");b.text:SetPoint("CENTER",0,0);FitLine(b.text,label or "",math.max(20,(w or 100)-12),10,7)
    b:SetScript("OnEnter",function(self)ApplyButtonVisual(self,true)end)
    b:SetScript("OnLeave",function(self)ApplyButtonVisual(self,false)end)
    ApplyButtonVisual(b,false)
    if RPAL.AttachButtonMotion then RPAL.AttachButtonMotion(b,0.018) end
    if RPAL.AttachFocusMark then RPAL.AttachFocusMark(b,Palette().gold) end
    return b
end

local function StopDMFade(frame)
    if not frame then return end
    local g=frame._dmFade
    frame._dmFade=nil
    if g and g.Stop then g:Stop() end
end

local function FadeInFrame(frame,duration)
    if not frame then return end
    StopDMFade(frame)
    -- Alpha animations are temporary presentation layers. Keep the frame's
    -- persistent alpha at 1 or it will become invisible when the animation
    -- layer is removed at the end of the fade.
    frame:SetAlpha(1)
    frame:Show()
    local g=frame._dmFadeInGroup
    if not g then
        g=frame:CreateAnimationGroup()
        frame._dmFadeInGroup=g
        local a=g:CreateAnimation("Alpha")
        frame._dmFadeInAnim=a
        a:SetFromAlpha(0)
        a:SetToAlpha(1)
        a:SetSmoothing("OUT")
        g:SetScript("OnFinished",function(self)
            frame:SetAlpha(1)
            if frame._dmFade==self then frame._dmFade=nil end
        end)
        g:SetScript("OnStop",function(self)
            frame:SetAlpha(1)
            if frame._dmFade==self then frame._dmFade=nil end
        end)
    end
    frame._dmFadeInAnim:SetDuration(duration or .14)
    frame._dmFade=g
    g:Play()
end

local function FadeOutHide(frame,duration)
    if not frame or not frame:IsShown() then return end
    StopDMFade(frame)
    frame:SetAlpha(1)
    local g=frame._dmFadeOutGroup
    if not g then
        g=frame:CreateAnimationGroup()
        frame._dmFadeOutGroup=g
        local a=g:CreateAnimation("Alpha")
        frame._dmFadeOutAnim=a
        a:SetFromAlpha(1)
        a:SetToAlpha(0)
        a:SetSmoothing("IN")
        g:SetScript("OnFinished",function(self)
            frame:Hide()
            frame:SetAlpha(1)
            if frame._dmFade==self then frame._dmFade=nil end
        end)
        g:SetScript("OnStop",function(self)
            frame:SetAlpha(1)
            if frame._dmFade==self then frame._dmFade=nil end
        end)
    end
    frame._dmFadeOutAnim:SetDuration(duration or .11)
    frame._dmFade=g
    g:Play()
end

local function Check(parent,label)
    local b=CreateFrame("CheckButton",nil,parent,"UICheckButtonTemplate")
    -- Keep the label parented to the checkbox itself.  The drawer rebuilds its
    -- dynamic controls frequently; parenting the label to the scroll child
    -- left orphaned FontStrings behind after the checkbox was discarded.
    b:SetSize(24,24);b.label=Text(b,label,9.5,Palette().pale);b.label:SetPoint("LEFT",b,"RIGHT",2,1)
    return b
end

local function Input(parent,w,h,multi)
    local e=CreateFrame("EditBox",nil,parent,"BackdropTemplate")
    e._dmInput=true
    e:SetSize(w or 180,h or 28);Backdrop(e,Palette().panel2,Palette().border)
    e:SetFont(FONT,RPAL.GetScaledFontSize and RPAL.GetScaledFontSize(11) or 11,"");if RPAL.RegisterScalableFontObject then RPAL.RegisterScalableFontObject(e,FONT,11,"") end;e:SetTextColor(Palette().pale[1],Palette().pale[2],Palette().pale[3],1);e:SetTextInsets(7,7,5,5);e:SetAutoFocus(false)
    if multi then e:SetMultiLine(true);e:SetJustifyV("TOP") end
    return e
end

local function TitleRule(parent,y,title)
    local p=Palette();local t=Text(parent,string.upper(title),10,p.gold,"OUTLINE");t:SetPoint("TOPLEFT",12,y)
    local r=parent:CreateTexture(nil,"ARTWORK");r:SetTexture(WHITE);r:SetHeight(1);r:SetPoint("LEFT",t,"RIGHT",8,0);r:SetPoint("RIGHT",parent,-12,0);r:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],.25)
    return t
end

local function FormatTime(ts)
    if not ts then return "--:--" end
    return date("%H:%M",ts)
end

local LOG_KIND_LABELS={
    ROLL="ROLL",INITIATIVE="COMBAT",NPC="NPC",HEALTH="HEALTH",PLAYER="PLAYER",
    SCREEN="SCREEN",NOTIFY="NOTICE",TEMPLATE="PRESET",SESSION="SESSION",SYSTEM="SYSTEM",STATUS="STATUS",
}
local function FormatLogLine(entry)
    entry=entry or {}
    local kind=LOG_KIND_LABELS[tostring(entry.kind or "SYSTEM"):upper()] or tostring(entry.kind or "EVENT"):upper()
    return FormatTime(entry.t).."  ·  "..kind.."  ·  "..tostring(entry.text or "")
end

local function ShortName(name)return tostring(name or ""):gsub("%-.*$","")end
local function Same(a,b)
    local function n(v)return tostring(v or ""):lower():gsub("%s+",""):gsub("%-.*$","") end
    return n(a)~="" and n(a)==n(b)
end

local function LocalFullName()
    local n,r=UnitName("player");if not n then return "" end
    r=r or GetRealmName() or "";r=tostring(r):gsub("%s+","")
    return r~="" and n.."-"..r or n
end

local function PlayerList(state,includeHost)
    local list={}
    for name,p in pairs(state and state.participants or {}) do
        if includeHost or not p.isHost then list[#list+1]={name=name,p=p} end
    end
    table.sort(list,function(a,b)
        if (a.p.joinedAt or 0)==(b.p.joinedAt or 0) then return tostring(a.name)<tostring(b.name) end
        return (a.p.joinedAt or 0)<(b.p.joinedAt or 0)
    end)
    return list
end

local RAID_TOKENS={
    [1]="star",[2]="circle",[3]="diamond",[4]="triangle",
    [5]="moon",[6]="square",[7]="cross",[8]="skull",
}

-- Use the same raid-marker token expansion WoW chat uses for {star}, {skull},
-- etc. This avoids atlas/crop mismatches and keeps the marker identical to
-- Blizzard's own chat rendering.
local function RaidMarkerMarkup(index)
    index=math.max(1,math.min(8,tonumber(index) or 1))
    local token="{"..(RAID_TOKENS[index] or "star").."}"
    if C_ChatInfo and C_ChatInfo.ReplaceIconAndGroupExpressions then
        local ok,markup=pcall(C_ChatInfo.ReplaceIconAndGroupExpressions,token)
        if ok and type(markup)=="string" and markup~="" and markup~=token then
            return markup
        end
    end
    return token
end

local function HexColor(hex,fallback)
    local h=tostring(hex or ""):gsub("#","")
    if #h==6 then
        local r,g,b=tonumber(h:sub(1,2),16),tonumber(h:sub(3,4),16),tonumber(h:sub(5,6),16)
        if r and g and b then return {r/255,g/255,b/255,1} end
    end
    return fallback or Palette().gold
end

local function ParticipantPalette(pdata)
    if pdata and RPAL.GetThemePaletteByName and pdata.theme and pdata.theme~="" then
        local ok,value=pcall(RPAL.GetThemePaletteByName,pdata.theme,pdata.themeHex)
        if ok and type(value)=="table" then return value end
    end
    return Palette()
end

local function UnitMatchesName(unit,name)
    if not UnitExists or not UnitExists(unit) then return false end
    local n,r=UnitName(unit);if not n then return false end
    r=r or (GetRealmName and GetRealmName()) or "";r=tostring(r):gsub("%s+","")
    local full=r~="" and (n.."-"..r) or n
    local function norm(v)return tostring(v or ""):lower():gsub("%s+","") end
    return norm(full)==norm(name) or norm(n)==norm(ShortName(name))
end

local function FindParticipantUnit(name)
    for _,unit in ipairs({"player","target","mouseover"}) do if UnitMatchesName(unit,name) then return unit end end
    if IsInRaid and IsInRaid() then
        for i=1,40 do local unit="raid"..i;if UnitMatchesName(unit,name) then return unit end end
    else
        for i=1,4 do local unit="party"..i;if UnitMatchesName(unit,name) then return unit end end
    end
    return nil
end

local function CreateHealthBar(parent,w,h,accent)
    local bar=CreateFrame("StatusBar",nil,parent,"BackdropTemplate")
    bar:SetSize(w or 180,h or 10);bar:SetStatusBarTexture(STATUSBAR);bar:SetMinMaxValues(0,1);bar:SetValue(0)
    local bg=bar:CreateTexture(nil,"BACKGROUND");bg:SetTexture(STATUSBAR);bg:SetAllPoints();bg:SetVertexColor(.055,.06,.07,.95);bar.bg=bg
    local gloss=bar:CreateTexture(nil,"OVERLAY");gloss:SetTexture("Interface\\Buttons\\WHITE8X8");gloss:SetPoint("TOPLEFT",1,-1);gloss:SetPoint("TOPRIGHT",-1,-1);gloss:SetHeight(math.max(1,(h or 10)*.28));gloss:SetVertexColor(1,1,1,.11);bar.gloss=gloss
    local edge=CreateFrame("Frame",nil,parent,"BackdropTemplate");edge:SetPoint("TOPLEFT",bar,-2,2);edge:SetPoint("BOTTOMRIGHT",bar,2,-2);Backdrop(edge,{0,0,0,0},accent or Palette().border,7);edge:EnableMouse(false);bar.edge=edge
    return bar
end

local function SetHealthBar(bar,current,maxHP,accent)
    if not bar then return end
    maxHP=math.max(1,tonumber(maxHP) or 1);current=math.max(0,math.min(maxHP,tonumber(current) or 0))
    bar:SetMinMaxValues(0,maxHP);bar:SetValue(current)
    local c=accent or Palette().gold
    if current/maxHP<=.22 then c={.82,.20,.16,1} end
    bar:SetStatusBarColor(c[1] or .8,c[2] or .65,c[3] or .25,.98)
    if bar.edge and bar.edge.SetBackdropBorderColor then bar.edge:SetBackdropBorderColor(c[1] or .8,c[2] or .65,c[3] or .25,.45) end
end

local function AddParticipantPortrait(row,name,pdata,size)
    size=size or 42
    local holder=CreateFrame("Frame",nil,row,"BackdropTemplate");holder:SetSize(size+4,size+4);Backdrop(holder,{.025,.025,.03,.96},ParticipantPalette(pdata).border,8)
    local portrait=holder:CreateTexture(nil,"ARTWORK");portrait:SetSize(size,size);portrait:SetPoint("CENTER")
    local mask=holder:CreateMaskTexture(nil,"ARTWORK");mask:SetTexture("Interface\\CharacterFrame\\TempPortraitAlphaMask");mask:SetAllPoints(portrait);portrait:AddMaskTexture(mask)
    local unit=FindParticipantUnit(name)
    if unit and SetPortraitTexture then
        portrait:SetTexture("Interface\\CharacterFrame\\TempPortrait");pcall(SetPortraitTexture,portrait,unit)
    else
        portrait:SetTexture("Interface\\Icons\\INV_Misc_QuestionMark")
    end
    local pal=ParticipantPalette(pdata);local accent=pal.gold or HexColor(pdata and pdata.themeHex)
    if RPAL.RenderCombatPortraitCosmetic and pdata and pdata.portraitFrame and pdata.portraitFrame~="Default" then
        pcall(RPAL.RenderCombatPortraitCosmetic,holder,portrait,pdata.portraitFrame,accent,size+2)
    end
    if RPAL.RenderLevelBadge and pdata and pdata.levelBadge and pdata.levelBadge~="" then
        local anchor=CreateFrame("Frame",nil,holder);anchor:SetSize(1,1);anchor:SetPoint("BOTTOMRIGHT",holder,"BOTTOMRIGHT",-1,1)
        pcall(RPAL.RenderLevelBadge,holder,anchor,pdata.levelBadge,22,pdata.level or 1,.72,7)
    end
    return holder
end

local function StatusToast(text)
    if not RPAL._dmStatusToast then
        local f=CreateFrame("Frame",nil,UIParent,"BackdropTemplate");f:SetSize(420,48);f:SetPoint("TOP",0,-165);Layer(f,"TOAST",20,"TOOLTIP",14020);Backdrop(f,Palette().panel,Palette().gold);f:Hide()
        f.text=Text(f,"",11,Palette().pale,"OUTLINE");f.text:SetPoint("CENTER");f.text:SetWidth(390);f.text:SetJustifyH("CENTER")
        RPAL._dmStatusToast=f
    end
    local f=RPAL._dmStatusToast;if RPAL.ApplyToastLayout then RPAL.ApplyToastLayout(f) end;f.text:SetText(text or "");f:SetAlpha(1);f:Show();f:Raise();f._age=0
    f:SetScript("OnUpdate",function(self,e)self._age=(self._age or 0)+e;if self._age>2.2 then local a=math.max(0,1-(self._age-2.2)/.5);self:SetAlpha(a);if a<=0 then self:Hide();self:SetScript("OnUpdate",nil)end end end)
end
RPAL.ShowDMStatusToast=StatusToast

local function ClosePrompt(f)
    if f then f:Hide();f:SetParent(nil) end
end

local function SimplePrompt(title,label,default,onAccept,numberOnly)
    local f=CreateFrame("Frame",nil,UIParent,"BackdropTemplate");f:SetSize(390,160);f:SetPoint("CENTER");Layer(f,"MODAL",20,"FULLSCREEN_DIALOG",8020);Backdrop(f,Palette().main,Palette().gold);f:EnableMouse(true);f:Raise()
    promptFrames[#promptFrames+1]=f
    local t=Text(f,title,15,Palette().gold,"OUTLINE");t:SetPoint("TOP",0,-18)
    local l=Text(f,label,10,Palette().muted);l:SetPoint("TOPLEFT",18,-54)
    local e=Input(f,354,30);e:SetPoint("TOPLEFT",18,-72);e:SetText(tostring(default or ""));e:HighlightText();e:SetFocus()
    if numberOnly then e:SetNumeric(true) end
    local ok=Button(f,"Confirm",110,30,true);ok:SetPoint("BOTTOMRIGHT",-18,16)
    local cancel=Button(f,"Cancel",90,30,false);cancel:SetPoint("RIGHT",ok,"LEFT",-8,0)
    cancel:SetScript("OnClick",function()ClosePrompt(f)end)
    local function submit()local v=e:GetText();if onAccept then local good,msg=onAccept(v);if good==false then StatusToast(msg or "Invalid value.");return end end;ClosePrompt(f)end
    ok:SetScript("OnClick",submit);e:SetScript("OnEnterPressed",submit);e:SetScript("OnEscapePressed",function()ClosePrompt(f)end)
    FadeInFrame(f,.12)
    return f,e
end

local function ConfirmPrompt(message,onConfirm,buttonText)
    local f=CreateFrame("Frame",nil,UIParent,"BackdropTemplate");f:SetSize(430,170);f:SetPoint("CENTER");Layer(f,"MODAL",30,"FULLSCREEN_DIALOG",8030);Backdrop(f,Palette().main,Palette().gold);f:EnableMouse(true);f:Raise()
    promptFrames[#promptFrames+1]=f
    local title=Text(f,"CONFIRM",15,Palette().gold,"OUTLINE");title:SetPoint("TOP",0,-20)
    local body=Text(f,message or "Are you sure?",11,Palette().pale);body:SetPoint("TOPLEFT",24,-58);body:SetPoint("TOPRIGHT",-24,-58);body:SetJustifyH("CENTER");body:SetWordWrap(true)
    local cancel=Button(f,"Cancel",118,30);cancel:SetPoint("BOTTOMLEFT",72,18);cancel:SetScript("OnClick",function()ClosePrompt(f)end)
    local yes=Button(f,buttonText or "Delete",118,30,true);yes:SetPoint("BOTTOMRIGHT",-72,18);yes:SetScript("OnClick",function()ClosePrompt(f);if onConfirm then onConfirm()end end)
    FadeInFrame(f,.12)
    return f
end

local function ConfirmCampaignDelete(campaign)
    if not campaign then return end
    ConfirmPrompt("Delete this campaign and all of its files? This cannot be undone.",function()
        local ok,res=DM.DeleteCampaign(campaign.id);if not ok then StatusToast(res)end
    end,"Delete Campaign")
end

local function CreateCampaignPrompt()
    SimplePrompt("NEW CAMPAIGN","Campaign name","",function(v)local ok,res=DM.CreateCampaign(v);if not ok then return false,res end;return true end)
end

local function CreateFolderPrompt()
    local c=DM.GetActiveCampaign();if not c then StatusToast("Create a campaign first.");return end
    SimplePrompt("ADD SUBFOLDER","Folder name","",function(v)local ok,res=DM.CreateSubFolder(c.id,v,DM._selectedFolder);if not ok then return false,res end;return true end)
end

local function CreateNPCPrompt()
    local c=DM.GetActiveCampaign();if not c then StatusToast("Create a campaign first.");return end
    local f=CreateFrame("Frame",nil,UIParent,"BackdropTemplate");f:SetSize(430,280);f:SetPoint("CENTER");Layer(f,"MODAL",20,"FULLSCREEN_DIALOG",8020);Backdrop(f,Palette().main,Palette().gold);f:EnableMouse(true)
    local t=Text(f,"CREATE NPC",15,Palette().gold,"OUTLINE");t:SetPoint("TOP",0,-18)
    local nl=Text(f,"Name",9,Palette().muted);nl:SetPoint("TOPLEFT",18,-54);local name=Input(f,394,28);name:SetPoint("TOPLEFT",18,-72)
    local tl=Text(f,"Title (optional)",9,Palette().muted);tl:SetPoint("TOPLEFT",18,-111);local title=Input(f,394,28);title:SetPoint("TOPLEFT",18,-129)
    local hl=Text(f,"Maximum health",9,Palette().muted);hl:SetPoint("TOPLEFT",18,-168);local hp=Input(f,150,28);hp:SetNumeric(true);hp:SetText("100");hp:SetPoint("TOPLEFT",18,-186)
    local ok=Button(f,"Create NPC",120,30,true);ok:SetPoint("BOTTOMRIGHT",-18,16);local cancel=Button(f,"Cancel",90,30);cancel:SetPoint("RIGHT",ok,"LEFT",-8,0)
    cancel:SetScript("OnClick",function()ClosePrompt(f)end)
    ok:SetScript("OnClick",function()local good,res=DM.CreateNPC(c.id,name:GetText(),hp:GetText(),title:GetText(),DM._selectedFolder);if not good then StatusToast(res);return end;ClosePrompt(f)end)
    name:SetFocus();FadeInFrame(f,.12)
end

local function EditNPCPrompt(npc)
    local c=DM.GetActiveCampaign();if not c or not npc then return end
    local p=Palette();local f=CreateFrame("Frame",nil,UIParent,"BackdropTemplate");f:SetSize(540,372);f:SetPoint("CENTER");Layer(f,"MODAL",40,"FULLSCREEN_DIALOG",8040);Backdrop(f,p.main,p.gold);f:EnableMouse(true);f:Raise();promptFrames[#promptFrames+1]=f
    local t=Text(f,"NPC EDITOR",16,p.gold,"OUTLINE");t:SetPoint("TOPLEFT",22,-20)
    local sub=Text(f,"Edit the Campaign file. Changes sync to deployed versions immediately.",8.5,p.muted);sub:SetPoint("TOPLEFT",22,-45);sub:SetWidth(490);sub:SetWordWrap(false)
    local rule=f:CreateTexture(nil,"ARTWORK");rule:SetTexture(WHITE);rule:SetHeight(1);rule:SetPoint("TOPLEFT",22,-69);rule:SetPoint("TOPRIGHT",-22,-69);rule:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],.25)
    local nl=Text(f,"NPC NAME",8,p.muted,"OUTLINE");nl:SetPoint("TOPLEFT",22,-91);local name=Input(f,496,32);name:SetPoint("TOPLEFT",22,-110);name:SetText(npc.name or "")
    local tl=Text(f,"TITLE / ROLE",8,p.muted,"OUTLINE");tl:SetPoint("TOPLEFT",22,-156);local title=Input(f,496,32);title:SetPoint("TOPLEFT",22,-175);title:SetText(npc.title or "")
    local hl=Text(f,"MAXIMUM HEALTH",8,p.muted,"OUTLINE");hl:SetPoint("TOPLEFT",22,-221);local hp=Input(f,150,32);hp:SetNumeric(true);hp:SetPoint("TOPLEFT",22,-240);hp:SetText(tostring(npc.maxHP or 100))
    local location=Text(f,"CAMPAIGN FILE",8,p.muted,"OUTLINE");location:SetPoint("TOPLEFT",194,-221);local path=Text(f,npc.folderID and "Stored inside a subfolder" or "Campaign root",9,p.pale);path:SetPoint("TOPLEFT",194,-245);path:SetWidth(320);path:SetWordWrap(false)
    local cancel=Button(f,"Cancel",100,32);cancel:SetPoint("BOTTOMRIGHT",-164,20);cancel:SetScript("OnClick",function()ClosePrompt(f)end)
    local save=Button(f,"Save Changes",138,32,true);save:SetPoint("BOTTOMRIGHT",-20,20);save:SetScript("OnClick",function()
        local ok,res=DM.UpdateNPC(c.id,npc.id,{name=name:GetText(),title=title:GetText(),maxHP=hp:GetText()});if not ok then StatusToast(res);return end
        if DM.SyncNPCDefinition then DM.SyncNPCDefinition(npc.id) end
        ClosePrompt(f)
    end)
    name:SetFocus();name:HighlightText();FadeInFrame(f,.14)
end

local function NotificationPrompt(target)
    local f=CreateFrame("Frame",nil,UIParent,"BackdropTemplate");f:SetSize(470,310);f:SetPoint("CENTER");Layer(f,"MODAL",20,"FULLSCREEN_DIALOG",8020);Backdrop(f,Palette().main,Palette().gold);f:EnableMouse(true)
    local t=Text(f,target and ("NOTIFY "..ShortName(target)) or "NOTIFY PLAYERS",15,Palette().gold,"OUTLINE");t:SetPoint("TOP",0,-18)
    local l1=Text(f,"Title",9,Palette().muted);l1:SetPoint("TOPLEFT",18,-54);local title=Input(f,434,28);title:SetPoint("TOPLEFT",18,-72)
    local l2=Text(f,"Message",9,Palette().muted);l2:SetPoint("TOPLEFT",18,-112);local body=Input(f,434,110,true);body:SetPoint("TOPLEFT",18,-130)
    local send=Button(f,"Send",110,30,true);send:SetPoint("BOTTOMRIGHT",-18,16);local cancel=Button(f,"Cancel",90,30);cancel:SetPoint("RIGHT",send,"LEFT",-8,0)
    cancel:SetScript("OnClick",function()ClosePrompt(f)end)
    send:SetScript("OnClick",function()local ok,res=DM.SendNotification(title:GetText(),body:GetText(),target);if not ok then StatusToast(res);return end;ClosePrompt(f)end)
    title:SetFocus()
end

local function HealthSetupPrompt(target)
    SimplePrompt(target and ("SETUP "..ShortName(target).." HEALTH") or "SETUP PLAYER HEALTH","Maximum health","100",function(v)local ok,res=DM.RequestPlayerHealth(v,target);if not ok then return false,res end;return true end,true)
end

local function PlayerControlPrompt(name)
    local state=DM.GetState();local p=state and state.participants and state.participants[name];if not p then return end
    local f=CreateFrame("Frame",nil,UIParent,"BackdropTemplate");f:SetSize(430,360);f:SetPoint("CENTER");Layer(f,"MODAL",40,"FULLSCREEN_DIALOG",8040);Backdrop(f,Palette().main,Palette().gold);f:EnableMouse(true);f:Raise();promptFrames[#promptFrames+1]=f
    local title=Text(f,p.trp or ShortName(name),15,Palette().gold,"OUTLINE");title:SetPoint("TOP",0,-18)
    local hp=Text(f,p.currentHP~=nil and ((p.currentHP or 0).." / "..(p.maxHP or 0).." HP") or "Health not configured",11,Palette().pale);hp:SetPoint("TOP",0,-48)
    local setup=Button(f,"Request HP",95,30);setup:SetPoint("TOPLEFT",18,-80);setup:SetScript("OnClick",function()ClosePrompt(f);HealthSetupPrompt(name)end)
    local maxb=Button(f,"Set Max HP",95,30);maxb:SetPoint("LEFT",setup,"RIGHT",8,0);maxb:SetScript("OnClick",function()ClosePrompt(f);SimplePrompt("SET MAX HP","Maximum health",p.maxHP or 100,function(v)local max=tonumber(v);if not max or max<1 then return false,"Invalid maximum health." end;local current=math.min(tonumber(p.currentHP) or max,max);local ok,res=DM.SetPlayerHP(name,current,max);if not ok then return false,res end;return true end,true)end)
    local damage=Button(f,"Damage",82,30);damage:SetPoint("LEFT",maxb,"RIGHT",8,0);damage:SetScript("OnClick",function()ClosePrompt(f);SimplePrompt("DAMAGE "..(p.trp or ShortName(name)),"Damage amount","10",function(v)local ok,res=DM.ApplyPlayerDelta(name,v,false);if not ok then return false,res end;return true end,true)end)
    local heal=Button(f,"Heal",82,30);heal:SetPoint("LEFT",damage,"RIGHT",8,0);heal:SetScript("OnClick",function()ClosePrompt(f);SimplePrompt("HEAL "..(p.trp or ShortName(name)),"Healing amount","10",function(v)local ok,res=DM.ApplyPlayerDelta(name,v,true);if not ok then return false,res end;return true end,true)end)
    local set=Button(f,"Set Current HP",120,30);set:SetPoint("TOPLEFT",18,-120);set:SetScript("OnClick",function()ClosePrompt(f);SimplePrompt("SET PLAYER HP","Current health",p.currentHP or 0,function(v)local ok,res=DM.SetPlayerHP(name,v,p.maxHP or v);if not ok then return false,res end;return true end,true)end)
    local whisper=Button(f,"Direct Whisper",120,30,true);whisper:SetPoint("LEFT",set,"RIGHT",8,0);whisper:SetScript("OnClick",function()ClosePrompt(f);DM.DirectWhisper(name)end)
    local notify=Button(f,"Notify",90,30);notify:SetPoint("LEFT",whisper,"RIGHT",8,0);notify:SetScript("OnClick",function()ClosePrompt(f);NotificationPrompt(name)end)
    local addStatus=Button(f,"+ Status Effect",125,30,true);addStatus:SetPoint("TOPLEFT",18,-162);addStatus:SetScript("OnClick",function()ClosePrompt(f);if RPAL.OpenDMStatusEffectPrompt then RPAL.OpenDMStatusEffectPrompt({name},false) end end)
    local removeStatus=Button(f,"Remove Status",125,30);removeStatus:SetPoint("LEFT",addStatus,"RIGHT",8,0);removeStatus:SetScript("OnClick",function()ClosePrompt(f);if RPAL.OpenDMStatusEffectPrompt then RPAL.OpenDMStatusEffectPrompt({name},true) end end)
    local healthRecord=Button(f,"Propose Injury",125,30,true);healthRecord:SetPoint("TOPLEFT",18,-204);healthRecord:SetScript("OnClick",function()ClosePrompt(f);if RPAL.HealthDMInjuryPrompt then RPAL.HealthDMInjuryPrompt(name) else StatusToast("ODYN Health is not available.") end end)
    local healthHint=Text(f,"Health changes require the player's consent.",7.5,Palette().muted);healthHint:SetPoint("LEFT",healthRecord,"RIGHT",10,0);healthHint:SetWidth(235);healthHint:SetWordWrap(true)
    if not p.isHost then
        local remove=Button(f,"Remove from Session",150,30);remove:SetPoint("BOTTOMLEFT",18,18);remove:SetScript("OnClick",function()ClosePrompt(f);local ok,res=DM.RemoveParticipant(name);if not ok then StatusToast(res) end end)
        if p.disconnected then
            local reinvite=Button(f,"Re-invite",100,30,true);reinvite:SetPoint("LEFT",remove,"RIGHT",8,0);reinvite:SetScript("OnClick",function()local ok,res=DM.InvitePlayer(name);StatusToast(ok and "Re-invitation sent." or res)end)
        end
    end
    local close=Button(f,"Close",90,30);close:SetPoint("BOTTOMRIGHT",-18,18);close:SetScript("OnClick",function()ClosePrompt(f)end)
end

local function SelectedPlayerNames(map)
    local out={};for name,v in pairs(map or {}) do if v then out[#out+1]=name end end
    table.sort(out,function(a,b)return tostring(a)<tostring(b) end);return out
end

local function StatusEffectPrompt(preselected,removeMode)
    local state=DM.GetState();local campaign=DM.GetActiveCampaign();if not state or not DM.IsHost() or not campaign then return end
    local f=CreateFrame("Frame",nil,UIParent,"BackdropTemplate");f:SetSize(800,650);f:SetPoint("CENTER");Layer(f,"MODAL",40,"FULLSCREEN_DIALOG",8040);Backdrop(f,Palette().main,Palette().gold);f:EnableMouse(true);f:Raise()
    local title=Text(f,removeMode and "REMOVE STATUS EFFECT" or "ADD STATUS EFFECT",16,Palette().gold,"OUTLINE");title:SetPoint("TOP",0,-18)
    local hint=Text(f,removeMode and "Choose a status and every player it should be removed from." or "Choose a status and every player it should be applied to.",9,Palette().muted);hint:SetPoint("TOP",0,-43)
    local left=CreateFrame("Frame",nil,f,"BackdropTemplate");left:SetPoint("TOPLEFT",18,-72);left:SetPoint("BOTTOMLEFT",18,62);left:SetWidth(350);Backdrop(left,Palette().panel2,Palette().border)
    local right=CreateFrame("Frame",nil,f,"BackdropTemplate");right:SetPoint("TOPRIGHT",-18,-72);right:SetPoint("BOTTOMRIGHT",-18,62);right:SetWidth(390);Backdrop(right,Palette().panel2,Palette().border)
    local lt=Text(left,"STATUS EFFECT",9,Palette().gold,"OUTLINE");lt:SetPoint("TOPLEFT",12,-12)
    local rt=Text(right,"TARGET PLAYERS",9,Palette().gold,"OUTLINE");rt:SetPoint("TOPLEFT",12,-12)
    local selectedStatus=nil;local statusButtons={}
    local templates=DM.GetStatusTemplates and DM.GetStatusTemplates(campaign.id) or {}
    local sy=-38
    for i,entry in ipairs(templates) do
        if i<=22 then
            local b=Button(left,entry.name,156,27,false);b:SetPoint("TOPLEFT",12+(((i-1)%2)*166),sy-(math.floor((i-1)/2)*31));statusButtons[#statusButtons+1]=b
            b:SetScript("OnClick",function()selectedStatus=entry;for _,x in ipairs(statusButtons)do SetButtonSelected(x,false)end;SetButtonSelected(b,true)end)
        end
    end
    if not removeMode then
        local custom=Button(left,"+ Custom Status",156,28,true);custom:SetPoint("BOTTOMLEFT",12,12);custom:SetScript("OnClick",function()
            SimplePrompt("CREATE CUSTOM STATUS","Status name","",function(v)
                local ok,res=DM.CreateCustomStatus(campaign.id,v);if not ok then return false,res end
                ClosePrompt(f);StatusEffectPrompt(preselected,false);return true
            end)
        end)
    end
    local selected={};for _,name in ipairs(preselected or {}) do selected[name]=true end
    local plist=PlayerList(state,true);local py=-38
    local checks={}
    local all=Check(right,"Select all");all:SetPoint("TOPLEFT",12,py);all:SetChecked(false);py=py-30
    for i,it in ipairs(plist) do
        if i<=20 then
            local name=it.name;local pdata=it.p;local cb=Check(right,pdata.trp or ShortName(name));cb:SetPoint("TOPLEFT",12+(((i-1)%2)*188),py-(math.floor((i-1)/2)*29));cb:SetChecked(selected[name] and true or false)
            cb:SetScript("OnClick",function(self)selected[name]=self:GetChecked() and true or nil;if not self:GetChecked() then all:SetChecked(false) end end);checks[#checks+1]={cb=cb,name=name}
        end
    end
    all:SetScript("OnClick",function(self)for _,it in ipairs(checks)do it.cb:SetChecked(self:GetChecked());selected[it.name]=self:GetChecked() and true or nil end end)
    local cancel=Button(f,"Cancel",96,32);cancel:SetPoint("BOTTOMRIGHT",-18,18);cancel:SetScript("OnClick",function()ClosePrompt(f)end)
    local apply=Button(f,removeMode and "Remove Status" or "Apply Status",138,32,true);apply:SetPoint("RIGHT",cancel,"LEFT",-8,0);apply:SetScript("OnClick",function()
        if not selectedStatus then StatusToast("Choose a status effect first.");return end
        local names=SelectedPlayerNames(selected);if #names==0 then StatusToast("Select at least one player.");return end
        local ok,res=DM.ApplyStatusToPlayers(names,selectedStatus.id,not removeMode,selectedStatus.name);if not ok then StatusToast(res);return end
        ClosePrompt(f)
    end)
end

RPAL.OpenDMStatusEffectPrompt=StatusEffectPrompt

local function PartyActionsPrompt(preselected)
    local state=DM.GetState();if not state or not DM.IsHost() then return end
    local f=CreateFrame("Frame",nil,UIParent,"BackdropTemplate");f:SetSize(760,610);f:SetPoint("CENTER");Layer(f,"MODAL",40,"FULLSCREEN_DIALOG",8040);Backdrop(f,Palette().main,Palette().gold);f:EnableMouse(true);f:Raise()
    local title=Text(f,"PARTY ACTIONS",16,Palette().gold,"OUTLINE");title:SetPoint("TOP",0,-18)
    local sub=Text(f,"Select one or more players, then apply an action to the whole selection.",9,Palette().muted);sub:SetPoint("TOP",0,-43)
    local box=CreateFrame("Frame",nil,f,"BackdropTemplate");box:SetPoint("TOPLEFT",18,-76);box:SetPoint("BOTTOMRIGHT",-18,176);Backdrop(box,Palette().panel2,Palette().border)
    local selected={};for _,name in ipairs(preselected or {}) do selected[name]=true end
    local plist=PlayerList(state,true);local checks={};local y=-14
    local all=Check(box,"Select all players");all:SetPoint("TOPLEFT",12,y);y=y-32
    for i,it in ipairs(plist) do
        if i<=20 then
            local name=it.name;local pdata=it.p;local cb=Check(box,(pdata.trp or ShortName(name))..(pdata.isHost and "  [DM]" or ""));cb:SetPoint("TOPLEFT",12+(((i-1)%2)*350),y-(math.floor((i-1)/2)*31));cb:SetChecked(selected[name] and true or false)
            cb:SetScript("OnClick",function(self)selected[name]=self:GetChecked() and true or nil;if not self:GetChecked() then all:SetChecked(false) end end);checks[#checks+1]={cb=cb,name=name}
        end
    end
    all:SetScript("OnClick",function(self)for _,it in ipairs(checks)do it.cb:SetChecked(self:GetChecked());selected[it.name]=self:GetChecked() and true or nil end end)
    local function targets()local out=SelectedPlayerNames(selected);if #out==0 then StatusToast("Select at least one player.");return nil end;return out end
    local label=Text(f,"APPLY TO SELECTION",9,Palette().gold,"OUTLINE");label:SetPoint("BOTTOMLEFT",20,142)
    local damage=Button(f,"Damage",108,31,true);damage:SetPoint("BOTTOMLEFT",20,100);damage:SetScript("OnClick",function()local names=targets();if not names then return end;SimplePrompt("DAMAGE SELECTED PLAYERS","Damage amount","10",function(v)local ok,res=DM.ApplyPlayerDeltaBulk(names,v,false);if not ok then return false,res end;return true end,true)end)
    local heal=Button(f,"Heal",108,31);heal:SetPoint("LEFT",damage,"RIGHT",8,0);heal:SetScript("OnClick",function()local names=targets();if not names then return end;SimplePrompt("HEAL SELECTED PLAYERS","Healing amount","10",function(v)local ok,res=DM.ApplyPlayerDeltaBulk(names,v,true);if not ok then return false,res end;return true end,true)end)
    local sethp=Button(f,"Set HP",108,31);sethp:SetPoint("LEFT",heal,"RIGHT",8,0);sethp:SetScript("OnClick",function()local names=targets();if not names then return end;SimplePrompt("SET SELECTED HP","Current health","50",function(v)local ok,res=DM.SetPlayerHPBulk(names,v);if not ok then return false,res end;return true end,true)end)
    local maxhp=Button(f,"Set Max HP",108,31);maxhp:SetPoint("LEFT",sethp,"RIGHT",8,0);maxhp:SetScript("OnClick",function()local names=targets();if not names then return end;SimplePrompt("SET SELECTED MAX HP","Maximum health","100",function(v)local ok,res=DM.SetPlayerMaxHPBulk(names,v);if not ok then return false,res end;return true end,true)end)
    local add=Button(f,"+ Status",108,31,true);add:SetPoint("BOTTOMLEFT",20,59);add:SetScript("OnClick",function()local names=targets();if names then StatusEffectPrompt(names,false) end end)
    local remove=Button(f,"- Status",108,31);remove:SetPoint("LEFT",add,"RIGHT",8,0);remove:SetScript("OnClick",function()local names=targets();if names then StatusEffectPrompt(names,true) end end)
    local request=Button(f,"Request HP",108,31);request:SetPoint("LEFT",remove,"RIGHT",8,0);request:SetScript("OnClick",function()local names=targets();if not names then return end;SimplePrompt("REQUEST HEALTH","Maximum health","100",function(v)local max=tonumber(v);if not max or max<1 then return false,"Invalid maximum health." end;local okCount=0;for _,name in ipairs(names) do local ok=DM.RequestPlayerHealth(max,name);if ok then okCount=okCount+1 end end;if okCount==0 then return false,"No health requests were sent." end;return true end,true)end)
    local close=Button(f,"Close",96,31);close:SetPoint("BOTTOMRIGHT",-20,20);close:SetScript("OnClick",function()ClosePrompt(f)end)
end

local function NPCControlPrompt(marker)
    local state=DM.GetState();local b=state and state.beacons and state.beacons[marker];if not b then return end
    local p=Palette();local f=CreateFrame("Frame",nil,UIParent,"BackdropTemplate");f:SetSize(470,400);f:SetPoint("CENTER");Layer(f,"MODAL",40,"FULLSCREEN_DIALOG",8040);Backdrop(f,p.main,p.gold,8);f:EnableMouse(true);f:Raise();promptFrames[#promptFrames+1]=f

    local markerName=DM.MARKER_NAMES[marker] or "Beacon"
    local kicker=Text(f,string.upper(markerName).."  •  DEPLOYED NPC",7.5,p.muted,"OUTLINE");kicker:SetPoint("TOPLEFT",22,-18)
    local title=Text(f,"",16,p.gold,"OUTLINE");title:SetPoint("TOPLEFT",22,-37);title:SetJustifyH("LEFT");FitLine(title,b.name or "NPC",420,16,11)
    if b.title and b.title~="" then local role=Text(f,b.title,9,p.pale);role:SetPoint("TOPLEFT",22,-61);role:SetWidth(420);role:SetWordWrap(false) end

    local health=CreateFrame("Frame",nil,f,"BackdropTemplate");health:SetPoint("TOPLEFT",20,-90);health:SetPoint("TOPRIGHT",-20,-90);health:SetHeight(70);Backdrop(health,p.panel2,p.border,7)
    local hlabel=Text(health,"HEALTH",7.5,p.muted,"OUTLINE");hlabel:SetPoint("TOPLEFT",12,-9)
    local hp=Text(health,tostring(b.currentHP or 0).." / "..tostring(b.maxHP or 0).." HP",11,p.pale,"OUTLINE");hp:SetPoint("TOPRIGHT",-12,-8)
    local bar=CreateHealthBar(health,410,10,p.border);bar:SetPoint("BOTTOMLEFT",12,12);bar:SetPoint("BOTTOMRIGHT",-12,12);SetHealthBar(bar,b.currentHP,b.maxHP,p.gold)

    local healthTitle=Text(f,"QUICK HEALTH ACTIONS",7.5,p.muted,"OUTLINE");healthTitle:SetPoint("TOPLEFT",22,-177)
    local d5=Button(f,"-5",70,30);d5:SetPoint("TOPLEFT",20,-196);d5:SetScript("OnClick",function()DM.ApplyNPCDelta(marker,5,false);ClosePrompt(f)end)
    local d10=Button(f,"-10",70,30);d10:SetPoint("LEFT",d5,"RIGHT",8,0);d10:SetScript("OnClick",function()DM.ApplyNPCDelta(marker,10,false);ClosePrompt(f)end)
    local dmg=Button(f,"Damage…",102,30);dmg:SetPoint("LEFT",d10,"RIGHT",8,0);dmg:SetScript("OnClick",function()ClosePrompt(f);SimplePrompt("DAMAGE "..b.name,"Damage amount","10",function(v)local ok,res=DM.ApplyNPCDelta(marker,v,false);if not ok then return false,res end;return true end,true)end)
    local heal=Button(f,"Heal…",102,30);heal:SetPoint("LEFT",dmg,"RIGHT",8,0);heal:SetScript("OnClick",function()ClosePrompt(f);SimplePrompt("HEAL "..b.name,"Healing amount","10",function(v)local ok,res=DM.ApplyNPCDelta(marker,v,true);if not ok then return false,res end;return true end,true)end)
    local set=Button(f,"Set HP…",94,30);set:SetPoint("TOPLEFT",20,-234);set:SetScript("OnClick",function()ClosePrompt(f);SimplePrompt("SET "..b.name.." HP","Current health",b.currentHP,function(v)local ok,res=DM.SetNPCHealth(marker,v,b.maxHP);if not ok then return false,res end;return true end,true)end)

    local beaconTitle=Text(f,"BEACON & OUTCOME",7.5,p.muted,"OUTLINE");beaconTitle:SetPoint("TOPLEFT",22,-283)
    local change=Button(f,"Change Beacon",126,30,true);change:SetPoint("TOPLEFT",20,-302);change:SetScript("OnClick",function()
        local picker=CreateFrame("Frame",nil,UIParent,"BackdropTemplate");picker:SetSize(450,292);picker:SetPoint("CENTER");Layer(picker,"MODAL",60,"FULLSCREEN_DIALOG",8060);Backdrop(picker,Palette().main,Palette().gold,8);picker:EnableMouse(true);picker:Raise();promptFrames[#promptFrames+1]=picker
        local pt=Text(picker,"CHANGE RAID BEACON",15,Palette().gold,"OUTLINE");pt:SetPoint("TOP",0,-18)
        local ph=Text(picker,"Choose a free marker. Health, title and Combat Timeline state are preserved.",8.7,Palette().muted);ph:SetPoint("TOP",0,-44);ph:SetWidth(405);ph:SetJustifyH("CENTER");ph:SetWordWrap(true)
        local st=DM.GetState()
        for m=1,8 do
            local mm=m;local occupied=st and st.beacons and st.beacons[mm] and mm~=marker
            local label=RaidMarkerMarkup(mm).."  "..(DM.MARKER_NAMES[mm] or "Beacon")..(mm==marker and "  • current" or (occupied and "  • occupied" or ""))
            local bb=Button(picker,label,196,34,mm==marker);bb:SetPoint("TOPLEFT",20+(((mm-1)%2)*204),-82-(math.floor((mm-1)/2)*40));SetButtonSelected(bb,mm==marker)
            if occupied or mm==marker then bb:Disable();bb:SetAlpha(mm==marker and .9 or .42) else bb:SetScript("OnClick",function()local ok,res=DM.ReassignMarker(marker,mm);if not ok then StatusToast(res);return end;ClosePrompt(picker);ClosePrompt(f)end) end
        end
        local pc=Button(picker,"Cancel",90,30);pc:SetPoint("BOTTOMRIGHT",-20,15);pc:SetScript("OnClick",function()ClosePrompt(picker)end);FadeInFrame(picker,.12)
    end)
    local saved=Button(f,"Mark Saved",112,30);saved:SetPoint("LEFT",change,"RIGHT",8,0);saved:SetScript("OnClick",function()local ok,res=DM.MarkNPCSaved and DM.MarkNPCSaved(marker);if not ok then StatusToast(res or "Could not mark NPC saved.") end;ClosePrompt(f)end)
    local clear=Button(f,"Unassign",100,30);clear:SetPoint("LEFT",saved,"RIGHT",8,0);clear:SetScript("OnClick",function()DM.ClearMarker(marker);ClosePrompt(f)end)
    local close=Button(f,"Close",90,30);close:SetPoint("BOTTOMRIGHT",-20,18);close:SetScript("OnClick",function()ClosePrompt(f)end)
    FadeInFrame(f,.12)
end

local function DamageRequestPrompt(marker)
    local state=DM.GetState();local b=state and state.beacons and state.beacons[marker];if not b then return end
    SimplePrompt("DEAL DAMAGE — "..b.name,"Damage amount","10",function(v)local ok,res=DM.SubmitDamage(marker,v);if not ok then return false,res end;StatusToast("Damage request sent to the DM.");return true end,true)
end

local function DeployPrompt()
    local state=DM.GetState();local c=state and DM.GetCampaign(state.campaignID);if not c then StatusToast("No active campaign.");return end
    local f=CreateFrame("Frame",nil,UIParent,"BackdropTemplate");f:SetSize(650,480);f:SetPoint("CENTER");Layer(f,"MODAL",40,"FULLSCREEN_DIALOG",8040);Backdrop(f,Palette().main,Palette().gold);f:EnableMouse(true)
    local title=Text(f,"DEPLOY NPC TO RAID BEACON",15,Palette().gold,"OUTLINE");title:SetPoint("TOP",0,-18)
    local selectedNPC
    local npcs=DM.GetNPCs(c.id,nil)
    local y=-58
    local rows={}
    for i,npc in ipairs(npcs) do
        if i>10 then break end
        local b=Button(f,npc.name.."  ·  "..npc.maxHP.." HP",270,30,false);b:SetPoint("TOPLEFT",18,y);y=y-34
        b:SetScript("OnClick",function()selectedNPC=npc.id;for _,r in ipairs(rows)do SetButtonSelected(r,false)end;SetButtonSelected(b,true)end);rows[#rows+1]=b
    end
    if #npcs==0 then local e=Text(f,"Create an NPC in the Campaign Manager first.",10,Palette().muted);e:SetPoint("TOPLEFT",18,-62) end
    local mt=Text(f,"Choose beacon",10,Palette().muted);mt:SetPoint("TOPLEFT",330,-58)
    for marker=1,8 do
        local b=CreateFrame("Button",nil,f,"BackdropTemplate");b:SetSize(140,38);b:SetPoint("TOPLEFT",330+(((marker-1)%2)*145),-82-(math.floor((marker-1)/2)*44));Backdrop(b,Palette().button,Palette().border)
        local tx=Text(b,RaidMarkerMarkup(marker).."  "..(DM.MARKER_NAMES[marker] or "Beacon"),9.5,Palette().pale,"OUTLINE")
        tx:SetPoint("LEFT",10,0);tx:SetWidth(118);tx:SetJustifyH("LEFT");tx:SetWordWrap(false)
        b:SetScript("OnClick",function()if not selectedNPC then StatusToast("Choose an NPC first.");return end;local ok,res=DM.AssignNPCToMarker(selectedNPC,marker);if not ok then StatusToast(res);return end;ClosePrompt(f)end)
    end
    local close=Button(f,"Close",90,30);close:SetPoint("BOTTOMRIGHT",-18,16);close:SetScript("OnClick",function()ClosePrompt(f)end)
end

local function RollPrompt()
    local state=DM.GetState();if not state or not DM.IsHost() then return end
    local eligible=DM.GetRollEligiblePlayers and DM.GetRollEligiblePlayers(true) or PlayerList(state,true)
    local targetRows=math.max(1,math.ceil(math.min(#eligible,20)/2));local targetH=48+(targetRows*27);local frameH=math.max(520,math.min(790,431+targetH))
    local f=CreateFrame("Frame",nil,UIParent,"BackdropTemplate");f:SetSize(650,frameH);f:SetPoint("CENTER");Layer(f,"MODAL",40,"FULLSCREEN_DIALOG",8040);Backdrop(f,Palette().main,Palette().gold);f:EnableMouse(true)
    local t=Text(f,"SEND ROLL CHECK",15,Palette().gold,"OUTLINE");t:SetPoint("TOP",0,-18)
    local l1=Text(f,"Title",9,Palette().muted);l1:SetPoint("TOPLEFT",18,-54);local title=Input(f,614,28);title:SetPoint("TOPLEFT",18,-72);title:SetText("Perception Check")
    local l2=Text(f,"Additional info",9,Palette().muted);l2:SetPoint("TOPLEFT",18,-111);local info=Input(f,614,68,true);info:SetPoint("TOPLEFT",18,-129)
    local dl=Text(f,"Dice",9,Palette().muted);dl:SetPoint("TOPLEFT",18,-210)
    local dice={4,6,8,12,20,100};local selected=20;local diceButtons={}
    for i,sides in ipairs(dice) do
        local b=Button(f,"D"..sides,82,30,false);b:SetPoint("TOPLEFT",18+(i-1)*99,-229);diceButtons[#diceButtons+1]=b;SetButtonSelected(b,sides==20)
        b:SetScript("OnClick",function()selected=sides;for _,x in ipairs(diceButtons)do SetButtonSelected(x,false)end;SetButtonSelected(b,true)end)
    end

    local ml=Text(f,"Minimum success (optional)",9,Palette().muted);ml:SetPoint("TOPLEFT",18,-273);local minimum=Input(f,150,28);minimum:SetNumeric(true);minimum:SetPoint("TOPLEFT",18,-291)
    local sl=Text(f,"Character stat (optional)",9,Palette().muted);sl:SetPoint("TOPLEFT",195,-273)
    local selectedStat=nil;local statLabel="No stat modifier";local statButton=Button(f,statLabel,250,28,false);statButton:SetPoint("TOPLEFT",195,-291)
    local statOptions=(DM.GetRollStatOptions and DM.GetRollStatOptions()) or {{id="",label="No stat modifier"}}
    local function SelectStatLabel(label)
        statLabel=label or "No stat modifier";selectedStat=nil
        for _,entry in ipairs(statOptions) do if entry.label==statLabel then selectedStat=(entry.id~="" and entry.id or nil) break end end
        if statButton.text then FitLine(statButton.text,statLabel,238,10,7) end
    end
    statButton:SetScript("OnClick",function()
        local labels={};for _,entry in ipairs(statOptions) do labels[#labels+1]=entry.label end
        if RPAL.OpenPopupDropdown then
            RPAL.OpenPopupDropdown(f,statButton,labels,statLabel,function(choice)SelectStatLabel(choice)end)
        else
            local idx=1;for i,label in ipairs(labels) do if label==statLabel then idx=i break end end;idx=(idx%#labels)+1;SelectStatLabel(labels[idx])
        end
    end)

    local tl=Text(f,"Targets",9,Palette().muted);tl:SetPoint("TOPLEFT",18,-337)
    local targetBox=CreateFrame("Frame",nil,f,"BackdropTemplate");targetBox:SetPoint("TOPLEFT",18,-355);targetBox:SetSize(614,targetH);Backdrop(targetBox,Palette().panel2,Palette().border)
    local everyone=Check(targetBox,"Everyone (DM observes)");everyone:SetPoint("TOPLEFT",8,-5);everyone:SetChecked(true)
    local selectedTargets={};local targetChecks={}
    for i,it in ipairs(eligible) do
        local name=it.name;local pdata=it.p
        local cb=Check(targetBox,(pdata.trp or ShortName(name))..(pdata.isHost and "  [DM]" or ""));cb:SetPoint("TOPLEFT",8+(((i-1)%2)*294),-34-(math.floor((i-1)/2)*27));cb:SetChecked(false)
        cb:SetScript("OnClick",function(self)selectedTargets[name]=self:GetChecked() and true or nil;if self:GetChecked() then everyone:SetChecked(false) end end)
        targetChecks[#targetChecks+1]=cb;if i>=20 then break end
    end
    everyone:SetScript("OnClick",function(self)if self:GetChecked() then selectedTargets={};for _,cb in ipairs(targetChecks)do cb:SetChecked(false)end end end)

    local send=Button(f,"Send Roll Check",145,32,true);send:SetPoint("BOTTOMRIGHT",-18,16);local cancel=Button(f,"Cancel",90,32);cancel:SetPoint("RIGHT",send,"LEFT",-8,0)
    cancel:SetScript("OnClick",function()ClosePrompt(f)end)
    send:SetScript("OnClick",function()
        local targets=nil
        if not everyone:GetChecked() then targets={};for name in pairs(selectedTargets)do targets[#targets+1]=name end;if #targets==0 then StatusToast("Select at least one target.");return end end
        local ok,res=DM.StartRollCheck(title:GetText(),info:GetText(),selected,minimum:GetText(),targets,{stat=selectedStat})
        if not ok then StatusToast(res);return end;ClosePrompt(f)
    end)
    FadeInFrame(f,.12)
end

local function PendingPrompt()
    local requests=DM.GetPendingDamage();if #requests==0 then StatusToast("No pending damage requests.");return end
    local f=CreateFrame("Frame",nil,UIParent,"BackdropTemplate");f:SetSize(570,math.min(520,100+#requests*58));f:SetPoint("CENTER");Layer(f,"MODAL",40,"FULLSCREEN_DIALOG",8040);Backdrop(f,Palette().main,Palette().gold);f:EnableMouse(true)
    local t=Text(f,"PENDING DAMAGE REQUESTS",15,Palette().gold,"OUTLINE");t:SetPoint("TOP",0,-18)
    local y=-54;local state=DM.GetState()
    for _,r in ipairs(requests) do
        local p=state.participants[r.player];local b=state.beacons[r.marker]
        local row=CreateFrame("Frame",nil,f,"BackdropTemplate");row:SetSize(534,50);row:SetPoint("TOPLEFT",18,y);Backdrop(row,Palette().panel2,Palette().border)
        local txt=Text(row,(p and p.trp or ShortName(r.player)).." → "..(b and b.name or "NPC").."   "..r.amount.." damage",10,Palette().pale);txt:SetPoint("LEFT",10,0);txt:SetWidth(300);txt:SetJustifyH("LEFT")
        local approve=Button(row,"Approve",72,26,true);approve:SetPoint("RIGHT",-142,0);approve:SetScript("OnClick",function()DM.ApproveDamage(r.id);ClosePrompt(f);if #DM.GetPendingDamage()>0 then PendingPrompt() end end)
        local edit=Button(row,"Edit",54,26);edit:SetPoint("LEFT",approve,"RIGHT",6,0);edit:SetScript("OnClick",function()ClosePrompt(f);SimplePrompt("EDIT DAMAGE","Damage amount",r.amount,function(v)local ok,res=DM.ApproveDamage(r.id,v);if not ok then return false,res end;return true end,true)end)
        local reject=Button(row,"Reject",64,26);reject:SetPoint("LEFT",edit,"RIGHT",6,0);reject:SetScript("OnClick",function()DM.RejectDamage(r.id);ClosePrompt(f);if #DM.GetPendingDamage()>0 then PendingPrompt() end end)
        y=y-56
    end
    local close=Button(f,"Close",90,30);close:SetPoint("BOTTOMRIGHT",-18,14);close:SetScript("OnClick",function()ClosePrompt(f)end)
end

local function NotificationHistoryPrompt()
    local items=DM.GetNotifications()
    local f=CreateFrame("Frame",nil,UIParent,"BackdropTemplate");f:SetSize(650,560);f:SetPoint("CENTER");Layer(f,"MODAL",40,"FULLSCREEN_DIALOG",8040);Backdrop(f,Palette().main,Palette().gold);f:EnableMouse(true)
    local t=Text(f,"DM NOTIFICATIONS",15,Palette().gold,"OUTLINE");t:SetPoint("TOP",0,-18)
    local scroll=CreateFrame("ScrollFrame",nil,f,"UIPanelScrollFrameTemplate");scroll:SetPoint("TOPLEFT",18,-52);scroll:SetPoint("BOTTOMRIGHT",-36,54)
    if RPAL.SkinScrollFrame then RPAL.SkinScrollFrame(scroll) end
    local c=CreateFrame("Frame",nil,scroll);c:SetSize(580,1);scroll:SetScrollChild(c)
    local y=-4
    for i=#items,1,-1 do
        local n=items[i];local card=CreateFrame("Frame",nil,c,"BackdropTemplate");card:SetSize(565,68);card:SetPoint("TOPLEFT",4,y);Backdrop(card,Palette().panel2,Palette().border)
        local h=Text(card,FormatTime(n.t).."  "..tostring(n.title or "Notification"),10,Palette().gold,"OUTLINE");h:SetPoint("TOPLEFT",10,-8);h:SetWidth(540);h:SetJustifyH("LEFT")
        local b=Text(card,tostring(n.body or ""),9.2,Palette().pale);b:SetPoint("TOPLEFT",10,-29);b:SetWidth(540);b:SetJustifyH("LEFT");b:SetWordWrap(true)
        y=y-74
    end
    if #items==0 then local e=Text(c,"No DM notifications have been received yet.",10,Palette().muted);e:SetPoint("TOPLEFT",8,-8) end
    c:SetHeight(math.max(1,-y+14))
    local close=Button(f,"Close",90,30);close:SetPoint("BOTTOMRIGHT",-18,14);close:SetScript("OnClick",function()ClosePrompt(f)end)
end

local function LogPrompt()
    local logs=DM.GetVisibleLog()
    local f=CreateFrame("Frame",nil,UIParent,"BackdropTemplate");f:SetSize(680,620);f:SetPoint("CENTER");Layer(f,"MODAL",40,"FULLSCREEN_DIALOG",8040);Backdrop(f,Palette().main,Palette().gold);f:EnableMouse(true)
    local t=Text(f,"SESSION LOG",15,Palette().gold,"OUTLINE");t:SetPoint("TOP",0,-18)
    local scroll=CreateFrame("ScrollFrame",nil,f,"UIPanelScrollFrameTemplate");scroll:SetPoint("TOPLEFT",18,-52);scroll:SetPoint("BOTTOMRIGHT",-36,54)
    if RPAL.SkinScrollFrame then RPAL.SkinScrollFrame(scroll) end
    local c=CreateFrame("Frame",nil,scroll);c:SetSize(610,1);scroll:SetScrollChild(c)
    local y=-4
    for i=#logs,1,-1 do
        local e=logs[i];local row=Text(c,FormatLogLine(e),10,Palette().pale);row:SetPoint("TOPLEFT",4,y);row:SetWidth(596);row:SetJustifyH("LEFT");row:SetJustifyV("TOP");row:SetWordWrap(true);local h=math.max(22,row:GetStringHeight()+8);y=y-h
    end
    c:SetHeight(math.max(1,-y+10))
    local close=Button(f,"Close",90,30);close:SetPoint("BOTTOMRIGHT",-18,14);close:SetScript("OnClick",function()ClosePrompt(f)end)
end

local function CampaignHistoryPrompt(campaign)
    if not campaign then return end
    local history=DM.GetCampaignHistory(campaign.id)
    local f=CreateFrame("Frame",nil,UIParent,"BackdropTemplate");f:SetSize(760,620);f:SetPoint("CENTER");Layer(f,"MODAL",40,"FULLSCREEN_DIALOG",8040);Backdrop(f,Palette().main,Palette().gold);f:EnableMouse(true)
    local t=Text(f,"CAMPAIGN SESSION HISTORY",15,Palette().gold,"OUTLINE");t:SetPoint("TOP",0,-18)
    local sub=Text(f,campaign.name,10,Palette().pale);sub:SetPoint("TOP",0,-42)
    local scroll=CreateFrame("ScrollFrame",nil,f,"UIPanelScrollFrameTemplate");scroll:SetPoint("TOPLEFT",18,-70);scroll:SetPoint("BOTTOMRIGHT",-36,54)
    if RPAL.SkinScrollFrame then RPAL.SkinScrollFrame(scroll) end
    local c=CreateFrame("Frame",nil,scroll);c:SetSize(690,1);scroll:SetScrollChild(c);local y=-4
    for si=#history,1,-1 do
        local session=history[si];local head=Text(c,date("%Y-%m-%d  %H:%M",session.startedAt or session.endedAt or time()).."  ·  "..#(session.log or {}).." events",10,Palette().gold,"OUTLINE");head:SetPoint("TOPLEFT",4,y);head:SetWidth(670);head:SetJustifyH("LEFT");y=y-24
        for _,e in ipairs(session.log or {}) do
            local line=Text(c,FormatLogLine(e),8.8,Palette().pale);line:SetPoint("TOPLEFT",18,y);line:SetWidth(650);line:SetJustifyH("LEFT");line:SetWordWrap(true);local h=math.max(18,line:GetStringHeight()+3);y=y-h
        end
        y=y-12
    end
    if #history==0 then local e=Text(c,"No completed DM sessions have been archived for this campaign yet.",10,Palette().muted);e:SetPoint("TOPLEFT",8,-8) end
    c:SetHeight(math.max(1,-y+12))
    local close=Button(f,"Close",90,30);close:SetPoint("BOTTOMRIGHT",-18,14);close:SetScript("OnClick",function()ClosePrompt(f)end)
end

-- ---------------------- NOTES ----------------------
function RPAL.OpenDMNotes()
    local campaign=DM.GetActiveCampaign()
    if not campaign then StatusToast("Select a campaign first.");return end
    if not notesFrame then
        local f=CreateFrame("Frame","RPAL_DMNotes",UIParent,"BackdropTemplate");notesFrame=f;f:SetSize(420,300);Layer(f,"WINDOW",40,"FULLSCREEN_DIALOG",3040);Backdrop(f,Palette().main,Palette().gold);f:SetMovable(true);f:SetResizable(true);f:SetResizeBounds(300,200,760,650);f:EnableMouse(true);f:RegisterForDrag("LeftButton")
        f:SetScript("OnDragStart",function(self)self:StartMoving()end);f:SetScript("OnDragStop",function(self)self:StopMovingOrSizing();local x,y=self:GetCenter();local ux,uy=UIParent:GetCenter();local d=DM.GetData().notesWindow;d.x=x-ux;d.y=y-uy end)
        local title=Text(f,"DM NOTES",12,Palette().gold,"OUTLINE");title:SetPoint("TOPLEFT",14,-11);f.title=title
        local close=Button(f,"×",28,25);close:SetPoint("TOPRIGHT",-7,-6);close:SetScript("OnClick",function()f:Hide()end);f.closeButton=close
        local edit=Input(f,380,230,true);f.edit=edit;edit:SetPoint("TOPLEFT",12,-38);edit:SetPoint("BOTTOMRIGHT",-12,44);edit:SetJustifyV("TOP");edit:SetScript("OnTextChanged",function(self,user)if user and f.campaignID then local c=DM.GetCampaign(f.campaignID);if c then c.notes=self:GetText() end end end)
        local stamp=Button(f,"Insert Timestamp",115,26);stamp:SetPoint("BOTTOMLEFT",12,10);stamp:SetScript("OnClick",function()edit:Insert("["..date("%H:%M").."] ")end);f.stampButton=stamp
        local grip=CreateFrame("Button",nil,f);grip:SetSize(22,22);grip:SetPoint("BOTTOMRIGHT",-3,3);grip:SetNormalTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Up");grip:SetHighlightTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Highlight");grip:SetPushedTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Down")
        grip:SetScript("OnMouseDown",function()f:StartSizing("BOTTOMRIGHT")end);grip:SetScript("OnMouseUp",function()f:StopMovingOrSizing();local d=DM.GetData().notesWindow;d.w=f:GetWidth();d.h=f:GetHeight()end)
        if UISpecialFrames then table.insert(UISpecialFrames,"RPAL_DMNotes") end
    end
    local d=DM.GetData().notesWindow or {};notesFrame:ClearAllPoints();notesFrame:SetPoint("CENTER",UIParent,"CENTER",d.x or 0,d.y or 0);notesFrame:SetSize(d.w or 420,d.h or 300)
    notesFrame.campaignID=campaign.id;notesFrame.title:SetText("DM NOTES — "..string.upper(campaign.name));notesFrame.edit:SetText(campaign.notes or "");notesFrame:Show();notesFrame:Raise()
end

-- ---------------------- INVITES / HEALTH REQUEST ----------------------
function RPAL.ShowDMInvite(inv)
    if not inviteFrame then
        local f=CreateFrame("Frame",nil,UIParent,"BackdropTemplate");inviteFrame=f;f:SetSize(450,230);f:SetPoint("CENTER");Layer(f,"MODAL",80,"FULLSCREEN_DIALOG",8080);Backdrop(f,Palette().main,Palette().gold);f:EnableMouse(true)
        f.title=Text(f,"DM SESSION INVITATION",15,Palette().gold,"OUTLINE");f.title:SetPoint("TOP",0,-20)
        f.body=Text(f,"",11,Palette().pale);f.body:SetPoint("TOPLEFT",22,-62);f.body:SetPoint("TOPRIGHT",-22,-62);f.body:SetJustifyH("CENTER");f.body:SetWordWrap(true)
        f.accept=Button(f,"Join Session",120,32,true);f.accept:SetPoint("BOTTOM",-67,22);f.decline=Button(f,"Decline",100,32);f.decline:SetPoint("LEFT",f.accept,"RIGHT",14,0)
        f.accept:SetScript("OnClick",function()local ok,res=DM.AcceptInvite();if not ok then StatusToast(res) end end);f.decline:SetScript("OnClick",function()DM.DeclineInvite()end)
    end
    inviteFrame.body:SetText((inv.hostTRP or ShortName(inv.host)).." is hosting:\n\n|cffffffff"..tostring(inv.campaignName or "DM Session").."|r")
    inviteFrame:Show();inviteFrame:Raise()
end
function RPAL.HideDMInvite()if inviteFrame then inviteFrame:Hide()end end

function RPAL.ShowDMHealthRequest(maxHP)
    local f=CreateFrame("Frame",nil,UIParent,"BackdropTemplate");f:SetSize(430,230);f:SetPoint("CENTER",0,40);Layer(f,"MODAL",90,"FULLSCREEN_DIALOG",8090);Backdrop(f,Palette().main,Palette().gold);f:EnableMouse(true)
    local t=Text(f,"PLAYER HEALTH SETUP",15,Palette().gold,"OUTLINE");t:SetPoint("TOP",0,-18)
    local body=Text(f,"The DM is requesting your current RP health.\nMaximum health: "..maxHP,10.5,Palette().pale);body:SetPoint("TOP",0,-54);body:SetWidth(390);body:SetJustifyH("CENTER")
    local e=Input(f,180,30);e:SetPoint("TOP",0,-112);e:SetNumeric(true);e:SetText(tostring(maxHP));e:HighlightText();e:SetFocus()
    local ok=Button(f,"Confirm",110,30,true);ok:SetPoint("BOTTOM",0,20)
    local function submit()local v=tonumber(e:GetText());if not v or v<0 or v>maxHP then StatusToast("Enter a value between 0 and "..maxHP..".");return end;local good,res=DM.SubmitHealthResponse(v,maxHP);if not good then StatusToast(res);return end;ClosePrompt(f)end
    ok:SetScript("OnClick",submit);e:SetScript("OnEnterPressed",submit)
end

-- ---------------------- NOTIFICATION TOAST ----------------------
function RPAL.ShowDMNotification(title,body)
    local f=CreateFrame("Frame",nil,UIParent,"BackdropTemplate");f:SetSize(500,120);f:SetPoint("TOP",0,-110);Layer(f,"TOAST",30,"TOOLTIP",14030);Backdrop(f,Palette().main,Palette().gold);f:EnableMouse(true)
    if RPAL.ApplyToastLayout then RPAL.ApplyToastLayout(f) end
    local h=Text(f,title or "DM Notification",14,Palette().gold,"OUTLINE");h:SetPoint("TOP",0,-18);h:SetWidth(460);h:SetJustifyH("CENTER")
    local b=Text(f,body or "",10.5,Palette().pale);b:SetPoint("TOPLEFT",20,-49);b:SetPoint("BOTTOMRIGHT",-20,18);b:SetJustifyH("CENTER");b:SetJustifyV("MIDDLE");b:SetWordWrap(true)
    f._age=0;f:SetScript("OnUpdate",function(self,e)self._age=self._age+e;if self._age>5 then local a=math.max(0,1-(self._age-5)/.5);self:SetAlpha(a);if a<=0 then self:Hide();self:SetScript("OnUpdate",nil) end end end)
    f:SetScript("OnMouseDown",function(self)self:Hide()end)
end

-- ---------------------- ENCOUNTER WIDGET ----------------------
local function EnsureEncounterWidget()
    if encounterWidget then return encounterWidget end
    local f=CreateFrame("Frame","RPAL_DMEncounterWidget",UIParent,"BackdropTemplate");encounterWidget=f;f:SetSize(350,60);f:SetFrameStrata("MEDIUM");f:SetMovable(true);f:SetClampedToScreen(true);f:EnableMouse(true);f:RegisterForDrag("LeftButton");Backdrop(f,Palette().main,Palette().border);f:Hide();f.rows={}
    local saved=DM.GetData and DM.GetData().encounterWindow
    if saved and tonumber(saved.x) and tonumber(saved.y) then
        f:SetPoint("CENTER",UIParent,"CENTER",tonumber(saved.x),tonumber(saved.y))
    else
        -- Default near the centre-right rather than hugging the screen edge.
        f:SetPoint("CENTER",UIParent,"CENTER",340,40)
    end
    f:SetScript("OnDragStart",function(self)self:StartMoving()end)
    f:SetScript("OnDragStop",function(self)
        self:StopMovingOrSizing()
        local _,_,_,x,y=self:GetPoint(1)
        local db=DM.GetData and DM.GetData()
        if db then db.encounterWindow={x=math.floor((x or 340)+.5),y=math.floor((y or 40)+.5)} end
    end)
    local title=Text(f,"ENCOUNTER",9,Palette().gold,"OUTLINE");title:SetPoint("TOPLEFT",10,-8);f.title=title
    local dragHint=Text(f,"drag",7.5,Palette().muted);dragHint:SetPoint("TOPRIGHT",-10,-8);f.dragHint=dragHint
    return f
end

function RPAL.RefreshDMEncounterWidget()
    local f=EnsureEncounterWidget();local state=DM.GetState();if not state then f:Hide();return end
    local markers={};for m,b in pairs(state.beacons or {}) do if b then markers[#markers+1]=m end end;table.sort(markers)
    if #markers==0 then f:Hide();return end
    for _,r in ipairs(f.rows) do r:Hide() end
    local p=Palette();local y=-29
    for i,marker in ipairs(markers) do
        local b=state.beacons[marker];local row=f.rows[i]
        if not row then
            -- Keep encounter rows visually light.  The widget itself already
            -- provides contrast; a second boxed background around every NPC
            -- made the beacon column feel crooked and detached from the rest
            -- of the card.
            row=CreateFrame("Button",nil,f);row:SetSize(330,55);f.rows[i]=row

            row.markerIcon=Text(row,"",14,p.pale,"OUTLINE")
            row.markerIcon:SetPoint("TOPLEFT",8,-4);row.markerIcon:SetWidth(42);row.markerIcon:SetHeight(24);row.markerIcon:SetJustifyH("CENTER");row.markerIcon:SetJustifyV("MIDDLE")
            row.markerName=Text(row,"",6.8,p.muted,"OUTLINE")
            row.markerName:SetPoint("TOPLEFT",4,-29);row.markerName:SetWidth(50);row.markerName:SetJustifyH("CENTER");row.markerName:SetWordWrap(false)

            row.name=Text(row,"",10.3,p.pale,"OUTLINE");row.name:SetPoint("TOPLEFT",62,-3);row.name:SetWidth(205);row.name:SetJustifyH("LEFT");row.name:SetWordWrap(false)
            row.npcTitle=Text(row,"",8.2,p.muted);row.npcTitle:SetPoint("TOPLEFT",62,-20);row.npcTitle:SetWidth(205);row.npcTitle:SetJustifyH("LEFT");row.npcTitle:SetWordWrap(false)
            row.bar=CreateHealthBar(row,145,9,p.border);row.bar:SetPoint("BOTTOMLEFT",62,5)
            row.hp=Text(row,"",7.9,p.pale,"OUTLINE");row.hp:SetWidth(68);row.hp:SetHeight(14);row.hp:SetJustifyH("RIGHT");row.hp:SetJustifyV("MIDDLE");row.hp:SetWordWrap(false)
            if row.hp.SetNonSpaceWrap then row.hp:SetNonSpaceWrap(false) end
            row.damage=Button(row,"Deal Damage",82,21);row.damage:SetPoint("BOTTOMRIGHT",-3,1)

            row.divider=row:CreateTexture(nil,"ARTWORK");row.divider:SetTexture(WHITE);row.divider:SetHeight(1);row.divider:SetPoint("BOTTOMLEFT",4,0);row.divider:SetPoint("BOTTOMRIGHT",-4,0);row.divider:SetVertexColor(p.border[1],p.border[2],p.border[3],.28)
        end
        row:SetPoint("TOPLEFT",10,y);y=y-58;row.marker=marker;row.markerIcon:SetText(RaidMarkerMarkup(marker))
        row.markerName:SetText(string.upper(DM.MARKER_NAMES[marker] or "BEACON"));row.name:SetText(b.name or "NPC");row.npcTitle:SetText((b.title and b.title~="") and b.title or "No title")
        local canDamage=state.role=="PARTICIPANT" and state.allowPlayerDamage
        -- Keep the health value on one line and reserve space for the optional
        -- participant DMG button.  Previously the narrow FontString wrapped
        -- values such as 100/100 around the slash.
        row.bar:ClearAllPoints();row.bar:SetPoint("BOTTOMLEFT",62,5);row.bar:SetWidth(canDamage and 112 or 164)
        row.hp:ClearAllPoints()
        if canDamage then
            row.hp:SetPoint("BOTTOMRIGHT",row,"BOTTOMRIGHT",-91,3)
            row.hp:SetWidth(66)
        else
            row.hp:SetPoint("BOTTOMRIGHT",row,"BOTTOMRIGHT",-8,3)
            row.hp:SetWidth(76)
        end
        SetHealthBar(row.bar,b.currentHP,b.maxHP,p.gold);row.hp:SetText((b.currentHP or 0).."/"..(b.maxHP or 0))
        row.damage:SetShown(canDamage);row.damage:SetScript("OnClick",function()DamageRequestPrompt(marker)end)
        row:SetScript("OnClick",function()if DM.IsHost() then NPCControlPrompt(marker) end end);row:Show()
    end
    f:SetHeight(38+#markers*58);f:Show()
end


-- ---------------------- V2 LIVE-DIRECTOR TOOLS ----------------------
local function TargetSelector(parent,y,includeHost)
    local state=DM.GetState();local selected={};local checks={};local all=Check(parent,"Everyone");all:SetPoint("TOPLEFT",18,y);all:SetChecked(true)
    local eligible=DM.GetRollEligiblePlayers and DM.GetRollEligiblePlayers(includeHost) or PlayerList(state,includeHost)
    for i,it in ipairs(eligible) do
        if i<=20 then
            local name,pdata=it.name,it.p;local cb=Check(parent,(pdata.trp or ShortName(name))..(pdata.isHost and " [DM]" or ""));cb:SetPoint("TOPLEFT",18+(((i-1)%2)*245),y-28-(math.floor((i-1)/2)*27));cb:SetChecked(false)
            cb:SetScript("OnClick",function(self)selected[name]=self:GetChecked() and true or nil;if self:GetChecked() then all:SetChecked(false) end end);checks[#checks+1]=cb
        end
    end
    all:SetScript("OnClick",function(self)if self:GetChecked() then selected={};for _,cb in ipairs(checks)do cb:SetChecked(false)end end end)
    return function()
        if all:GetChecked() then return nil end
        local out={};for name in pairs(selected)do out[#out+1]=name end;return out
    end
end

local function ScreenMessagePrompt()
    local f=CreateFrame("Frame",nil,UIParent,"BackdropTemplate");f:SetSize(560,680);f:SetPoint("CENTER");Layer(f,"MODAL",40,"FULLSCREEN_DIALOG",8040);Backdrop(f,Palette().main,Palette().gold);f:EnableMouse(true)
    local t=Text(f,"DM SCREEN MESSAGE",15,Palette().gold,"OUTLINE");t:SetPoint("TOP",0,-18)
    local l1=Text(f,"Title",9,Palette().muted);l1:SetPoint("TOPLEFT",18,-55);local title=Input(f,524,30);title:SetPoint("TOPLEFT",18,-73)
    local l2=Text(f,"Subtitle",9,Palette().muted);l2:SetPoint("TOPLEFT",18,-113);local sub=Input(f,524,58,true);sub:SetPoint("TOPLEFT",18,-131)
    local preset="CINEMATIC";local pLabel=Text(f,"Presentation",9,Palette().muted);pLabel:SetPoint("TOPLEFT",18,-202);local pb={}
    for i,name in ipairs({"SUBTLE","CINEMATIC","WARNING"}) do local b=Button(f,name,118,28,false);b:SetPoint("TOPLEFT",18+(i-1)*128,-220);pb[#pb+1]=b;SetButtonSelected(b,name=="CINEMATIC");b:SetScript("OnClick",function()preset=name;for _,x in ipairs(pb)do SetButtonSelected(x,false)end;SetButtonSelected(b,true)end) end
    local duration=5;local dLabel=Text(f,"Duration",9,Palette().muted);dLabel:SetPoint("TOPLEFT",410,-202);local db={};for i,v in ipairs({3,5,8}) do local b=Button(f,v.."s",38,28,false);b:SetPoint("TOPLEFT",410+(i-1)*43,-220);db[#db+1]=b;SetButtonSelected(b,v==5);b:SetScript("OnClick",function()duration=v;for _,x in ipairs(db)do SetButtonSelected(x,false)end;SetButtonSelected(b,true)end) end
    local targetLabel=Text(f,"Recipients",9,Palette().muted);targetLabel:SetPoint("TOPLEFT",18,-263);local getTargets=TargetSelector(f,-282,false)
    local send=Button(f,"Send Screen Message",165,32,true);send:SetPoint("BOTTOMRIGHT",-18,16);local cancel=Button(f,"Cancel",90,32);cancel:SetPoint("RIGHT",send,"LEFT",-8,0);cancel:SetScript("OnClick",function()ClosePrompt(f)end)
    send:SetScript("OnClick",function()local targets=getTargets();if targets and #targets==0 then StatusToast("Select at least one recipient.");return end;local ok,res=DM.SendScreenMessage(title:GetText(),sub:GetText(),duration,targets,preset);if not ok then StatusToast(res);return end;ClosePrompt(f)end)
end

local function AddNPCToCombatPrompt(onAdded)
    local state=DM.GetState();local init=state and state.initiative
    if not state or not init or not init.active or not DM.IsHost() then StatusToast("Combat is not active.");return end
    local inCombat={};for _,e in ipairs(init.entries or {}) do if e.kind=="NPC" and e.marker then inCombat[tonumber(e.marker)]=true end end
    local available={};for marker=1,8 do local b=state.beacons and state.beacons[marker];if b and not inCombat[marker] then available[#available+1]={marker=marker,b=b} end end
    if #available==0 then StatusToast("Every deployed NPC is already in Combat.");return end
    local h=math.min(520,115+#available*42)
    local f=CreateFrame("Frame",nil,UIParent,"BackdropTemplate");f:SetSize(430,h);f:SetPoint("CENTER");Layer(f,"MODAL",120,"FULLSCREEN_DIALOG",8120);Backdrop(f,Palette().main,Palette().gold);f:EnableMouse(true);f:Raise();promptFrames[#promptFrames+1]=f
    local title=Text(f,"ADD DEPLOYED NPC",14,Palette().gold,"OUTLINE");title:SetPoint("TOP",0,-18)
    local hint=Text(f,init.manual and "Adds the NPC at the end of the editable timeline." or "The NPC rolls D20 initiative and is inserted into the live order.",8.2,Palette().muted);hint:SetPoint("TOP",0,-42);hint:SetWidth(390);hint:SetJustifyH("CENTER")
    local y=-70
    for _,it in ipairs(available) do
        local marker,b=it.marker,it.b
        local row=Button(f,RaidMarkerMarkup(marker).."  "..tostring(b.name or "NPC"),390,32,false);row:SetPoint("TOP",0,y);row.text:SetJustifyH("LEFT");row.text:ClearAllPoints();row.text:SetPoint("LEFT",12,0);row.text:SetPoint("RIGHT",-12,0)
        row:SetScript("OnClick",function()local ok,res=DM.AddNPCToInitiative(marker);if not ok then StatusToast(res);return end;ClosePrompt(f);if onAdded then onAdded() end end)
        y=y-38
    end
    local close=Button(f,"Cancel",90,28);close:SetPoint("BOTTOMRIGHT",-18,14);close:SetScript("OnClick",function()ClosePrompt(f)end)
    FadeInFrame(f,.10)
end

local function InitiativeManagePrompt()
    local state=DM.GetState();local init=state and state.initiative;if not init then StatusToast("Combat is not active.");return end
    local f=CreateFrame("Frame",nil,UIParent,"BackdropTemplate");f:SetSize(560,math.min(700,170+#(init.entries or {})*46));f:SetPoint("CENTER");Layer(f,"MODAL",110,"FULLSCREEN_DIALOG",8110);Backdrop(f,Palette().main,Palette().gold);f:EnableMouse(true);f:Raise();promptFrames[#promptFrames+1]=f
    local t=Text(f,"COMBAT TIMELINE",15,Palette().gold,"OUTLINE");t:SetPoint("TOP",0,-18);local hint=Text(f,"Live editing is allowed. Drag rows or use the arrows; the current turn is preserved.",8.5,Palette().muted);hint:SetPoint("TOP",0,-42);hint:SetWidth(510);hint:SetJustifyH("CENTER")
    local function rebuild()
        if f.rows then for _,r in ipairs(f.rows)do r:Hide();r:SetParent(nil) end end;f.rows={};init=DM.GetInitiative();if not init then ClosePrompt(f);return end
        local y=-76
        for i,e in ipairs(init.entries or {}) do
            local row=CreateFrame("Button",nil,f,"BackdropTemplate");row:SetSize(524,40);row:SetPoint("TOPLEFT",18,y);Backdrop(row,i==init.index and Palette().accent or Palette().panel2,i==init.index and Palette().gold or Palette().border);row:SetMovable(true);row:RegisterForDrag("LeftButton");f.rows[#f.rows+1]=row
            local num=Text(row,tostring(i),9,Palette().gold,"OUTLINE");num:SetPoint("LEFT",10,0)
            local icon=Text(row,e.kind=="NPC" and RaidMarkerMarkup(e.marker or 1) or "",13,Palette().pale);icon:SetPoint("LEFT",34,0)
            local nm=Text(row,"",9.5,Palette().pale,"OUTLINE");nm:SetPoint("LEFT",58,0);nm:SetJustifyH("LEFT");FitLine(nm,e.name or "Unknown",245,9.5,7.0)
            local val=Text(row,(tonumber(e.value) or 0)>0 and ("INIT "..tostring(e.value)) or "MANUAL",7.8,Palette().gold,"OUTLINE");val:SetPoint("RIGHT",-108,0)
            local up=Button(row,"↑",36,26);up:SetPoint("RIGHT",-50,0);if up.label then up.label:SetWidth(30);up.label:SetJustifyH("CENTER") end;up:SetScript("OnClick",function()if i>1 then DM.MoveInitiativeEntry(i,i-1);rebuild() end end)
            local down=Button(row,"↓",36,26);down:SetPoint("RIGHT",-8,0);if down.label then down.label:SetWidth(30);down.label:SetJustifyH("CENTER") end;down:SetScript("OnClick",function()if i<#(init.entries or {}) then DM.MoveInitiativeEntry(i,i+1);rebuild() end end)
            -- Never StartMoving() the actual timeline row. Moving anchored rows caused
            -- duplicate/overlapping presentation artifacts after a reorder. The row
            -- stays in place while the cursor chooses the destination slot.
            row:SetScript("OnDragStart",function(self)self._dragIndex=i;self:SetAlpha(.50);self:SetBackdropBorderColor(Palette().gold[1],Palette().gold[2],Palette().gold[3],1)end)
            row:SetScript("OnDragStop",function(self)self:SetAlpha(1);local _,cy=GetCursorPosition();local scale=UIParent:GetEffectiveScale() or 1;cy=cy/scale;local top=f:GetTop()-76;local target=math.floor((top-cy)/46)+1;target=math.max(1,math.min(#(init.entries or {}),target));if target~=i then DM.MoveInitiativeEntry(i,target)end;rebuild()end)
            y=y-46
        end
    end
    rebuild()
    local prev=Button(f,"Previous Turn",110,30);prev:SetPoint("BOTTOMLEFT",18,16);prev:SetScript("OnClick",function()DM.PreviousTurn();rebuild()end)
    local nextb=Button(f,"Next Turn",110,30,true);nextb:SetPoint("LEFT",prev,"RIGHT",8,0);nextb:SetScript("OnClick",function()DM.NextTurn();rebuild()end)
    local addNPC=Button(f,"+ Add NPC",90,30);addNPC:SetPoint("LEFT",nextb,"RIGHT",8,0);addNPC:SetScript("OnClick",function()AddNPCToCombatPrompt(rebuild)end)
    local ending=Button(f,"End Combat",100,30);ending:SetPoint("LEFT",addNPC,"RIGHT",8,0);ending:SetScript("OnClick",function()DM.EndInitiative();ClosePrompt(f)end)
    local close=Button(f,"Close",82,30);close:SetPoint("BOTTOMRIGHT",-18,16);close:SetScript("OnClick",function()ClosePrompt(f)end)
end

local function InitiativePrompt()
    local state=DM.GetState();if not state or not DM.IsHost() then return end
    if state.initiative and state.initiative.active then InitiativeManagePrompt();return end
    local list=DM.GetRollEligiblePlayers and DM.GetRollEligiblePlayers(true) or PlayerList(state,true);local npcCount=0;for marker=1,8 do if state.beacons and state.beacons[marker] then npcCount=npcCount+1 end end
    local playerRows=math.max(1,math.ceil(math.min(#list,20)/2));local npcRows=math.max(1,math.ceil(npcCount/2));local frameH=math.max(500,math.min(760,320+playerRows*28+npcRows*28))
    local f=CreateFrame("Frame",nil,UIParent,"BackdropTemplate");f:SetSize(650,frameH);f:SetPoint("CENTER");Layer(f,"MODAL",100,"FULLSCREEN_DIALOG",8100);Backdrop(f,Palette().main,Palette().gold);f:EnableMouse(true);f:Raise();promptFrames[#promptFrames+1]=f
    local t=Text(f,"START COMBAT",15,Palette().gold,"OUTLINE");t:SetPoint("TOP",0,-18)
    local desc=Text(f,"Choose how the opening order is built. You can reorder the timeline at any time after Combat begins.",8.8,Palette().muted);desc:SetPoint("TOP",0,-43);desc:SetWidth(590);desc:SetJustifyH("CENTER")
    local mode="ROLL";local rollMode=Button(f,"ROLL INITIATIVE",150,30,true);rollMode:SetPoint("TOPLEFT",18,-76);local manualMode=Button(f,"ARRANGE MANUALLY",160,30);manualMode:SetPoint("LEFT",rollMode,"RIGHT",8,0);SetButtonSelected(rollMode,true)
    local modeInfo=Text(f,"Players roll D20 on the shared 3D roll screen. Deployed NPCs roll automatically.",8.2,Palette().muted);modeInfo:SetPoint("LEFT",manualMode,"RIGHT",12,0);modeInfo:SetWidth(270);modeInfo:SetWordWrap(true)
    local function setMode(v)mode=v;SetButtonSelected(rollMode,v=="ROLL");SetButtonSelected(manualMode,v=="MANUAL");modeInfo:SetText(v=="ROLL" and "Players roll D20 on the shared 3D roll screen. Deployed NPCs roll automatically." or "No dice are rolled. Combat opens immediately and you arrange the live timeline yourself.")end
    rollMode:SetScript("OnClick",function()setMode("ROLL")end);manualMode:SetScript("OnClick",function()setMode("MANUAL")end)
    local players=Text(f,"PLAYERS",9,Palette().gold,"OUTLINE");players:SetPoint("TOPLEFT",18,-126);local selectedPlayers={};local y=-151
    for i,it in ipairs(list) do if i<=20 then local name=it.name;local cb=Check(f,(it.p.trp or ShortName(name))..(it.p.isHost and "  [DM]" or ""));cb:SetPoint("TOPLEFT",18+(((i-1)%2)*285),y-(math.floor((i-1)/2)*28));cb:SetChecked(true);selectedPlayers[name]=true;cb:SetScript("OnClick",function(self)selectedPlayers[name]=self:GetChecked() and true or nil end) end end
    local npcY=y-(math.ceil(math.min(#list,20)/2)*28)-18;local npcl=Text(f,"DEPLOYED NPCS",9,Palette().gold,"OUTLINE");npcl:SetPoint("TOPLEFT",18,npcY);local selectedNPC={};local ni=0
    for marker=1,8 do local b=state.beacons and state.beacons[marker];if b then ni=ni+1;local m=marker;local cb=Check(f,RaidMarkerMarkup(marker).."  "..b.name);cb:SetPoint("TOPLEFT",18+(((ni-1)%2)*285),npcY-26-(math.floor((ni-1)/2)*28));cb:SetChecked(true);selectedNPC[m]=true;cb:SetScript("OnClick",function(self)selectedNPC[m]=self:GetChecked() and true or nil end) end end
    if ni==0 then local none=Text(f,"No deployed NPCs — player-only Combat is fine.",8.5,Palette().muted);none:SetPoint("TOPLEFT",18,npcY-26) end
    local hide=Check(f,"Hide future turn order from players (show current turn only)");hide:SetPoint("BOTTOMLEFT",18,68);hide:SetChecked(false)
    local startb=Button(f,"Start Combat",135,32,true);startb:SetPoint("BOTTOMRIGHT",-18,18);local cancel=Button(f,"Cancel",90,32);cancel:SetPoint("RIGHT",startb,"LEFT",-8,0);cancel:SetScript("OnClick",function()ClosePrompt(f)end)
    startb:SetScript("OnClick",function()
        local ps,ns={},{};for name in pairs(selectedPlayers)do ps[#ps+1]=name end;for marker in pairs(selectedNPC)do ns[#ns+1]=marker end
        local ok,res;if mode=="MANUAL" then ok,res=DM.StartInitiativeManual(ps,ns,hide:GetChecked()) else ok,res=DM.StartInitiative(ps,ns,hide:GetChecked()) end
        if not ok then StatusToast(res);return end;ClosePrompt(f);if mode=="MANUAL" then C_Timer.After(0,function()InitiativeManagePrompt()end) end
    end)
    FadeInFrame(f,.12)
end

local function EncounterTemplatesPrompt()
    local state=DM.GetState();local campaign=DM.GetActiveCampaign();if not campaign then StatusToast("Select a campaign first.");return end
    local f=CreateFrame("Frame",nil,UIParent,"BackdropTemplate");f:SetSize(620,500);f:SetPoint("CENTER");Layer(f,"MODAL",40,"FULLSCREEN_DIALOG",8040);Backdrop(f,Palette().main,Palette().gold);f:EnableMouse(true)
    local t=Text(f,"ENCOUNTER PRESETS",15,Palette().gold,"OUTLINE");t:SetPoint("TOP",0,-18);local sub=Text(f,campaign.name,9,Palette().muted);sub:SetPoint("TOP",0,-42)
    local listFrame=CreateFrame("Frame",nil,f);listFrame:SetPoint("TOPLEFT",18,-70);listFrame:SetPoint("BOTTOMRIGHT",-18,70);listFrame.rows={}
    local function rebuild()
        for _,r in ipairs(listFrame.rows)do r:Hide();r:SetParent(nil) end;listFrame.rows={};local y=0
        local templates=DM.GetEncounterPresets and DM.GetEncounterPresets(campaign.id) or DM.GetEncounterTemplates(campaign.id)
        for _,tpl in ipairs(templates) do
            local row=CreateFrame("Frame",nil,listFrame,"BackdropTemplate");row:SetSize(584,48);row:SetPoint("TOPLEFT",0,y);Backdrop(row,Palette().panel2,Palette().border);listFrame.rows[#listFrame.rows+1]=row
            local nm=Text(row,tpl.name,10,Palette().pale,"OUTLINE");nm:SetPoint("LEFT",10,7);nm:SetWidth(280);nm:SetJustifyH("LEFT")
            local meta=Text(row,tostring((function()local n=0;for _ in pairs(tpl.beacons or {})do n=n+1 end;return n end)()).." deployed NPC(s)",7.7,Palette().muted);meta:SetPoint("LEFT",10,-10)
            local load=Button(row,"Load",70,26,true);load:SetPoint("RIGHT",-86,0);load:SetScript("OnClick",function()
                if not DM.IsHost() then StatusToast("Start or resume a live session first.");return end
                local has=false;for _ in pairs(DM.GetState().beacons or {})do has=true break end
                local function doLoad()local ok,res=DM.LoadEncounterTemplate(tpl.id);if not ok then StatusToast(res)else ClosePrompt(f)end end
                if has then ConfirmPrompt("An encounter is already active. Replace it with "..tpl.name.."?",doLoad,"Replace Encounter") else doLoad() end
            end)
            local del=Button(row,"Delete",70,26);del:SetPoint("RIGHT",-8,0);del:SetScript("OnClick",function()ConfirmPrompt("Delete encounter preset '"..tpl.name.."'?",function()if DM.DeleteEncounterPreset then DM.DeleteEncounterPreset(campaign.id,tpl.id) else DM.DeleteEncounterTemplate(campaign.id,tpl.id) end;rebuild()end,"Delete")end)
            y=y-54
        end
        if #templates==0 then local e=Text(listFrame,"No encounter presets yet.",10,Palette().muted);e:SetPoint("TOP",0,-20);listFrame.rows[#listFrame.rows+1]=e end
    end
    rebuild()
    local save=Button(f,"Save Current Encounter",175,32,true);save:SetPoint("BOTTOMLEFT",18,18);save:SetScript("OnClick",function()if not DM.IsHost() then StatusToast("Start or resume a live session first.");return end;SimplePrompt("SAVE ENCOUNTER PRESET","Preset name","Encounter",function(v)local ok,res=(DM.SaveEncounterPreset and DM.SaveEncounterPreset(v,DM._selectedFolder) or DM.SaveEncounterTemplate(v,DM._selectedFolder));if not ok then return false,res end;rebuild();return true end)end)
    local close=Button(f,"Close",90,32);close:SetPoint("BOTTOMRIGHT",-18,18);close:SetScript("OnClick",function()ClosePrompt(f)end)
end

-- Non-modal cinematic message. It never captures mouse/keyboard input.
function RPAL.ShowDMScreenMessage(title,subtitle,duration,preset,themeHex)
    local f=screenMessageFrame
    if not f then
        f=CreateFrame("Frame","RPAL_DMScreenMessage",UIParent);screenMessageFrame=f;f:SetSize(760,190);f:SetPoint("CENTER",0,120);Layer(f,"OVERLAY",700,"FULLSCREEN_DIALOG",6700);f:EnableMouse(false);f:Hide()
        f.rule=f:CreateTexture(nil,"ARTWORK");f.rule:SetTexture(WHITE);f.rule:SetSize(520,2);f.rule:SetPoint("CENTER",0,8)
        f.title=Text(f,"",28,Palette().gold,"OUTLINE");f.title:SetPoint("BOTTOM",f.rule,"TOP",0,19);f.title:SetWidth(730);f.title:SetJustifyH("CENTER")
        f.sub=Text(f,"",12,Palette().pale,"OUTLINE");f.sub:SetPoint("TOP",f.rule,"BOTTOM",0,-18);f.sub:SetWidth(650);f.sub:SetJustifyH("CENTER");f.sub:SetWordWrap(true)
        f.glow=f:CreateTexture(nil,"BACKGROUND");f.glow:SetTexture("Interface\\Buttons\\WHITE8X8");f.glow:SetPoint("CENTER",0,8);f.glow:SetSize(620,120);f.glow:SetVertexColor(0,0,0,.16)
    end
    local accent=HexColor(themeHex,Palette().gold);preset=tostring(preset or "CINEMATIC"):upper();if preset=="WARNING" then accent={1,.28,.18,1} end
    f.title:SetText(title or "");f.sub:SetText(subtitle or "");f.rule:SetVertexColor(accent[1],accent[2],accent[3],.9);f.title:SetTextColor(accent[1],accent[2],accent[3],1);f.sub:SetTextColor(Palette().pale[1],Palette().pale[2],Palette().pale[3],1)
    f:SetScale(preset=="SUBTLE" and .82 or 1);duration=tonumber(duration) or 5;f._age=0;f:SetAlpha(0);f:Show();f:SetScript("OnUpdate",function(self,e)self._age=self._age+(e or 0);local t=self._age;local a=math.min(1,t/.28);if t>duration-.45 then a=math.max(0,(duration-t)/.45)end;self:SetAlpha(a);if t>=duration then self:Hide();self:SetScript("OnUpdate",nil)end end)
end

-- Shared initiative timeline; participant cards inherit each player's synced
-- RPAL theme/portrait frame/badge just like the drawer roster.
local function EnsureInitiativeFrame()
    if initiativeFrame then return initiativeFrame end
    local p=Palette();local f=CreateFrame("Frame","RPAL_DMInitiativeTimeline",UIParent,"BackdropTemplate");initiativeFrame=f;f:SetSize(1080,202);f:SetPoint("TOP",UIParent,"TOP",0,-72);f:SetFrameStrata("HIGH");f:SetFrameLevel(280);Backdrop(f,p.main,p.border);f:SetMovable(true);f:EnableMouse(true);f:RegisterForDrag("LeftButton");f:SetScript("OnDragStart",function(self)if not self._scrollDragging then self:StartMoving()end end);f:SetScript("OnDragStop",function(self)self:StopMovingOrSizing()end);f:Hide();f.cards={}
    f.title=Text(f,"COMBAT TIMELINE",10,p.gold,"OUTLINE");f.title:SetPoint("TOPLEFT",14,-10)
    f.elapsed=Text(f,"00:00",8,p.muted,"OUTLINE");f.elapsed:SetPoint("TOP",0,-11)
    f.round=Text(f,"",8,p.muted,"OUTLINE");f.round:SetPoint("TOPRIGHT",-14,-10)
    f.scroll=CreateFrame("ScrollFrame",nil,f);f.scroll:SetPoint("TOPLEFT",12,-34);f.scroll:SetPoint("BOTTOMRIGHT",-12,12);f.scroll:EnableMouseWheel(true);f.scroll:EnableMouse(true)
    f.content=CreateFrame("Frame",nil,f.scroll);f.content:SetHeight(148);f.scroll:SetScrollChild(f.content)
    f.scroll:SetScript("OnMouseWheel",function(self,delta)local max=math.max(0,(f.content:GetWidth() or 0)-(self:GetWidth() or 0));self:SetHorizontalScroll(math.max(0,math.min(max,(self:GetHorizontalScroll() or 0)-(delta*120))))end)
    f.scroll:SetScript("OnMouseDown",function(self,button)if button~="LeftButton" then return end;local x=GetCursorPosition();local scale=UIParent:GetEffectiveScale() or 1;f._scrollDragging=true;f._dragStartX=x/scale;f._dragStartScroll=self:GetHorizontalScroll() or 0;self:SetScript("OnUpdate",function(sf)local cx=GetCursorPosition();cx=cx/(UIParent:GetEffectiveScale() or 1);local max=math.max(0,(f.content:GetWidth() or 0)-(sf:GetWidth() or 0));sf:SetHorizontalScroll(math.max(0,math.min(max,f._dragStartScroll-(cx-f._dragStartX))))end)end)
    f.scroll:SetScript("OnMouseUp",function(self)f._scrollDragging=false;self:SetScript("OnUpdate",nil)end)
    f.edit=Button(f,"Edit",58,24);f.edit:SetPoint("TOPRIGHT",-152,-6);f.edit:SetScript("OnClick",InitiativeManagePrompt);f.edit:Hide()
    f:SetScript("OnHide",function(self)if self._elapsedTicker and self._elapsedTicker.Cancel then self._elapsedTicker:Cancel() end;self._elapsedTicker=nil;if self.scroll then self.scroll:SetScript("OnUpdate",nil)end end)
    return f
end

local function UpdateCombatElapsed(f,init)
    if not f or not f.elapsed then return end;local sec=math.max(0,time()-(tonumber(init and init.startedAt) or time()));local m=math.floor(sec/60);local s=sec%60;f.elapsed:SetText(string.format("%02d:%02d",m,s))
end

function RPAL.RefreshDMInitiativeUI()
    local f=EnsureInitiativeFrame();local state=DM.GetState();local init=state and state.initiative
    for _,c in ipairs(f.cards or {})do c:Hide();c:SetParent(nil)end;f.cards={}
    if not state or not init or not init.active or #(init.entries or {})==0 then f:Hide();return end
    local desiredW=math.max(390,math.min(1120,(#(init.entries or {})*176)+30));f:SetWidth(desiredW)
    if not f:IsShown() then FadeInFrame(f,.16) else f:Show() end;f.edit:SetShown(DM.IsHost() and true or false);f.round:SetText("ROUND "..tostring(init.round or 1).."  ·  TURN "..tostring(init.index or 1).."/"..#init.entries);UpdateCombatElapsed(f,init)
    if not f._elapsedTicker and C_Timer and C_Timer.NewTicker then f._elapsedTicker=C_Timer.NewTicker(1,function()local st=DM.GetState();local ii=st and st.initiative;if not ii or not ii.active or not f:IsShown() then if f._elapsedTicker and f._elapsedTicker.Cancel then f._elapsedTicker:Cancel() end;f._elapsedTicker=nil;return end;UpdateCombatElapsed(f,ii)end) end
    local entries={};local host=DM.IsHost();if init.hideOrder and not host then entries={init.entries[init.index]} else for _,e in ipairs(init.entries)do entries[#entries+1]=e end end
    local cardW=176;local contentW=math.max(f.scroll:GetWidth() or 0,#entries*cardW);f.content:SetWidth(contentW)
    local x=0
    for visibleIndex,e in ipairs(entries) do
        local actualIndex=1;for i,v in ipairs(init.entries)do if v==e or (v.id and v.id==e.id) then actualIndex=i break end end;local current=actualIndex==(init.index or 1)
        local pdata=e.kind=="PLAYER" and state.participants and state.participants[e.player] or nil;local pp=pdata and ParticipantPalette(pdata) or Palette();local c=CreateFrame("Frame",nil,f.content,"BackdropTemplate");c:SetSize(cardW-8,136);c:SetPoint("TOPLEFT",x,-4);Backdrop(c,current and (pp.accent or Palette().accent) or Palette().panel2,current and (pp.gold or Palette().gold) or (pp.border or Palette().border));f.cards[#f.cards+1]=c
        local sequence=Text(c,"#"..tostring(actualIndex),7,pp.muted or Palette().muted,"OUTLINE");sequence:SetPoint("TOPRIGHT",-7,-6)
        if e.kind=="PLAYER" then local portrait=AddParticipantPortrait(c,e.player,pdata,52);portrait:SetPoint("CENTER",0,8) else local icon=Text(c,RaidMarkerMarkup(e.marker or 1),27,Palette().pale);icon:SetPoint("CENTER",0,8);icon:SetWidth(72);icon:SetJustifyH("CENTER") end
        local nm=Text(c,"",8.8,pp.pale or Palette().pale,"OUTLINE");nm:SetPoint("BOTTOM",0,31);nm:SetJustifyH("CENTER");FitLine(nm,e.name or "Unknown",cardW-22,8.8,6.8)
        local val=Text(c,(tonumber(e.value) or 0)>0 and ("INIT "..tostring(e.value)) or "MANUAL ORDER",7.2,pp.gold or Palette().gold,"OUTLINE");val:SetPoint("BOTTOM",0,14)
        if e.kind=="NPC" and e.marker and state.beacons and state.beacons[e.marker] and (state.beacons[e.marker].currentHP or 1)<=0 then c:SetAlpha(.42);val:SetText("DEFEATED") end
        if current then local now=Text(c,"CURRENT TURN",7.3,pp.gold or Palette().gold,"OUTLINE");now:SetPoint("TOPLEFT",7,-6);local pulse=c:CreateAnimationGroup();local a=pulse:CreateAnimation("Alpha");a:SetFromAlpha(.45);a:SetToAlpha(1);a:SetDuration(.28);pulse:Play() end
        local enter=c:CreateAnimationGroup();local alpha=enter:CreateAnimation("Alpha");alpha:SetFromAlpha(0);alpha:SetToAlpha(1);alpha:SetDuration(.18);alpha:SetStartDelay(math.min(.20,(visibleIndex-1)*.025));enter:Play()
        x=x+cardW
    end
    if f._lastInitIndex~=(init.index or 1) and not (init.hideOrder and not host) then local viewport=f.scroll:GetWidth() or 1000;local target=((init.index or 1)-1)*cardW-(viewport-cardW)/2;local max=math.max(0,contentW-viewport);f.scroll:SetHorizontalScroll(math.max(0,math.min(max,target)));f._lastInitIndex=init.index or 1 end
end

-- ---------------------- DRAWER ----------------------
local function EnsureDrawer()
    if drawer then return drawer end
    local p=Palette()
    local f=CreateFrame("Frame","RPAL_DMDrawer",UIParent,"BackdropTemplate");drawer=f;f:SetSize(W,680);f:SetPoint("LEFT",UIParent,"LEFT",-W,0);f:SetFrameStrata("HIGH");f:SetFrameLevel(250);Backdrop(f,p.main,p.border,8);f:EnableMouse(true);f.open=false;f.targetX=-W;f.currentX=-W
    local wash=f:CreateTexture(nil,"BACKGROUND",nil,-1);wash:SetTexture(WHITE);wash:SetPoint("TOPLEFT",4,-4);wash:SetPoint("BOTTOMRIGHT",-4,4);wash:SetVertexColor(p.panel[1],p.panel[2],p.panel[3],.34);f.wash=wash
    local edge=f:CreateTexture(nil,"ARTWORK");edge:SetTexture(WHITE);edge:SetWidth(1);edge:SetPoint("TOPRIGHT",-2,-7);edge:SetPoint("BOTTOMRIGHT",-2,7);edge:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],.48);f.edge=edge
    local tab=CreateFrame("Button","RPAL_DMDrawerTab",UIParent,"BackdropTemplate");drawerTab=tab;tab:SetSize(36,118);tab:SetFrameStrata("HIGH");tab:SetFrameLevel(260);Backdrop(tab,p.panel2,p.gold,7);tab:Hide()
    tab.text=Text(tab,"DM",11,p.gold,"OUTLINE");tab.text:SetPoint("CENTER",0,13);tab.arrow=Text(tab,"›",19,p.pale,"OUTLINE");tab.arrow:SetPoint("CENTER",0,-18)
    local function PositionDrawer(x)
        f.currentX=x
        f:ClearAllPoints();f:SetPoint("LEFT",UIParent,"LEFT",x,0)
        tab:ClearAllPoints();tab:SetPoint("LEFT",UIParent,"LEFT",x+W-1,0)
    end
    local function StartDrawerAnimation()
        if f:GetScript("OnUpdate") then return end
        f:SetScript("OnUpdate",function(self,e)
            local d=(self.targetX-self.currentX)*math.min(1,(e or 0)*13)
            local nextX=self.currentX+d
            if math.abs(self.targetX-nextX)<.4 then
                nextX=self.targetX
                PositionDrawer(nextX)
                self:SetScript("OnUpdate",nil)
                return
            end
            PositionDrawer(nextX)
        end)
    end
    tab:SetScript("OnClick",function()
        f.open=not f.open;f.targetX=f.open and 0 or -W;tab.arrow:SetText(f.open and "‹" or "›")
        if f.open and RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer(true);FadeInFrame(f.content,.12) end
        StartDrawerAnimation()
    end)
    PositionDrawer(-W)
    f.header=CreateFrame("Frame",nil,f,"BackdropTemplate");f.header:SetPoint("TOPLEFT",7,-7);f.header:SetPoint("TOPRIGHT",-7,-7);f.header:SetHeight(66);Backdrop(f.header,p.panel2,p.border,7)
    local cap=f.header:CreateTexture(nil,"ARTWORK");cap:SetTexture(WHITE);cap:SetHeight(2);cap:SetPoint("TOPLEFT",6,-3);cap:SetPoint("TOPRIGHT",-6,-3);cap:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],.72);f.headerCap=cap
    local head=Text(f.header,"DM KIT",15,p.gold,"OUTLINE");head:SetPoint("TOPLEFT",10,-11);f.head=head
    local campaign=Text(f.header,"No active session",10.5,p.pale,"OUTLINE");campaign:SetPoint("TOPLEFT",10,-33);campaign:SetWidth(215);campaign:SetJustifyH("LEFT");campaign:SetWordWrap(false);f.campaign=campaign
    local status=Text(f.header,"",7.9,p.muted,"OUTLINE");status:SetPoint("BOTTOMLEFT",10,8);f.status=status
    local role=CreateFrame("Frame",nil,f.header,"BackdropTemplate");role:SetSize(68,22);role:SetPoint("TOPRIGHT",-8,-10);Backdrop(role,p.button,p.border,7);f.roleBadge=role
    role.text=Text(role,"OFFLINE",7.5,p.muted,"OUTLINE");role.text:SetPoint("CENTER")
    local scroll=CreateFrame("ScrollFrame",nil,f,"UIPanelScrollFrameTemplate");f.scroll=scroll;scroll:SetPoint("TOPLEFT",8,-82);scroll:SetPoint("BOTTOMRIGHT",-28,10)
    if RPAL.SkinScrollFrame then RPAL.SkinScrollFrame(scroll) end
    local content=CreateFrame("Frame",nil,scroll);f.content=content;content:SetSize(W-42,1);scroll:SetScrollChild(content);f.dynamic={}
    return f
end

local function ClearDynamic(parent)
    for _,x in ipairs(parent.dynamic or {}) do if x.Hide then x:Hide() end;if x.SetParent then x:SetParent(nil) end end
    parent.dynamic={}
end
local function Keep(parent,x)parent.dynamic[#parent.dynamic+1]=x;return x end

local function DrawerButton(content,label,x,y,w,click,accent)
    local b=Keep(content,Button(content,label,w or 137,29,accent));b:SetPoint("TOPLEFT",x,y);if click then b:SetScript("OnClick",click)end;return b
end

local function DrawerSection(content,label,y)
    local p=Palette();local t=Keep(content,Text(content,label,8.5,p.gold,"OUTLINE"));t:SetPoint("TOPLEFT",8,y)
    local line=Keep(content,content:CreateTexture(nil,"ARTWORK"));line:SetTexture(WHITE);line:SetHeight(1);line:SetPoint("LEFT",t,"RIGHT",7,0);line:SetPoint("RIGHT",content,-10,0);line:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],.20)
    return y-22
end

local function CreateDrawerPartyRow(content,y,name,pdata,host)
    local p=Palette();local pp=ParticipantPalette(pdata);local row=Keep(content,CreateFrame("Button",nil,content,"BackdropTemplate"));row:SetSize(262,74);row:SetPoint("TOPLEFT",8,y);Backdrop(row,{p.panel2[1],p.panel2[2],p.panel2[3],.88},pp.border or p.border,7)
    local stripe=row:CreateTexture(nil,"ARTWORK");stripe:SetTexture(WHITE);stripe:SetWidth(3);stripe:SetPoint("TOPLEFT",2,-4);stripe:SetPoint("BOTTOMLEFT",2,4);local ac=pp.gold or p.gold;stripe:SetVertexColor(ac[1],ac[2],ac[3],.92)
    local portrait=AddParticipantPortrait(row,name,pdata,44);portrait:SetPoint("LEFT",8,4)
    local nm=Text(row,"",9.6,pp.pale or p.pale,"OUTLINE");nm:SetPoint("TOPLEFT",60,-7);nm:SetJustifyH("LEFT");FitLine(nm,pdata.trp or ShortName(name),190,9.6,7.2)
    local meta=(pdata.isHost and "DM / HOST" or (pdata.disconnected and "PAUSED ROSTER · REINVITE" or tostring(pdata.theme or "ODYN PARTICIPANT")))
    local mt=Text(row,"",7.1,pp.muted or p.muted,"OUTLINE");mt:SetPoint("TOPLEFT",60,-23);mt:SetJustifyH("LEFT");FitLine(mt,meta,190,7.1,6.2)
    local statuses={};for _,label in pairs(pdata.statusEffects or {}) do statuses[#statuses+1]=label end;table.sort(statuses)
    local statusText=Text(row,"",6.8,pp.gold or p.gold,"OUTLINE");statusText:SetPoint("TOPLEFT",60,-39);statusText:SetJustifyH("LEFT");FitLine(statusText,#statuses>0 and ("• "..table.concat(statuses,"  •  ")) or "",190,6.8,6.0)
    local hpText=Text(row,"",7.8,pp.pale or p.pale,"OUTLINE");hpText:SetPoint("BOTTOMRIGHT",-8,7);hpText:SetJustifyH("RIGHT");FitLine(hpText,pdata.currentHP~=nil and ((pdata.currentHP or 0).." / "..(pdata.maxHP or 0)) or "HP not set",58,7.8,6.5)
    local bar=CreateHealthBar(row,124,8,pp.border or p.border);bar:SetPoint("BOTTOMLEFT",60,7)
    if pdata.currentHP~=nil and pdata.maxHP then SetHealthBar(bar,pdata.currentHP,pdata.maxHP,pp.gold or p.gold) else SetHealthBar(bar,0,1,pp.gold or p.gold);bar:SetAlpha(.35) end
    if host then row:SetScript("OnClick",function()PlayerControlPrompt(name)end) end
    row:SetScript("OnEnter",function(self)self:SetBackdropBorderColor(ac[1],ac[2],ac[3],.9)end)
    row:SetScript("OnLeave",function(self)local b=pp.border or p.border;self:SetBackdropBorderColor(b[1],b[2],b[3],b[4] or 1)end)
    return y-79
end

function RPAL.TeardownDMSessionUI()
    -- Session teardown must not depend on the user's Enable DM Kit setting. A
    -- participant may have joined with DM Kit disabled, in which case leaving
    -- or concluding previously left an orphan drawer tab / encounter surface.
    if encounterWidget then encounterWidget:Hide() end
    if initiativeFrame then
        initiativeFrame:Hide()
        if initiativeFrame._elapsedTicker and initiativeFrame._elapsedTicker.Cancel then initiativeFrame._elapsedTicker:Cancel() end
        initiativeFrame._elapsedTicker=nil
        if initiativeFrame.scroll then initiativeFrame.scroll:SetScript("OnUpdate",nil) end
    end
    if screenMessageFrame then screenMessageFrame:Hide();screenMessageFrame:SetScript("OnUpdate",nil) end
    if not DM.GetState() and not DM.IsEnabled() then
        local f=drawer or EnsureDrawer()
        if drawerTab then drawerTab:Hide() end
        if f then
            f.open=false;f.targetX=-W;f.currentX=-W;f:SetScript("OnUpdate",nil)
            f:ClearAllPoints();f:SetPoint("LEFT",UIParent,"LEFT",-W,0);f:Hide()
        end
    end
end

function RPAL.RefreshDMDrawer(force)
    local f=EnsureDrawer();local state=DM.GetState();local show=DM.IsEnabled() or state~=nil
    drawerTab:SetShown(show);if not show then f.open=false;f.targetX=-W;f.currentX=-W;f:ClearAllPoints();f:SetPoint("LEFT",UIParent,"LEFT",-W,0);f:Hide();return else f:Show() end
    -- The closed drawer is fully off-screen. Defer its expensive dynamic rebuild
    -- until the user actually opens it; live encounter widgets still refresh.
    if not force and not f.open then
        f._refreshPending=true
        if RPAL.RefreshDMEncounterWidget then RPAL.RefreshDMEncounterWidget() end
        return
    end
    f._refreshPending=nil
    ClearDynamic(f.content)
    local p=Palette();local content=f.content;local y=-4
    if not state then
        f.head:SetText("DM KIT");FitLine(f.campaign,"Campaign preparation",215,10.5,7.5);f.status:SetText("OFFLINE · files and notes available");f.roleBadge.text:SetText("OFFLINE")
        local c=DM.GetActiveCampaign();local cName=c and c.name or "No campaign selected"
        local card=Keep(content,CreateFrame("Frame",nil,content,"BackdropTemplate"));card:SetSize(262,60);card:SetPoint("TOPLEFT",8,y);Backdrop(card,p.panel2,p.border)
        local tag=Text(card,"ACTIVE CAMPAIGN",7.2,p.muted,"OUTLINE");tag:SetPoint("TOPLEFT",10,-9)
        local t=Text(card,"",10.5,p.pale,"OUTLINE");t:SetPoint("TOPLEFT",10,-28);t:SetJustifyH("LEFT");FitLine(t,cName,240,10.5,7.5);y=y-70
        DrawerButton(content,"OPEN FULL DM KIT",8,y,262,function()RPAL.OpenDMKit()end,true);y=y-36
        if c then
            if DM.HasPausedCampaign and DM.HasPausedCampaign(c.id) then
                DrawerButton(content,"RESUME PAUSED CAMPAIGN",8,y,262,function()local ok,res=DM.ResumeCampaign(c.id);if not ok then StatusToast(res)end end,true)
            else
                DrawerButton(content,"START LIVE SESSION",8,y,262,function()local ok,res=DM.StartSession(c.id);if not ok then StatusToast(res)end end,true)
            end
            y=y-36
        end
        DrawerButton(content,"OPEN NOTES",8,y,127,function()RPAL.OpenDMNotes()end);DrawerButton(content,"PRESETS",143,y,127,function()EncounterTemplatesPrompt()end);y=y-38
        f.content:SetHeight(math.max(1,-y+20));return
    end

    local host=DM.IsHost();f.head:SetText(host and "DM KIT" or "DM SESSION");FitLine(f.campaign,state.campaignName or "DM Session",215,10.5,7.5)
    local count=0;for _,pp in pairs(state.participants or {}) do if not pp.isHost then count=count+1 end end
    f.status:SetText((host and "LIVE · " or "CONNECTED · ")..count.." player"..(count==1 and "" or "s"));f.roleBadge.text:SetText(host and "HOST" or "PLAYER")

    if host then
        y=DrawerSection(content,"QUICK ACTIONS",y)
        DrawerButton(content,"Roll Check",8,y,127,function()RollPrompt()end,true);DrawerButton(content,"Screen Message",143,y,127,function()ScreenMessagePrompt()end,true);y=y-34
        DrawerButton(content,state.initiative and "Combat Timeline" or "Start Combat",8,y,127,function()InitiativePrompt()end,true);DrawerButton(content,"Notify",143,y,127,function()NotificationPrompt()end);y=y-34
        DrawerButton(content,"Deploy NPC",8,y,127,function()DeployPrompt()end);DrawerButton(content,"Open Notes",143,y,127,function()RPAL.OpenDMNotes()end);y=y-34
        DrawerButton(content,"Presets",8,y,127,function()EncounterTemplatesPrompt()end);DrawerButton(content,"Party Actions",143,y,127,function()PartyActionsPrompt()end,true);y=y-34
        DrawerButton(content,"Invite Target",8,y,127,function()local ok,res=DM.InviteUnit("target");if not ok then StatusToast(res)else StatusToast("DM invitation sent.")end end);DrawerButton(content,"Invite Group",143,y,127,function()local ok,res=DM.InviteGroup();if not ok then StatusToast(res)else StatusToast("Invited "..tostring(res).." group player(s).")end end);y=y-34
        DrawerButton(content,"Pause & Save",8,y,127,function()ConfirmPrompt("Pause and save this campaign so it can be resumed later?",function()DM.PauseCampaign()end,"Pause Campaign")end);DrawerButton(content,"Full DM Kit",143,y,127,function()RPAL.OpenDMKit()end);y=y-40
        local options=Keep(content,CreateFrame("Frame",nil,content,"BackdropTemplate"));options:SetSize(262,66);options:SetPoint("TOPLEFT",8,y);Backdrop(options,p.panel2,p.border)
        local damage=Check(options,"Allow player damage input");damage:SetPoint("TOPLEFT",7,-7);damage:SetChecked(state.allowPlayerDamage);damage:SetScript("OnClick",function(self)DM.SetAllowPlayerDamage(self:GetChecked())end)
        local visibility=Check(options,"Players can see party health");visibility:SetPoint("TOPLEFT",7,-36);visibility:SetChecked(state.playerHealthVisible);visibility:SetScript("OnClick",function(self)DM.SetPlayerHealthVisibility(self:GetChecked())end);y=y-76

        if state.initiative and state.initiative.active then
            y=DrawerSection(content,"TURN TRACKER",y)
            local init=state.initiative;local current=init.entries and init.entries[init.index or 1]
            local turnCard=Keep(content,CreateFrame("Frame",nil,content,"BackdropTemplate"));turnCard:SetSize(262,62);turnCard:SetPoint("TOPLEFT",8,y);Backdrop(turnCard,p.panel2,p.gold)
            local k=Text(turnCard,"CURRENT TURN · ROUND "..tostring(init.round or 1),7.4,p.muted,"OUTLINE");k:SetPoint("TOPLEFT",10,-8)
            local nm=Text(turnCard,"",11,p.pale,"OUTLINE");nm:SetPoint("TOPLEFT",10,-27);nm:SetJustifyH("LEFT");FitLine(nm,current and current.name or "—",180,11,8)
            local val=Text(turnCard,current and tostring(current.value or "") or "",10,p.gold,"OUTLINE");val:SetPoint("RIGHT",-10,0);y=y-69
            DrawerButton(content,"‹ PREVIOUS",8,y,127,function()DM.PreviousTurn()end);DrawerButton(content,"NEXT TURN ›",143,y,127,function()DM.NextTurn()end,true);y=y-38
        end

        y=DrawerSection(content,"DEPLOYED NPCS",y)
        local any=false
        for marker=1,8 do local b=state.beacons and state.beacons[marker];if b then
            any=true;local row=Keep(content,CreateFrame("Button",nil,content,"BackdropTemplate"));row:SetSize(262,55);row:SetPoint("TOPLEFT",8,y);Backdrop(row,p.panel2,p.border)
            local icon=Text(row,RaidMarkerMarkup(marker),11,p.pale,"OUTLINE");icon:SetPoint("LEFT",8,5);icon:SetWidth(25);icon:SetJustifyH("CENTER")
            local nm=Text(row,"",9.2,p.pale,"OUTLINE");nm:SetPoint("TOPLEFT",40,-7);nm:SetJustifyH("LEFT");FitLine(nm,b.name,140,9.2,7)
            local tt=Text(row,"",7.2,p.muted);tt:SetPoint("TOPLEFT",40,-23);tt:SetJustifyH("LEFT");FitLine(tt,(b.title and b.title~="") and b.title or string.upper(DM.MARKER_NAMES[marker] or "BEACON"),145,7.2,6.1)
            local hp=Text(row,b.currentHP.." / "..b.maxHP,7.8,p.pale,"OUTLINE");hp:SetPoint("TOPRIGHT",-8,-8);hp:SetWidth(65);hp:SetJustifyH("RIGHT")
            local bar=CreateHealthBar(row,202,8,p.border);bar:SetPoint("BOTTOMLEFT",40,8);SetHealthBar(bar,b.currentHP,b.maxHP,p.gold)
            row:SetScript("OnClick",function()NPCControlPrompt(marker)end);y=y-60
        end end
        if not any then local none=Keep(content,Text(content,"No NPCs deployed.",9,p.muted));none:SetPoint("TOPLEFT",12,y);y=y-25 end
        y=y-5
    end

    y=DrawerSection(content,"PARTY",y)
    local players=PlayerList(state,true);local visible=0
    for _,it in ipairs(players) do
        local name,pdata=it.name,it.p
        if (host or state.playerHealthVisible) then y=CreateDrawerPartyRow(content,y,name,pdata,host);visible=visible+1 end
    end
    if visible==0 then local none=Keep(content,Text(content,"Waiting for players…",9,p.muted));none:SetPoint("TOPLEFT",12,y);y=y-25 end
    y=y-5

    if host then
        local pending=DM.GetPendingDamage();if #pending>0 then DrawerButton(content,"PENDING DAMAGE  ("..#pending..")",8,y,262,function()PendingPrompt()end,true);y=y-40 end
    end

    y=DrawerSection(content,"RECENT NOTIFICATIONS",y)
    local notifications=DM.GetNotifications();local shown=0
    for i=#notifications,1,-1 do if shown<3 then local n=notifications[i];local tx=Keep(content,Text(content,FormatTime(n.t).."  "..n.title,8.5,p.pale));tx:SetPoint("TOPLEFT",12,y);tx:SetWidth(250);tx:SetJustifyH("LEFT");tx:SetWordWrap(false);y=y-20;shown=shown+1 end end
    if shown==0 then local none=Keep(content,Text(content,"No notifications yet.",8.5,p.muted));none:SetPoint("TOPLEFT",12,y);y=y-20 end
    DrawerButton(content,"View Notifications",8,y-2,262,function()NotificationHistoryPrompt()end);y=y-38

    y=DrawerSection(content,"RECENT ACTIVITY",y)
    local logs=DM.GetVisibleLog();local countLogs=0
    for i=#logs,1,-1 do if countLogs<4 then local e=logs[i];local tx=Keep(content,Text(content,FormatLogLine(e),8.1,p.muted));tx:SetPoint("TOPLEFT",12,y);tx:SetWidth(250);tx:SetJustifyH("LEFT");tx:SetWordWrap(true);local h=math.max(18,tx:GetStringHeight()+3);y=y-h;countLogs=countLogs+1 end end
    DrawerButton(content,"View Full Log",8,y-4,262,function()LogPrompt()end);y=y-38
    if host then
        DrawerButton(content,"Stop Campaign Session",8,y-4,262,function()DM.EndSession()end)
    else
        DrawerButton(content,"Leave Session",8,y-4,262,function()DM.LeaveSession()end)
    end
    y=y-42
    f.content:SetHeight(math.max(1,-y+20));RPAL.RefreshDMEncounterWidget()
end

function RPAL.RefreshDMKitAvailability()
    RPAL.RefreshDMDrawer()
    if mainFrame and mainFrame:IsShown() then RPAL.RefreshDMKitUI() end
end

-- ---------------------- FULL DM KIT ----------------------
local MAIN_LAYOUTS={
    EXPLORER={label="Explorer",w=1320,h=820,desc="Three-column file manager"},
    DIRECTOR={label="Director",w=1360,h=840,desc="Campaign/files stacked beside live direction"},
    LIBRARY={label="Library",w=1380,h=850,desc="Explorer left, files above live controls"},
    WIDE={label="Wide Studio",w=1450,h=880,desc="Large file canvas with navigator/control stack"},
}

local function CurrentLayoutName()
    local db=DM.GetData and DM.GetData() or {};local name=tostring(db.layoutName or "EXPLORER"):upper()
    return MAIN_LAYOUTS[name] and name or "EXPLORER"
end

local function LayoutMainPanels(f,name)
    if not f or not f.left or not f.center or not f.right then return end
    name=tostring(name or CurrentLayoutName()):upper();local w=math.max(980,f:GetWidth() or 1320);local h=math.max(620,f:GetHeight() or 820)
    local top=-70;local bottom=12;local gap=8;local usableH=h-82
    f.left:ClearAllPoints();f.center:ClearAllPoints();f.right:ClearAllPoints();f.left:SetSize(1,1);f.center:SetSize(1,1);f.right:SetSize(1,1)
    if name=="DIRECTOR" then
        local rightW=math.floor(w*.35);local leftW=w-rightW-32
        local split=math.floor(usableH*.43)
        f.right:SetPoint("TOPRIGHT",-12,top);f.right:SetPoint("BOTTOMRIGHT",-12,bottom);f.right:SetWidth(rightW)
        f.left:SetPoint("TOPLEFT",12,top);f.left:SetPoint("TOPRIGHT",f.right,"TOPLEFT",-gap,0);f.left:SetHeight(split)
        f.center:SetPoint("TOPLEFT",f.left,"BOTTOMLEFT",0,-gap);f.center:SetPoint("BOTTOMRIGHT",f.right,"BOTTOMLEFT",-gap,bottom)
    elseif name=="LIBRARY" then
        local leftW=math.floor(w*.29);local rightArea=w-leftW-32;local topH=math.floor(usableH*.61)
        f.left:SetPoint("TOPLEFT",12,top);f.left:SetPoint("BOTTOMLEFT",12,bottom);f.left:SetWidth(leftW)
        f.center:SetPoint("TOPLEFT",f.left,"TOPRIGHT",gap,0);f.center:SetPoint("TOPRIGHT",-12,top);f.center:SetHeight(topH)
        f.right:SetPoint("TOPLEFT",f.center,"BOTTOMLEFT",0,-gap);f.right:SetPoint("BOTTOMRIGHT",-12,bottom)
    elseif name=="WIDE" then
        local navW=math.floor(w*.30);local topH=math.floor(usableH*.50)
        f.left:SetPoint("TOPLEFT",12,top);f.left:SetWidth(navW);f.left:SetHeight(topH)
        f.right:SetPoint("TOPLEFT",f.left,"BOTTOMLEFT",0,-gap);f.right:SetPoint("BOTTOMLEFT",12,bottom);f.right:SetWidth(navW)
        f.center:SetPoint("TOPLEFT",f.left,"TOPRIGHT",gap,0);f.center:SetPoint("BOTTOMRIGHT",-12,bottom)
    else
        local leftW=math.floor(w*.235);local rightW=math.floor(w*.275)
        f.left:SetPoint("TOPLEFT",12,top);f.left:SetPoint("BOTTOMLEFT",12,bottom);f.left:SetWidth(leftW)
        f.right:SetPoint("TOPRIGHT",-12,top);f.right:SetPoint("BOTTOMRIGHT",-12,bottom);f.right:SetWidth(rightW)
        f.center:SetPoint("TOPLEFT",f.left,"TOPRIGHT",gap,0);f.center:SetPoint("BOTTOMRIGHT",f.right,"BOTTOMLEFT",-gap,0)
    end
end

local function ApplyMainLayout(f,name,keepUserSize)
    if not f then return end
    name=tostring(name or CurrentLayoutName()):upper();local cfg=MAIN_LAYOUTS[name] or MAIN_LAYOUTS.EXPLORER
    local db=DM.GetData and DM.GetData();if db then db.layoutName=name end
    f._applyingDMLayout=true
    local sw,sh
    if keepUserSize and db and tonumber(db.mainWindowW) and tonumber(db.mainWindowH) then sw,sh=tonumber(db.mainWindowW),tonumber(db.mainWindowH) else sw,sh=cfg.w,cfg.h end
    f:SetSize(math.max(1040,math.min(1650,sw)),math.max(650,math.min(980,sh)))
    LayoutMainPanels(f,name)
    f._applyingDMLayout=false
    if f.headerSub then f.headerSub:SetText("Campaign Studio  •  Drag files into folders  •  Live Direction  ·  "..cfg.label) end
end

local function SetMainLayout(name)
    local f=mainFrame;if not f then return end
    local db=DM.GetData and DM.GetData();if db then db.mainWindowW=nil;db.mainWindowH=nil end
    ApplyMainLayout(f,name,false)
    if f.layoutButtons then for key,b in pairs(f.layoutButtons) do SetButtonSelected(b,key==CurrentLayoutName()) end end
    if f.optionsMenu then f.optionsMenu:Hide() end;if f.layoutMenu then f.layoutMenu:Hide() end
    if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end
    FadeInFrame(f.left,.16);FadeInFrame(f.center,.19);FadeInFrame(f.right,.22)
end


local function OpenCampaignStringTransfer(importMode)
    local p=Palette();local campaign=DM.GetActiveCampaign()
    if not importMode and not campaign then StatusToast("Select a campaign first.");return end
    local payload=""
    if not importMode then
        local err;payload,err=DM.ExportCampaign(campaign.id)
        if not payload then StatusToast(err or "Campaign export failed.");return end
    end
    local f=CreateFrame("Frame",nil,UIParent,"BackdropTemplate");f:SetSize(820,610);f:SetPoint("CENTER");Layer(f,"MODAL",140,"FULLSCREEN_DIALOG",8140);f:EnableMouse(true);Backdrop(f,p.main,p.gold);f:Raise();promptFrames[#promptFrames+1]=f
    local title=Text(f,importMode and "IMPORT DM CAMPAIGN" or "EXPORT DM CAMPAIGN",16,p.gold,"OUTLINE");title:SetPoint("TOPLEFT",20,-18)
    local hint=Text(f,importMode and "Paste a complete ODYN campaign export. Imported campaigns are always added as a separate campaign." or "Copy this complete string. It contains preparation files only — never live players, paused state or session history.",9,p.muted);hint:SetPoint("TOPLEFT",20,-47);hint:SetWidth(775);hint:SetWordWrap(true)
    local box=CreateFrame("Frame",nil,f,"BackdropTemplate");box:SetPoint("TOPLEFT",18,-84);box:SetPoint("BOTTOMRIGHT",-18,66);Backdrop(box,p.panel2,p.border)
    local scroll=CreateFrame("ScrollFrame",nil,box,"UIPanelScrollFrameTemplate");scroll:SetPoint("TOPLEFT",8,-8);scroll:SetPoint("BOTTOMRIGHT",-28,8)
    if RPAL.SkinScrollFrame then RPAL.SkinScrollFrame(scroll) end
    local edit=CreateFrame("EditBox",nil,scroll);edit:SetMultiLine(true);edit:SetAutoFocus(importMode and true or false);edit:SetFont(FONT,10,"");edit:SetTextColor(p.pale[1],p.pale[2],p.pale[3],1);edit:SetWidth(744);edit:SetTextInsets(3,3,3,3);edit:SetText(payload);edit:SetCursorPosition(0);scroll:SetScrollChild(edit)
    edit:SetScript("OnTextChanged",function(self)self:SetHeight(math.max(430,(self:GetNumLetters() or 0)/90*15+40));scroll:UpdateScrollChildRect()end)
    edit:SetHeight(math.max(430,#payload/90*15+40))
    local close=Button(f,"Close",100,32);close:SetPoint("BOTTOMRIGHT",-18,18);close:SetScript("OnClick",function()ClosePrompt(f)end)
    if importMode then
        local import=Button(f,"Import Campaign",140,32,true);import:SetPoint("RIGHT",close,"LEFT",-8,0);import:SetScript("OnClick",function()
            local ok,result=DM.ImportCampaign(edit:GetText())
            if not ok then StatusToast(result or "Campaign import failed.");return end
            ClosePrompt(f);StatusToast("Campaign imported: "..tostring(result and result.name or "Campaign"));if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end
        end)
    else
        local select=Button(f,"Select All",110,32,true);select:SetPoint("RIGHT",close,"LEFT",-8,0);select:SetScript("OnClick",function()edit:SetFocus();edit:HighlightText()end)
    end
end

local function EnsureMain()
    if mainFrame then return mainFrame end
    local p=Palette()
    local f=CreateFrame("Frame","RPAL_DMKitMain",UIParent,"BackdropTemplate");mainFrame=f
    f:SetSize(1320,820);f:SetPoint("CENTER");if RPAL.ApplyUILayer then RPAL.ApplyUILayer(f,"WORKSPACE")else f:SetFrameStrata("DIALOG");f:SetFrameLevel(320);f:SetToplevel(true)end
    Backdrop(f,p.main,p.gold);f:SetMovable(true);f:SetResizable(true);if f.SetResizeBounds then f:SetResizeBounds(1040,650,1650,980) end;f:SetClampedToScreen(true);f:EnableMouse(true)
    f:RegisterForDrag("LeftButton");f:SetScript("OnDragStart",function(self)self:StartMoving()end);f:SetScript("OnDragStop",function(self)self:StopMovingOrSizing()end);f:Hide();f.dynamic={}

    -- A stronger, quieter masthead makes the window read as a standalone tool
    -- rather than another three-column RPAL page.
    f.header=CreateFrame("Frame",nil,f,"BackdropTemplate");f.header:SetPoint("TOPLEFT",8,-8);f.header:SetPoint("TOPRIGHT",-8,-8);f.header:SetHeight(54);Backdrop(f.header,p.panel2,p.border)
    local accent=f.header:CreateTexture(nil,"ARTWORK");accent:SetTexture(WHITE);accent:SetHeight(2);accent:SetPoint("BOTTOMLEFT",8,2);accent:SetPoint("BOTTOMRIGHT",-8,2);accent:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],.65);f.headerAccent=accent
    local title=Text(f.header,"DM KIT",18,p.gold,"OUTLINE");title:SetPoint("TOPLEFT",286,-9);f.headerTitle=title
    local sub=Text(f.header,"Campaign Explorer  •  NPC Library  •  Live Direction",9.2,p.muted);sub:SetPoint("TOPLEFT",287,-32);f.headerSub=sub
    local options=Button(f.header,"OPTIONS  ▾",100,28);options:SetPoint("LEFT",10,0);f.optionsButton=options
    local campaignSwitch=Button(f.header,"CAMPAIGN  ▾",150,28);campaignSwitch:SetPoint("LEFT",options,"RIGHT",6,0);f.campaignSwitch=campaignSwitch
    local campaignMenu=CreateFrame("Frame",nil,f,"BackdropTemplate");f.campaignMenu=campaignMenu;campaignMenu:SetSize(240,90);campaignMenu:SetPoint("TOPLEFT",campaignSwitch,"BOTTOMLEFT",0,-3);campaignMenu:SetFrameLevel(f:GetFrameLevel()+74);Backdrop(campaignMenu,p.panel2,p.gold);campaignMenu:Hide();campaignMenu.dynamic={}
    local function RebuildCampaignSwitchMenu()
        for _,x in ipairs(campaignMenu.dynamic or {}) do if x.Hide then x:Hide() end;if x.SetParent then x:SetParent(nil) end end;campaignMenu.dynamic={}
        local campaigns=DM.GetCampaigns();local h=math.max(52,14+#campaigns*34);campaignMenu:SetHeight(math.min(330,h));local y=-10
        if #campaigns==0 then local e=Text(campaignMenu,"No campaigns yet.",9,p.muted);e:SetPoint("TOPLEFT",14,y);campaignMenu.dynamic[#campaignMenu.dynamic+1]=e;return end
        for _,c in ipairs(campaigns) do local cc=c;local b=Button(campaignMenu,cc.name,212,28,DM.GetActiveCampaign() and DM.GetActiveCampaign().id==cc.id);b:SetPoint("TOPLEFT",14,y);y=y-34;campaignMenu.dynamic[#campaignMenu.dynamic+1]=b;b:SetScript("OnClick",function()campaignMenu:Hide();DM.SetActiveCampaign(cc.id);DM._selectedFolder=nil;DM._selectedNPCs={};RPAL.RefreshDMKitUI()end) end
    end
    campaignSwitch:SetScript("OnClick",function()local show=not campaignMenu:IsShown();if show then RebuildCampaignSwitchMenu();if RPAL.SetOpenDropdown then RPAL.SetOpenDropdown(campaignMenu)end;FadeInFrame(campaignMenu,.10) else FadeOutHide(campaignMenu,.08) end end)
    local optionsMenu=CreateFrame("Frame",nil,f,"BackdropTemplate");f.optionsMenu=optionsMenu;optionsMenu:SetSize(194,118);optionsMenu:SetPoint("TOPLEFT",f.header,"BOTTOMLEFT",10,-3);optionsMenu:SetFrameLevel(f:GetFrameLevel()+60);Backdrop(optionsMenu,p.panel2,p.gold);optionsMenu:Hide()
    local layoutButton=Button(optionsMenu,"Layout  ›",166,28);layoutButton:SetPoint("TOPLEFT",14,-10)
    local transferButton=Button(optionsMenu,"Campaign Transfer  ›",166,28);transferButton:SetPoint("TOPLEFT",14,-44)
    local resetButton=Button(optionsMenu,"Reset Window Position",166,28);resetButton:SetPoint("TOPLEFT",14,-78);resetButton:SetScript("OnClick",function()f:ClearAllPoints();f:SetPoint("CENTER");optionsMenu:Hide();if f.transferMenu then f.transferMenu:Hide() end end)
    local layoutMenu=CreateFrame("Frame",nil,f,"BackdropTemplate");f.layoutMenu=layoutMenu;layoutMenu:SetSize(238,166);layoutMenu:SetPoint("TOPLEFT",optionsMenu,"TOPRIGHT",4,0);layoutMenu:SetFrameLevel(f:GetFrameLevel()+70);Backdrop(layoutMenu,p.panel2,p.gold);layoutMenu:Hide()
    local transferMenu=CreateFrame("Frame",nil,f,"BackdropTemplate");f.transferMenu=transferMenu;transferMenu:SetSize(242,132);transferMenu:SetPoint("TOPLEFT",optionsMenu,"TOPRIGHT",4,-34);transferMenu:SetFrameLevel(f:GetFrameLevel()+72);Backdrop(transferMenu,p.panel2,p.gold);transferMenu:Hide()
    local exportString=Button(transferMenu,"Export as String",214,30,false);exportString:SetPoint("TOPLEFT",14,-12);exportString:SetScript("OnClick",function()transferMenu:Hide();optionsMenu:Hide();OpenCampaignStringTransfer(false)end)
    local importString=Button(transferMenu,"Import from String",214,30,false);importString:SetPoint("TOPLEFT",14,-49);importString:SetScript("OnClick",function()transferMenu:Hide();optionsMenu:Hide();OpenCampaignStringTransfer(true)end)
    local sendOnline=Button(transferMenu,"Send via Online Exchange",214,30,true);sendOnline:SetPoint("TOPLEFT",14,-86);sendOnline:SetScript("OnClick",function()
        local c=DM.GetActiveCampaign();if not c then StatusToast("Select a campaign first.");return end
        transferMenu:Hide();optionsMenu:Hide();if RPAL.OpenDMCampaignOnlineExchange then RPAL.OpenDMCampaignOnlineExchange(c.id) else StatusToast("Online Exchange is unavailable.") end
    end)
    local ly=-10;f.layoutButtons={}
    for _,key in ipairs({"EXPLORER","DIRECTOR","LIBRARY","WIDE"}) do
        local layoutKey=key;local cfg=MAIN_LAYOUTS[layoutKey];local b=Button(layoutMenu,cfg.label,210,32,false);b:SetPoint("TOPLEFT",14,ly);ly=ly-37
        f.layoutButtons[layoutKey]=b;SetButtonSelected(b,layoutKey==CurrentLayoutName())
        b:SetScript("OnClick",function()SetMainLayout(layoutKey)end)
    end
    layoutButton:SetScript("OnClick",function()local show=not layoutMenu:IsShown();layoutMenu:SetShown(show);transferMenu:Hide()end)
    transferButton:SetScript("OnClick",function()local show=not transferMenu:IsShown();transferMenu:SetShown(show);layoutMenu:Hide()end)
    options:SetScript("OnClick",function()CloseDMContextMenus();campaignMenu:Hide();local shown=not optionsMenu:IsShown();if shown then if RPAL.SetOpenDropdown then RPAL.SetOpenDropdown(optionsMenu)end;FadeInFrame(optionsMenu,.10) else FadeOutHide(optionsMenu,.08) end;if not shown then layoutMenu:Hide();transferMenu:Hide() end end)
    local close=Button(f.header,"×",30,28);close:SetPoint("RIGHT",-9,0);close:SetScript("OnClick",function()CloseDMContextMenus();f:Hide();optionsMenu:Hide();layoutMenu:Hide();transferMenu:Hide();campaignMenu:Hide()end);f.closeButton=close

    f.left=CreateFrame("Frame",nil,f,"BackdropTemplate");Backdrop(f.left,p.panel,p.border);f.left.dynamic={}
    f.center=CreateFrame("Frame",nil,f,"BackdropTemplate");Backdrop(f.center,p.panel,p.border);f.center.dynamic={}
    f.right=CreateFrame("Frame",nil,f,"BackdropTemplate");Backdrop(f.right,p.panel,p.border);f.right.dynamic={}

    -- Bottom-left resize grip. Layout recalculation runs only while the window
    -- actually changes size, never as an idle OnUpdate.
    local grip=CreateFrame("Button",nil,f);f.resizeGrip=grip;grip:SetSize(30,30);grip:SetPoint("BOTTOMLEFT",3,3);grip:RegisterForDrag("LeftButton")
    local g1=grip:CreateTexture(nil,"ARTWORK");g1:SetTexture(WHITE);g1:SetSize(14,1);g1:SetPoint("BOTTOMLEFT",5,7);g1:SetRotation(math.rad(45));g1:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],.70)
    local g2=grip:CreateTexture(nil,"ARTWORK");g2:SetTexture(WHITE);g2:SetSize(20,1);g2:SetPoint("BOTTOMLEFT",4,11);g2:SetRotation(math.rad(45));g2:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],.45)
    grip:SetScript("OnDragStart",function()f:StartSizing("BOTTOMLEFT")end)
    grip:SetScript("OnDragStop",function()f:StopMovingOrSizing();local db=DM.GetData and DM.GetData();if db then db.mainWindowW=math.floor(f:GetWidth()+.5);db.mainWindowH=math.floor(f:GetHeight()+.5) end;LayoutMainPanels(f,CurrentLayoutName());if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end end)
    f:SetScript("OnSizeChanged",function(self)if not self._applyingDMLayout then LayoutMainPanels(self,CurrentLayoutName()) end end)
    ApplyMainLayout(f,CurrentLayoutName(),true)
    if UISpecialFrames then table.insert(UISpecialFrames,"RPAL_DMKitMain") end
    return f
end

local function ClearPanel(panel)for _,x in ipairs(panel.dynamic or {})do if x.Hide then x:Hide()end;if x.SetParent then x:SetParent(nil)end end;panel.dynamic={}end
local function PKeep(panel,x)panel.dynamic[#panel.dynamic+1]=x;return x end


-- ---------------------- Campaign Studio drag/drop + file assets ----------------------
local dmDragGhost
local function MouseFocusForDM()
    if GetMouseFoci then
        local ok,t=pcall(GetMouseFoci)
        if ok and type(t)=="table" then return t[1] end
    end
    if GetMouseFocus then local ok,t=pcall(GetMouseFocus);if ok then return t end end
end
local function FindDMDropTarget(frame)
    local f=frame;local safety=0
    while f and safety<12 do
        if f._dmDropCampaignID then return f._dmDropCampaignID,f._dmDropFolderID end
        f=f.GetParent and f:GetParent() or nil;safety=safety+1
    end
end
local function BeginDMFileDrag(payload,label)
    if not payload then return end;DM._dragPayload=payload
    if not dmDragGhost then
        dmDragGhost=CreateFrame("Frame",nil,UIParent,"BackdropTemplate");dmDragGhost:SetSize(210,32);dmDragGhost:SetFrameStrata("TOOLTIP");dmDragGhost:SetFrameLevel(9000);dmDragGhost:EnableMouse(false);Backdrop(dmDragGhost,Palette().panel2,Palette().gold)
        dmDragGhost.text=Text(dmDragGhost,"",9,Palette().pale,"OUTLINE");dmDragGhost.text:SetPoint("LEFT",10,0);dmDragGhost.text:SetPoint("RIGHT",-10,0);dmDragGhost.text:SetJustifyH("LEFT");dmDragGhost.text:SetWordWrap(false)
    end
    dmDragGhost.text:SetText("MOVE  ·  "..tostring(label or "Campaign File"));dmDragGhost:Show()
    dmDragGhost:SetScript("OnUpdate",function(self)
        local x,y=GetCursorPosition();local scale=UIParent:GetEffectiveScale() or 1;x=x/scale;y=y/scale
        self:ClearAllPoints();self:SetPoint("BOTTOMLEFT",UIParent,"BOTTOMLEFT",x+16,y-12)
    end)
end
local function FinishDMFileDrag()
    local payload=DM._dragPayload;DM._dragPayload=nil
    if dmDragGhost then dmDragGhost:SetScript("OnUpdate",nil);dmDragGhost:Hide() end
    if not payload then return end
    local campaignID,folderID=FindDMDropTarget(MouseFocusForDM());if not campaignID or campaignID~=payload.campaignID then return end
    if folderID==false then folderID=nil end
    local ok,res
    if payload.kind=="FOLDER" then ok,res=DM.MoveSubFolder(campaignID,payload.id,folderID) else ok,res=DM.MoveCampaignFile(campaignID,payload.kind,payload.id,folderID) end
    if not ok then StatusToast(res or "Could not move that file.") else RPAL.RefreshDMKitUI() end
end
local function FolderPath(campaign,folderID)
    if not folderID then return "Campaign Root" end
    local bits={};local id=folderID;local safety=0
    while id and safety<30 do local f=campaign.folders and campaign.folders[id];if not f then break end;table.insert(bits,1,f.name or "Folder");id=f.parentID;safety=safety+1 end
    return #bits>0 and table.concat(bits," / ") or "Campaign Root"
end
local function FolderDescendsFrom(campaign,folderID,ancestor)
    if not ancestor then return true end
    local id=folderID;local safety=0
    while id and safety<40 do if id==ancestor then return true end;local f=campaign.folders and campaign.folders[id];id=f and f.parentID or nil;safety=safety+1 end
    return false
end

local function OpenNoteFilePrompt(item)
    local c=DM.GetActiveCampaign();if not c then return end
    local p=Palette();local f=CreateFrame("Frame",nil,UIParent,"BackdropTemplate");f:SetSize(610,500);f:SetPoint("CENTER");Layer(f,"MODAL",120,"FULLSCREEN_DIALOG",8120);Backdrop(f,p.main,p.gold);f:EnableMouse(true);f:Raise();promptFrames[#promptFrames+1]=f
    local title=Text(f,item and "EDIT NOTE FILE" or "NEW NOTE FILE",15,p.gold,"OUTLINE");title:SetPoint("TOPLEFT",18,-18)
    local name=Input(f,574,30);name:SetPoint("TOPLEFT",18,-58);name:SetText(item and item.name or "Notes")
    local body=Input(f,574,340,true);body:SetPoint("TOPLEFT",18,-101);body:SetText(item and item.body or "")
    local cancel=Button(f,"Cancel",90,31);cancel:SetPoint("BOTTOMLEFT",18,18);cancel:SetScript("OnClick",function()ClosePrompt(f)end)
    local save=Button(f,"Save Note",112,31,true);save:SetPoint("BOTTOMRIGHT",-18,18);save:SetScript("OnClick",function()
        local ok,res;if item then ok,res=DM.UpdateNoteFile(c.id,item.id,{name=name:GetText(),body=body:GetText()}) else ok,res=DM.CreateNoteFile(c.id,name:GetText(),body:GetText(),DM._selectedFolder) end
        if not ok then StatusToast(res);return end;ClosePrompt(f)
    end);name:SetFocus();name:HighlightText()
end

local function NewNotificationPresetPrompt()
    local c=DM.GetActiveCampaign();if not c then return end;local p=Palette();local f=CreateFrame("Frame",nil,UIParent,"BackdropTemplate");f:SetSize(560,405);f:SetPoint("CENTER");Layer(f,"MODAL",120,"FULLSCREEN_DIALOG",8120);Backdrop(f,p.main,p.gold);f:EnableMouse(true);f:Raise();promptFrames[#promptFrames+1]=f
    local t=Text(f,"NEW NOTIFICATION PRESET",15,p.gold,"OUTLINE");t:SetPoint("TOPLEFT",18,-18)
    local n=Input(f,524,28);n:SetPoint("TOPLEFT",18,-58);n:SetText("Notification")
    local ttl=Input(f,524,28);ttl:SetPoint("TOPLEFT",18,-108);ttl:SetText("Attention")
    local body=Input(f,524,150,true);body:SetPoint("TOPLEFT",18,-158)
    local labels={{"File name",-42},{"Notification title",-92},{"Message",-142}};for _,x in ipairs(labels)do local z=Text(f,x[1],8,p.muted);z:SetPoint("TOPLEFT",18,x[2])end
    local save=Button(f,"Create Preset",125,30,true);save:SetPoint("BOTTOMRIGHT",-18,18);save:SetScript("OnClick",function()local ok,res=DM.CreateNotificationPreset(c.id,n:GetText(),ttl:GetText(),body:GetText(),DM._selectedFolder);if not ok then StatusToast(res);return end;ClosePrompt(f)end)
    local cancel=Button(f,"Cancel",90,30);cancel:SetPoint("RIGHT",save,"LEFT",-8,0);cancel:SetScript("OnClick",function()ClosePrompt(f)end)
end

local function NewRollPresetPrompt()
    local c=DM.GetActiveCampaign();if not c then return end;local p=Palette();local f=CreateFrame("Frame",nil,UIParent,"BackdropTemplate");f:SetSize(610,430);f:SetPoint("CENTER");Layer(f,"MODAL",120,"FULLSCREEN_DIALOG",8120);Backdrop(f,p.main,p.gold);f:EnableMouse(true);f:Raise();promptFrames[#promptFrames+1]=f
    local t=Text(f,"NEW ROLL CHECK PRESET",15,p.gold,"OUTLINE");t:SetPoint("TOPLEFT",18,-18)
    local n=Input(f,574,28);n:SetPoint("TOPLEFT",18,-58);n:SetText("Perception")
    local ttl=Input(f,574,28);ttl:SetPoint("TOPLEFT",18,-108);ttl:SetText("Perception Check")
    local info=Input(f,574,62,true);info:SetPoint("TOPLEFT",18,-158)
    for _,d in ipairs({{"File name",-42},{"Roll title",-92},{"Additional info",-142}})do local z=Text(f,d[1],8,p.muted);z:SetPoint("TOPLEFT",18,d[2])end
    local selected=20;local buttons={};local dice={4,6,8,12,20,100};for i,v in ipairs(dice)do local b=Button(f,"D"..v,80,28);b:SetPoint("TOPLEFT",18+(i-1)*95,-246);buttons[#buttons+1]=b;SetButtonSelected(b,v==selected);b:SetScript("OnClick",function()selected=v;for _,x in ipairs(buttons)do SetButtonSelected(x,false)end;SetButtonSelected(b,true)end)end
    local dc=Input(f,140,28);dc:SetNumeric(true);dc:SetPoint("TOPLEFT",18,-312);local lab=Text(f,"Minimum success (optional)",8,p.muted);lab:SetPoint("BOTTOMLEFT",dc,"TOPLEFT",0,4)
    local save=Button(f,"Create Preset",125,30,true);save:SetPoint("BOTTOMRIGHT",-18,18);save:SetScript("OnClick",function()local ok,res=DM.CreateRollPreset(c.id,n:GetText(),ttl:GetText(),info:GetText(),selected,dc:GetText(),DM._selectedFolder);if not ok then StatusToast(res);return end;ClosePrompt(f)end)
    local cancel=Button(f,"Cancel",90,30);cancel:SetPoint("RIGHT",save,"LEFT",-8,0);cancel:SetScript("OnClick",function()ClosePrompt(f)end)
end

local function NewScreenPresetPrompt()
    local c=DM.GetActiveCampaign();if not c then return end;local p=Palette();local f=CreateFrame("Frame",nil,UIParent,"BackdropTemplate");f:SetSize(600,430);f:SetPoint("CENTER");Layer(f,"MODAL",120,"FULLSCREEN_DIALOG",8120);Backdrop(f,p.main,p.gold);f:EnableMouse(true);f:Raise();promptFrames[#promptFrames+1]=f
    local t=Text(f,"NEW SCREEN MESSAGE PRESET",15,p.gold,"OUTLINE");t:SetPoint("TOPLEFT",18,-18)
    local n=Input(f,564,28);n:SetPoint("TOPLEFT",18,-58);n:SetText("Cinematic Message")
    local ttl=Input(f,564,28);ttl:SetPoint("TOPLEFT",18,-108);ttl:SetText("THE STORY CONTINUES")
    local sub=Input(f,564,62,true);sub:SetPoint("TOPLEFT",18,-158)
    for _,d in ipairs({{"File name",-42},{"Screen title",-92},{"Subtitle",-142}})do local z=Text(f,d[1],8,p.muted);z:SetPoint("TOPLEFT",18,d[2])end
    local presentation="CINEMATIC";local pb={};for i,v in ipairs({"SUBTLE","CINEMATIC","WARNING"})do local b=Button(f,v,116,28);b:SetPoint("TOPLEFT",18+(i-1)*126,-245);pb[#pb+1]=b;SetButtonSelected(b,v==presentation);b:SetScript("OnClick",function()presentation=v;for _,x in ipairs(pb)do SetButtonSelected(x,false)end;SetButtonSelected(b,true)end)end
    local duration=Input(f,100,28);duration:SetNumeric(true);duration:SetText("5");duration:SetPoint("TOPLEFT",18,-310);local dl=Text(f,"Duration (seconds)",8,p.muted);dl:SetPoint("BOTTOMLEFT",duration,"TOPLEFT",0,4)
    local save=Button(f,"Create Preset",125,30,true);save:SetPoint("BOTTOMRIGHT",-18,18);save:SetScript("OnClick",function()local ok,res=DM.CreateScreenPreset(c.id,n:GetText(),ttl:GetText(),sub:GetText(),duration:GetText(),presentation,DM._selectedFolder);if not ok then StatusToast(res);return end;ClosePrompt(f)end)
    local cancel=Button(f,"Cancel",90,30);cancel:SetPoint("RIGHT",save,"LEFT",-8,0);cancel:SetScript("OnClick",function()ClosePrompt(f)end)
end

local function NewCampaignFileMenu(anchor)
    local c=DM.GetActiveCampaign();if not c then return end
    if mainFrame and mainFrame._campaignFileMenu and mainFrame._campaignFileMenu:IsShown() then mainFrame._campaignFileMenu:Hide();mainFrame._campaignFileMenu=nil;return end
    if mainFrame and mainFrame._campaignFileMenu then mainFrame._campaignFileMenu:Hide();mainFrame._campaignFileMenu=nil end
    local p=Palette();local m=CreateFrame("Frame",nil,mainFrame or UIParent,"BackdropTemplate");m:SetSize(228,240);Layer(m,"POPOVER",100,"TOOLTIP",12100);Backdrop(m,p.panel2,p.gold);promptFrames[#promptFrames+1]=m
    if mainFrame then mainFrame._campaignFileMenu=m end
    if RPAL.SetOpenDropdown then RPAL.SetOpenDropdown(m) end
    m:SetScript("OnHide",function(self)if mainFrame and mainFrame._campaignFileMenu==self then mainFrame._campaignFileMenu=nil end end)
    if anchor then m:SetPoint("TOPRIGHT",anchor,"BOTTOMRIGHT",0,-4) else m:SetPoint("CENTER") end
    local defs={
        {"Note",function()OpenNoteFilePrompt(nil)end},{"Encounter Preset",function()if not DM.IsHost() then StatusToast("Start or resume a Campaign Session first.");return end;SimplePrompt("SAVE ENCOUNTER PRESET","Preset name","Encounter",function(v)local ok,res=DM.SaveEncounterPreset(v,DM._selectedFolder);if not ok then return false,res end;return true end)end},
        {"Notification Preset",NewNotificationPresetPrompt},{"Roll Check Preset",NewRollPresetPrompt},{"Screen Message Preset",NewScreenPresetPrompt},
        {"Debuff Preset",function()SimplePrompt("NEW DEBUFF PRESET","Status name","",function(v)local ok,res=DM.CreateCustomStatus(c.id,v,DM._selectedFolder);if not ok then return false,res end;return true end)end},
    }
    local y=-10;for _,d in ipairs(defs)do local label,fn=d[1],d[2];local b=Button(m,label,200,31,false);b:SetPoint("TOPLEFT",14,y);y=y-37;b:SetScript("OnClick",function()m:Hide();fn()end)end
    FadeInFrame(m,.10)
end

local FILE_LABELS={NPC="NPC",NOTE="NOTE",ENCOUNTER="PRESET",NOTIFICATION="NOTIFICATION",ROLL="ROLL CHECK",SCREEN="SCREEN MESSAGE",DEBUFF="DEBUFF",FOLDER="FOLDER"}
local FILE_ICONS={
    NPC=RPAL.ICONS and RPAL.ICONS.person,NOTE=RPAL.ICONS and RPAL.ICONS.scroll,ENCOUNTER=RPAL.ICONS and RPAL.ICONS.trophy,
    NOTIFICATION=RPAL.ICONS and RPAL.ICONS.info,ROLL=RPAL.ICONS and RPAL.ICONS.replay,SCREEN=RPAL.ICONS and RPAL.ICONS.eye,
    DEBUFF=RPAL.ICONS and RPAL.ICONS.warning,FOLDER=RPAL.ICONS and RPAL.ICONS.book,
}
local function UseCampaignAsset(campaign,item)
    local kind=item.kind;local data=item.data
    if kind=="NPC" then EditNPCPrompt(data)
    elseif kind=="NOTE" then OpenNoteFilePrompt(data)
    elseif kind=="ENCOUNTER" then if not DM.IsHost() then StatusToast("Start or resume a Campaign Session first.") else local ok,res=DM.LoadEncounterTemplate(item.id);if not ok then StatusToast(res) end end
    elseif kind=="NOTIFICATION" then if not DM.IsHost() then StatusToast("Start or resume a Campaign Session first.") else local ok,res=DM.SendNotification(data.title,data.body,nil);if not ok then StatusToast(res) end end
    elseif kind=="ROLL" then
        if not DM.IsHost() then StatusToast("Start or resume a Campaign Session first.") else local targets={};for _,it in ipairs(DM.GetRollEligiblePlayers and DM.GetRollEligiblePlayers(true) or {})do targets[#targets+1]=it.name end;local ok,res=DM.StartRollCheck(data.title,data.info,data.sides,data.minimum,targets);if not ok then StatusToast(res) end end
    elseif kind=="SCREEN" then if not DM.IsHost() then StatusToast("Start or resume a Campaign Session first.") else local ok,res=DM.SendScreenMessage(data.title,data.subtitle,data.duration,nil,data.presentation);if not ok then StatusToast(res) end end
    elseif kind=="DEBUFF" then local names=SelectedPlayerNames(DM._selectedPlayers or {});if #names==0 then StatusToast("Select one or more players in Campaign & Live first.") else local ok,res=DM.ApplyStatusToPlayers(names,data.id,true,data.name);if not ok then StatusToast(res) end end
    end
end


CloseDMContextMenus=function()
    if mainFrame and mainFrame._campaignFileMenu then mainFrame._campaignFileMenu:Hide();mainFrame._campaignFileMenu=nil end
    if mainFrame and mainFrame._explorerContextMenu then mainFrame._explorerContextMenu:Hide();mainFrame._explorerContextMenu=nil end
end

local function DeleteCampaignConfirmed(campaign)
    if not campaign then return end
    ConfirmPrompt("Delete this campaign and all of its files? This cannot be undone.",function()
        local ok,res=DM.DeleteCampaign(campaign.id);if not ok then StatusToast(res) end
    end,"Delete Campaign")
end

local function OpenExplorerContextMenu(anchor,campaign,item,kind)
    if not mainFrame or not campaign then return end
    CloseDMContextMenus()
    local p=Palette();local m=CreateFrame("Frame",nil,mainFrame,"BackdropTemplate");mainFrame._explorerContextMenu=m
    m:SetSize(196,kind=="campaign" and 174 or 140);Layer(m,"POPOVER",110,"TOOLTIP",12110);Backdrop(m,p.panel2,p.gold)
    if RPAL.SetOpenDropdown then RPAL.SetOpenDropdown(m) end
    if anchor then m:SetPoint("TOPLEFT",anchor,"TOPRIGHT",5,0) else m:SetPoint("CENTER") end
    m:SetScript("OnHide",function(self)if mainFrame and mainFrame._explorerContextMenu==self then mainFrame._explorerContextMenu=nil end end)
    local function add(label,y,fn,accent)
        local b=Button(m,label,168,28,accent);b:SetPoint("TOPLEFT",14,y);b:SetScript("OnClick",function()m:Hide();fn()end);return b
    end
    if kind=="campaign" then
        add("Set Active Campaign",-10,function()DM.SetActiveCampaign(campaign.id);DM._selectedFolder=nil;DM._selectedNPCs={};RPAL.RefreshDMKitUI()end,true)
        add("Rename",-44,function()SimplePrompt("RENAME CAMPAIGN","Campaign name",campaign.name,function(v)local ok,res=DM.RenameCampaign(campaign.id,v);if not ok then return false,res end;return true end)end)
        add("Duplicate",-78,function()local ok,res=DM.DuplicateCampaign and DM.DuplicateCampaign(campaign.id);if not ok then StatusToast(res or "Could not duplicate campaign.") end end)
        add("Delete",-112,function()DeleteCampaignConfirmed(campaign)end)
    else
        add("Rename",-10,function()
            SimplePrompt("RENAME "..(FILE_LABELS[kind] or "FILE"),"Name",item and item.name or "",function(v)
                local ok,res;if kind=="FOLDER" then ok,res=DM.RenameSubFolder(campaign.id,item.id,v) else ok,res=DM.RenameCampaignFile(campaign.id,kind,item.id,v) end
                if not ok then return false,res end;return true
            end)
        end)
        add("Duplicate",-44,function()
            local ok,res;if kind=="FOLDER" then ok,res=DM.DuplicateSubFolder and DM.DuplicateSubFolder(campaign.id,item.id) else ok,res=DM.DuplicateCampaignFile and DM.DuplicateCampaignFile(campaign.id,kind,item.id) end
            if not ok then StatusToast(res or "Could not duplicate item.") end
        end)
        add("Delete",-78,function()
            ConfirmPrompt("Delete '"..tostring(item and item.name or "item").."'?",function()
                local ok,res;if kind=="FOLDER" then ok,res=DM.DeleteSubFolder(campaign.id,item.id) else ok,res=DM.DeleteCampaignFile(campaign.id,kind,item.id) end;if not ok then StatusToast(res) end
            end,"Delete")
        end)
    end
    FadeInFrame(m,.10)
end

local function ExplorerRow(panel,label,y,indent,selected,onClick,kind)
    local p=Palette();indent=indent or 0
    local isCampaign=kind=="campaign"
    local isRoot=kind=="root"
    local h=isCampaign and 40 or 29
    local b=PKeep(panel,CreateFrame("Button",nil,panel,"BackdropTemplate"));b:SetHeight(h);b:SetPoint("TOPLEFT",14,y);b:SetPoint("TOPRIGHT",-14,y)

    if isCampaign then
        Backdrop(b,selected and p.accent or p.panel2,selected and p.gold or p.border)
        local stripe=b:CreateTexture(nil,"ARTWORK");stripe:SetTexture(WHITE);stripe:SetWidth(selected and 3 or 2);stripe:SetPoint("TOPLEFT",2,-3);stripe:SetPoint("BOTTOMLEFT",2,3);stripe:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],selected and 1 or .42)

        local icon=b:CreateTexture(nil,"ARTWORK");icon:SetTexture("Interface\\AddOns\\ODYN\\Textures\\IconPack\\book.tga");icon:SetSize(23,23);icon:SetPoint("LEFT",10,0);icon:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],selected and 1 or .74)
        local typeText=Text(b,"CAMPAIGN",6.8,p.gold,"OUTLINE");typeText:SetPoint("TOPLEFT",41,-5)
        local tx=Text(b,label,10.2,selected and p.pale or p.muted,selected and "OUTLINE" or "");tx:SetPoint("BOTTOMLEFT",40,6);tx:SetPoint("RIGHT",-28,0);tx:SetJustifyH("LEFT");tx:SetWordWrap(false)
        local chevron=Text(b,selected and "▾" or "›",12,selected and p.gold or p.muted,"OUTLINE");chevron:SetPoint("RIGHT",-10,0)

        b:SetScript("OnEnter",function(self)if not selected then local q=Palette();self:SetBackdropColor(q.buttonHover[1],q.buttonHover[2],q.buttonHover[3],1);tx:SetTextColor(q.pale[1],q.pale[2],q.pale[3],1)end end)
        b:SetScript("OnLeave",function(self)if not selected then local q=Palette();Backdrop(self,q.panel2,q.border);tx:SetTextColor(q.muted[1],q.muted[2],q.muted[3],1)end end)
    else
        -- Library/folder rows read as children of the campaign instead of
        -- looking like another campaign button. The branch line + folder
        -- silhouette makes the hierarchy visible even with similar names.
        local childBg=selected and p.accent or {p.panel[1],p.panel[2],p.panel[3],.38}
        Backdrop(b,childBg,selected and p.gold or {p.border[1],p.border[2],p.border[3],.28},7)
        local branchX=12+indent*13
        local v=b:CreateTexture(nil,"BACKGROUND");v:SetTexture(WHITE);v:SetWidth(1);v:SetPoint("TOPLEFT",branchX,-1);v:SetPoint("BOTTOMLEFT",branchX,1);v:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],.22)
        local arm=b:CreateTexture(nil,"BACKGROUND");arm:SetTexture(WHITE);arm:SetHeight(1);arm:SetPoint("LEFT",branchX,0);arm:SetWidth(10);arm:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],.32)

        local iconHolder=CreateFrame("Frame",nil,b);iconHolder:SetSize(17,16);iconHolder:SetPoint("LEFT",branchX+13,0)
        if isRoot then
            local icon=iconHolder:CreateTexture(nil,"ARTWORK");icon:SetTexture("Interface\\AddOns\\ODYN\\Textures\\IconPack\\list.tga");icon:SetAllPoints();icon:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],selected and 1 or .72)
        else
            local body=iconHolder:CreateTexture(nil,"ARTWORK");body:SetTexture(WHITE);body:SetPoint("BOTTOMLEFT",1,1);body:SetSize(15,10);body:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],selected and .94 or .58)
            local tab=iconHolder:CreateTexture(nil,"ARTWORK");tab:SetTexture(WHITE);tab:SetPoint("BOTTOMLEFT",body,"TOPLEFT",1,-1);tab:SetSize(7,4);tab:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],selected and .94 or .58)
        end

        local kindText=Text(b,isRoot and "LIBRARY" or "FOLDER",6.5,selected and p.gold or p.muted,"OUTLINE");kindText:SetPoint("LEFT",iconHolder,"RIGHT",7,6)
        local tx=Text(b,label,9.2,selected and p.pale or p.muted,selected and "OUTLINE" or "");tx:SetPoint("LEFT",iconHolder,"RIGHT",7,-5);tx:SetPoint("RIGHT",-7,0);tx:SetJustifyH("LEFT");tx:SetWordWrap(false)
        b:SetScript("OnEnter",function(self)if not selected then local q=Palette();self:SetBackdropColor(q.buttonHover[1],q.buttonHover[2],q.buttonHover[3],.75);tx:SetTextColor(q.pale[1],q.pale[2],q.pale[3],1)end end)
        b:SetScript("OnLeave",function(self)if not selected then local q=Palette();Backdrop(self,{q.panel[1],q.panel[2],q.panel[3],.38},{q.border[1],q.border[2],q.border[3],.28},7);tx:SetTextColor(q.muted[1],q.muted[2],q.muted[3],1)end end)
    end
    if onClick then b:SetScript("OnClick",onClick) end
    return b,h
end

local function RefreshCampaignPanel(panel,campaign)
    ClearPanel(panel);local p=Palette()
    local title=PKeep(panel,Text(panel,"CAMPAIGN EXPLORER",10,p.gold,"OUTLINE"));title:SetPoint("TOPLEFT",14,-14)
    local hint=PKeep(panel,Text(panel,"Drag folders/files to reorganize",8,p.muted));hint:SetPoint("TOPLEFT",14,-32)
    local add=PKeep(panel,Button(panel,"+  New Campaign",254,30,true));add:SetPoint("TOPLEFT",14,-55);add:SetPoint("TOPRIGHT",-14,-55);add:SetScript("OnClick",CreateCampaignPrompt)
    local rule=PKeep(panel,panel:CreateTexture(nil,"ARTWORK"));rule:SetTexture(WHITE);rule:SetHeight(1);rule:SetPoint("TOPLEFT",14,-93);rule:SetPoint("TOPRIGHT",-14,-93);rule:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],.18)
    local y=-102
    for _,c in ipairs(DM.GetCampaigns()) do
        local active=campaign and c.id==campaign.id
        local row,ch=ExplorerRow(panel,c.name,y,0,active,function(_,button)
            if button=="RightButton" then OpenExplorerContextMenu(row,c,c,"campaign");return end
            CloseDMContextMenus();DM._selectedNPCs={};DM.SetActiveCampaign(c.id);DM._selectedFolder=nil;RPAL.RefreshDMKitUI()
        end,"campaign");row:RegisterForClicks("LeftButtonUp","RightButtonUp");row._dmDropCampaignID=c.id;row._dmDropFolderID=false;y=y-(ch+6)
        if active then
            local byParent={};for _,fid in ipairs(c.folderOrder or {})do local fol=c.folders and c.folders[fid];if fol then local parent=fol.parentID or "__root";byParent[parent]=byParent[parent] or {};byParent[parent][#byParent[parent]+1]=fol end end
            local function render(parentID,depth)
                for _,fol in ipairs(byParent[parentID or "__root"] or {}) do
                    local frow,fh=ExplorerRow(panel,fol.name,y,1+depth,DM._selectedFolder==fol.id,function(_,button)
                        if button=="RightButton" then OpenExplorerContextMenu(frow,c,fol,"FOLDER");return end
                        CloseDMContextMenus();DM._selectedFolder=fol.id;DM._selectedNPCs={};RPAL.RefreshDMKitUI()
                    end,"folder")
                    frow:RegisterForClicks("LeftButtonUp","RightButtonUp");frow:RegisterForDrag("LeftButton");frow._dmDropCampaignID=c.id;frow._dmDropFolderID=fol.id
                    frow:SetScript("OnDragStart",function()BeginDMFileDrag({campaignID=c.id,kind="FOLDER",id=fol.id},fol.name)end);frow:SetScript("OnDragStop",FinishDMFileDrag)
                    y=y-(fh+3);render(fol.id,depth+1)
                end
            end
            render(nil,0);y=y-5
        end
    end
    if #(DM.GetCampaigns())==0 then local empty=PKeep(panel,Text(panel,"No campaigns yet.\nCreate one to start your Campaign Studio.",9,p.muted));empty:SetPoint("TOPLEFT",18,y-4);empty:SetWidth(242);empty:SetWordWrap(true) end
end

local function RefreshCenterPanel(panel,campaign)
    -- Clear transient file-browser behaviour as well as visible children.
    -- Otherwise an empty/new campaign can retain the previous folder's mouse-wheel/drop closure.
    panel:SetScript("OnMouseWheel",nil)
    panel._dmDropCampaignID=nil
    panel._dmDropFolderID=nil
    ClearPanel(panel);local p=Palette()
    if not campaign then local badge=PKeep(panel,Text(panel,"CAMPAIGN FILES",10,p.gold,"OUTLINE"));badge:SetPoint("TOPLEFT",18,-18);local e=PKeep(panel,Text(panel,"Choose a Campaign from the Explorer.\n\nNPCs, notes, presets and folders all live here.",11,p.muted));e:SetPoint("CENTER",0,22);e:SetWidth(420);e:SetJustifyH("CENTER");e:SetWordWrap(true);return end
    local folder=DM._selectedFolder and campaign.folders and campaign.folders[DM._selectedFolder]
    if DM._selectedFolder and not folder then DM._selectedFolder=nil end
    panel._dmDropCampaignID=campaign.id;panel._dmDropFolderID=folder and folder.id or false
    local rootView=not folder;local viewName=rootView and "ALL CAMPAIGN FILES" or tostring(folder.name or "FOLDER")
    local headerTextW=math.max(150,(panel:GetWidth() or 620)-345)
    local crumb=PKeep(panel,Text(panel,"",8.7,p.muted));crumb:SetPoint("TOPLEFT",16,-15);crumb:SetJustifyH("LEFT");FitLine(crumb,rootView and campaign.name or (campaign.name.."  /  "..FolderPath(campaign,folder.id)),headerTextW,8.7,6.8)
    local title=PKeep(panel,Text(panel,"",14,p.gold,"OUTLINE"));title:SetPoint("TOPLEFT",16,-37);title:SetJustifyH("LEFT");FitLine(title,viewName,headerTextW,14,9.5)
    local npc=PKeep(panel,Button(panel,"+ NPC",74,28,true));npc:SetPoint("TOPRIGHT",-14,-14);npc:SetScript("OnClick",CreateNPCPrompt)
    local file=PKeep(panel,Button(panel,"+ File",74,28));file:SetPoint("RIGHT",npc,"LEFT",-7,0);file:SetScript("OnClick",function()NewCampaignFileMenu(file)end)
    local folderBtn=PKeep(panel,Button(panel,"+ Folder",82,28));folderBtn:SetPoint("RIGHT",file,"LEFT",-7,0);folderBtn:SetScript("OnClick",CreateFolderPrompt)
    if folder then local rename=PKeep(panel,Button(panel,"Rename Folder",100,25));rename:SetPoint("TOPRIGHT",-14,-50);rename:SetScript("OnClick",function()SimplePrompt("RENAME FOLDER","Folder name",folder.name,function(v)local ok,res=DM.RenameSubFolder(campaign.id,folder.id,v);if not ok then return false,res end;return true end)end);local del=PKeep(panel,Button(panel,"Delete",62,25));del:SetPoint("RIGHT",rename,"LEFT",-6,0);del:SetScript("OnClick",function()ConfirmPrompt("Delete this folder? Its contents will be moved to the Campaign root.",function()local ok,res=DM.DeleteSubFolder(campaign.id,folder.id);if not ok then StatusToast(res)end end,"Delete Folder")end) end

    local entries={}
    -- Campaign root deliberately shows every file recursively. Selecting a folder
    -- scopes the view to that folder and its descendants.
    for _,it in ipairs(DM.GetCampaignFiles(campaign.id,folder and folder.id or nil,true) or {})do entries[#entries+1]=it end
    for _,fid in ipairs(campaign.folderOrder or {})do local f=campaign.folders and campaign.folders[fid];if f and (rootView or FolderDescendsFrom(campaign,f.id,folder.id)) then entries[#entries+1]={kind="FOLDER",id=f.id,name=f.name,folderID=f.parentID,data=f} end end
    table.sort(entries,function(a,b)if a.kind=="FOLDER" and b.kind~="FOLDER" then return true elseif b.kind=="FOLDER" and a.kind~="FOLDER" then return false end;local ap=FolderPath(campaign,a.folderID);local bp=FolderPath(campaign,b.folderID);if ap~=bp then return ap<bp end;return tostring(a.name or ""):lower()<tostring(b.name or ""):lower() end)
    local count=PKeep(panel,Text(panel,tostring(#entries).." item"..(#entries==1 and "" or "s")..(rootView and "  ·  full Campaign" or "  ·  folder subtree"),8.4,p.muted));count:SetPoint("TOPLEFT",17,-68)
    local header=PKeep(panel,CreateFrame("Frame",nil,panel,"BackdropTemplate"));header:SetPoint("TOPLEFT",14,-91);header:SetPoint("TOPRIGHT",-14,-91);header:SetHeight(24);Backdrop(header,p.panel2,p.border)
    local h1=Text(header,"TYPE / NAME",8,p.muted,"OUTLINE");h1:SetPoint("LEFT",12,0);local h2=Text(header,"LOCATION",8,p.muted,"OUTLINE");h2:SetPoint("RIGHT",-208,0);local h3=Text(header,"ACTIONS",8,p.muted,"OUTLINE");h3:SetPoint("RIGHT",-26,0)
    local y=-121;local maxRows=math.max(7,math.floor(((panel:GetHeight() or 700)-158)/57))
    local scrollKey=tostring(campaign.id)..":"..tostring(folder and folder.id or "ROOT")
    if DM._fileScrollKey~=scrollKey then DM._fileScrollKey=scrollKey;DM._fileScrollOffset=0 end
    local maxOffset=math.max(0,#entries-maxRows);DM._fileScrollOffset=math.max(0,math.min(maxOffset,tonumber(DM._fileScrollOffset) or 0))
    panel:EnableMouseWheel(true);panel:SetScript("OnMouseWheel",function(_,delta)if #entries<=maxRows then return end;DM._fileScrollOffset=math.max(0,math.min(maxOffset,(DM._fileScrollOffset or 0)-(delta>0 and 1 or -1)));RPAL.RefreshDMKitUI()end)
    local first=(DM._fileScrollOffset or 0)+1;local last=math.min(#entries,first+maxRows-1)
    for index=first,last do local it=entries[index]
        local row=PKeep(panel,CreateFrame("Button",nil,panel,"BackdropTemplate"));row:SetHeight(52);row:SetPoint("TOPLEFT",14,y);row:SetPoint("TOPRIGHT",-14,y);Backdrop(row,p.panel2,p.border);row:RegisterForClicks("LeftButtonUp","RightButtonUp");row:RegisterForDrag("LeftButton")
        local icon=CreateFrame("Frame",nil,row,"BackdropTemplate");icon:SetSize(34,30);icon:SetPoint("LEFT",8,0);Backdrop(icon,p.button,it.kind=="FOLDER" and p.gold or p.border)
        local itx=icon:CreateTexture(nil,"ARTWORK");itx:SetTexture(FILE_ICONS[it.kind] or (RPAL.ICONS and RPAL.ICONS.question));itx:SetSize(20,20);itx:SetPoint("CENTER");itx:SetVertexColor((it.kind=="FOLDER" and p.gold or p.pale)[1],(it.kind=="FOLDER" and p.gold or p.pale)[2],(it.kind=="FOLDER" and p.gold or p.pale)[3],.92)
        local nm=Text(row,it.name or "Untitled",9.7,p.pale,"OUTLINE");nm:SetPoint("TOPLEFT",50,-8);nm:SetPoint("RIGHT",-330,0);nm:SetJustifyH("LEFT");nm:SetWordWrap(false)
        local detail=(it.kind=="NPC" and ((it.data.title and it.data.title~="") and it.data.title or (tostring(it.data.maxHP or 100).." max HP"))) or (it.kind=="ROLL" and ("D"..tostring(it.data.sides or 20))) or (it.kind=="SCREEN" and tostring(it.data.presentation or "Cinematic")) or (it.kind=="FOLDER" and "Drag to re-parent") or "Campaign file"
        local sub=Text(row,detail,7.4,p.muted);sub:SetPoint("BOTTOMLEFT",50,8);sub:SetPoint("RIGHT",-330,0);sub:SetJustifyH("LEFT");sub:SetWordWrap(false)
        local location=Text(row,FolderPath(campaign,it.folderID),7.4,p.muted);location:SetPoint("RIGHT",-202,0);location:SetWidth(150);location:SetJustifyH("RIGHT");location:SetWordWrap(false)
        local open=Button(row,it.kind=="FOLDER" and "Open" or (it.kind=="NOTE" and "Edit" or "Use"),54,25,false);open:SetPoint("RIGHT",-140,0);open:SetScript("OnClick",function()CloseDMContextMenus();if it.kind=="FOLDER" then DM._selectedFolder=it.id;RPAL.RefreshDMKitUI() else UseCampaignAsset(campaign,it) end end)
        local rename=Button(row,"Rename",64,25);rename:SetPoint("RIGHT",-72,0);rename:SetScript("OnClick",function()SimplePrompt("RENAME "..(FILE_LABELS[it.kind] or "FILE"),"Name",it.name,function(v)local ok,res;if it.kind=="FOLDER" then ok,res=DM.RenameSubFolder(campaign.id,it.id,v) else ok,res=DM.RenameCampaignFile(campaign.id,it.kind,it.id,v) end;if not ok then return false,res end;return true end)end)
        local del=Button(row,"×",30,25);del:SetPoint("RIGHT",-8,0);del:SetScript("OnClick",function()ConfirmPrompt("Delete '"..tostring(it.name).."'?",function()local ok,res;if it.kind=="FOLDER" then ok,res=DM.DeleteSubFolder(campaign.id,it.id) else ok,res=DM.DeleteCampaignFile(campaign.id,it.kind,it.id) end;if not ok then StatusToast(res)end end,"Delete")end)
        if it.kind=="FOLDER" then row._dmDropCampaignID=campaign.id;row._dmDropFolderID=it.id end
        row:SetScript("OnClick",function(_,button)if button=="RightButton" then OpenExplorerContextMenu(row,campaign,it,it.kind) end end)
        row:SetScript("OnDragStart",function()BeginDMFileDrag({campaignID=campaign.id,kind=it.kind,id=it.id},it.name)end);row:SetScript("OnDragStop",FinishDMFileDrag)
        y=y-57
    end
    if #entries>maxRows then local more=PKeep(panel,Text(panel,"Showing "..tostring(first).."–"..tostring(last).." of "..tostring(#entries).."  ·  mouse wheel to browse",8,p.muted));more:SetPoint("TOPLEFT",18,y+4) end
    if #entries==0 then local e=PKeep(panel,Text(panel,"This location is empty.",10,p.muted));e:SetPoint("TOP",0,-155);local sub=PKeep(panel,Text(panel,"Create an NPC, note or preset — or drag files here from another folder.",8.8,p.muted));sub:SetPoint("TOP",e,"BOTTOM",0,-7);sub:SetWidth(410);sub:SetJustifyH("CENTER") end
end

local function RefreshLivePanel(panel,campaign)
    ClearPanel(panel);local p=Palette();local state=DM.GetState()
    local title=PKeep(panel,Text(panel,"CAMPAIGN & LIVE",10,p.gold,"OUTLINE"));title:SetPoint("TOPLEFT",14,-14)

    if campaign then
        local campaignCard=PKeep(panel,CreateFrame("Frame",nil,panel,"BackdropTemplate"));campaignCard:SetPoint("TOPLEFT",12,-38);campaignCard:SetPoint("TOPRIGHT",-12,-38);campaignCard:SetHeight(104);Backdrop(campaignCard,p.panel2,p.border)
        local label=Text(campaignCard,campaign.concluded and "CONCLUDED CAMPAIGN" or "ACTIVE CAMPAIGN",7.8,campaign.concluded and p.gold or p.muted,"OUTLINE");label:SetPoint("TOPLEFT",10,-9)
        local nm=Text(campaignCard,campaign.name,10.5,p.pale,"OUTLINE");nm:SetPoint("TOPLEFT",10,-28);nm:SetWidth(238);nm:SetJustifyH("LEFT");nm:SetWordWrap(false)
        local notes=Button(campaignCard,"Notes",70,25);notes:SetPoint("BOTTOMLEFT",8,8);notes:SetScript("OnClick",function()RPAL.OpenDMNotes()end)
        local history=Button(campaignCard,"History",70,25);history:SetPoint("LEFT",notes,"RIGHT",6,0);history:SetScript("OnClick",function()CampaignHistoryPrompt(campaign)end)
        local rename=Button(campaignCard,"Rename",70,25);rename:SetPoint("LEFT",history,"RIGHT",6,0);rename:SetScript("OnClick",function()SimplePrompt("RENAME CAMPAIGN","Campaign name",campaign.name,function(v)local ok,res=DM.RenameCampaign(campaign.id,v);if not ok then return false,res end;return true end)end)
    else
        local no=PKeep(panel,Text(panel,"Select a campaign to unlock preparation and live controls.",9,p.muted));no:SetPoint("TOPLEFT",14,-42);no:SetWidth(250);no:SetWordWrap(true);return
    end

    if campaign.concluded then
        local done=PKeep(panel,Text(panel,"CONCLUDED  ·  READ ONLY",9,p.gold,"OUTLINE"));done:SetPoint("TOPLEFT",14,-158)
        local when=PKeep(panel,Text(panel,campaign.concludedAt and date("%d %b %Y  %H:%M",campaign.concludedAt) or "Completed Campaign",8.5,p.muted));when:SetPoint("TOPLEFT",14,-181)
        local help=PKeep(panel,Text(panel,"This Campaign is finished. Its completed record is available from Sessions → Campaigns and may be pinned to the profile.",8.8,p.muted));help:SetPoint("TOPLEFT",14,-210);help:SetWidth(260);help:SetWordWrap(true)
        local history=PKeep(panel,Button(panel,"Campaign History",130,27));history:SetPoint("BOTTOMRIGHT",-14,14);history:SetScript("OnClick",function()CampaignHistoryPrompt(campaign)end)
        return
    end

    local y=-156
    local liveLabel=PKeep(panel,Text(panel,"LIVE SESSION",8.8,p.gold,"OUTLINE"));liveLabel:SetPoint("TOPLEFT",14,y);y=y-24
    if not state then
        local paused=DM.HasPausedCampaign and DM.HasPausedCampaign(campaign.id)
        local status=PKeep(panel,Text(panel,paused and "PAUSED CHECKPOINT" or "OFFLINE",8,paused and p.gold or p.muted,"OUTLINE"));status:SetPoint("TOPLEFT",14,y+3)
        local start=PKeep(panel,Button(panel,paused and "Resume Campaign" or "Start Live Session",254,32,true));start:SetPoint("TOPLEFT",14,y-20);start:SetPoint("TOPRIGHT",-14,y-20);start:SetScript("OnClick",function()local ok,res;if paused then ok,res=DM.ResumeCampaign(campaign.id) else ok,res=DM.StartSession(campaign.id) end;if not ok then StatusToast(res)end end)
        local help=PKeep(panel,Text(panel,paused and "Encounter HP, player HP, turn order and the session checkpoint are saved. Re-invite players after resuming." or "Start only when you are ready to invite players. Preparation files remain available while offline.",8.4,p.muted));help:SetPoint("TOPLEFT",16,y-60);help:SetWidth(244);help:SetWordWrap(true)
        local delete=PKeep(panel,Button(panel,"Delete",66,25));delete:SetPoint("BOTTOMLEFT",14,14);delete:SetScript("OnClick",function()ConfirmCampaignDelete(campaign)end)
        local conclude=PKeep(panel,Button(panel,"Conclude Campaign",126,25,true));conclude:SetPoint("LEFT",delete,"RIGHT",7,0);conclude:SetScript("OnClick",function()ConfirmPrompt("Conclude this Campaign permanently? It will become a read-only completed Campaign and cannot be resumed.",function()local ok,res=DM.ConcludeCampaign(campaign.id);if not ok then StatusToast(res)end end,"Conclude Campaign")end)
        local presets=PKeep(panel,Button(panel,"Presets",64,25));presets:SetPoint("BOTTOMRIGHT",-14,14);presets:SetScript("OnClick",EncounterTemplatesPrompt)
        return
    end

    local live=PKeep(panel,Text(panel,DM.IsHost() and "●  LIVE — HOST" or "●  LIVE — PARTICIPANT",8.4,{.35,1,.45,1},"OUTLINE"));live:SetPoint("TOPLEFT",14,y);y=y-28
    if not DM.IsHost() then
        local info=PKeep(panel,Text(panel,"Participant controls live in the slide panel. Party health, notifications and public logs stay read-only.",9,p.muted));info:SetPoint("TOPLEFT",14,y);info:SetWidth(248);info:SetWordWrap(true);return
    end

    local inviteT=PKeep(panel,Button(panel,"Invite Target",122,28));inviteT:SetPoint("TOPLEFT",14,y);inviteT:SetScript("OnClick",function()local ok,res=DM.InviteUnit("target");StatusToast(ok and "Invitation sent." or res)end)
    local inviteG=PKeep(panel,Button(panel,"Invite Group",122,28));inviteG:SetPoint("LEFT",inviteT,"RIGHT",8,0);inviteG:SetScript("OnClick",function()local ok,res=DM.InviteGroup();StatusToast(ok and ("Invited "..res.." player(s).") or res)end);y=y-36
    local roll=PKeep(panel,Button(panel,"Roll Check",122,28,true));roll:SetPoint("TOPLEFT",14,y);roll:SetScript("OnClick",RollPrompt)
    local health=PKeep(panel,Button(panel,"Setup Health",122,28));health:SetPoint("LEFT",roll,"RIGHT",8,0);health:SetScript("OnClick",function()HealthSetupPrompt()end);y=y-36
    local init=PKeep(panel,Button(panel,state.initiative and "Combat Timeline" or "Start Combat",122,28,true));init:SetPoint("TOPLEFT",14,y);init:SetScript("OnClick",InitiativePrompt)
    local screen=PKeep(panel,Button(panel,"Screen Message",122,28));screen:SetPoint("LEFT",init,"RIGHT",8,0);screen:SetScript("OnClick",ScreenMessagePrompt);y=y-42

    DM._selectedPlayers=DM._selectedPlayers or {}
    local selectedPlayersCount=0;for name in pairs(DM._selectedPlayers) do if state.participants and state.participants[name] then selectedPlayersCount=selectedPlayersCount+1 else DM._selectedPlayers[name]=nil end end
    local pt=PKeep(panel,Text(panel,"ROSTER"..(selectedPlayersCount>0 and ("  ·  "..selectedPlayersCount.." selected") or ""),8.8,p.gold,"OUTLINE"));pt:SetPoint("TOPLEFT",14,y)
    local partyActions=PKeep(panel,Button(panel,"Actions",78,24,selectedPlayersCount>0));partyActions:SetPoint("TOPRIGHT",-14,y+5);partyActions:SetScript("OnClick",function()PartyActionsPrompt(SelectedPlayerNames(DM._selectedPlayers))end);y=y-29
    if selectedPlayersCount>0 then
        local dmg=PKeep(panel,Button(panel,"DMG",70,24,true));dmg:SetPoint("TOPLEFT",14,y);dmg:SetScript("OnClick",function()local names=SelectedPlayerNames(DM._selectedPlayers);SimplePrompt("DAMAGE SELECTED PLAYERS","Damage amount","10",function(v)local ok,res=DM.ApplyPlayerDeltaBulk(names,v,false);if not ok then return false,res end;return true end,true)end)
        local heal=PKeep(panel,Button(panel,"HEAL",70,24));heal:SetPoint("LEFT",dmg,"RIGHT",6,0);heal:SetScript("OnClick",function()local names=SelectedPlayerNames(DM._selectedPlayers);SimplePrompt("HEAL SELECTED PLAYERS","Healing amount","10",function(v)local ok,res=DM.ApplyPlayerDeltaBulk(names,v,true);if not ok then return false,res end;return true end,true)end)
        local status=PKeep(panel,Button(panel,"+ STATUS",86,24));status:SetPoint("LEFT",heal,"RIGHT",6,0);status:SetScript("OnClick",function()StatusEffectPrompt(SelectedPlayerNames(DM._selectedPlayers),false)end)
        y=y-31
    end
    local players=PlayerList(state,true)
    -- Keep the live roster clear of the persistent Campaign controls at the bottom.
    -- Wider/taller layouts naturally expose more players without overlapping the footer.
    local maxRosterRows=math.max(3,math.min(8,math.floor(((panel:GetHeight() or 700)+y-125)/68)))
    for i,it in ipairs(players) do
        if i<=maxRosterRows then
            local selected=DM._selectedPlayers[it.name] and true or false;local pp=ParticipantPalette(it.p);local row=PKeep(panel,CreateFrame("Button",nil,panel,"BackdropTemplate"));row:SetHeight(64);row:SetPoint("TOPLEFT",14,y);row:SetPoint("TOPRIGHT",-14,y);Backdrop(row,selected and (pp.accent or p.accent) or p.panel2,selected and (pp.gold or p.gold) or (pp.border or p.border));row:RegisterForClicks("LeftButtonUp","RightButtonUp")
            local portrait=AddParticipantPortrait(row,it.name,it.p,40);portrait:SetPoint("LEFT",7,0)
            local cardW=math.max(180,(row:GetWidth() or panel:GetWidth() or 280)-70)
            local nm=Text(row,"",9.1,pp.pale or p.pale,"OUTLINE");nm:SetPoint("TOPLEFT",54,-7);nm:SetJustifyH("LEFT");FitLine(nm,it.p.trp or ShortName(it.name),math.max(110,cardW-8),9.1,7.0)
            local metaText=it.p.isHost and "DM / HOST" or (it.p.disconnected and "PAUSED ROSTER · OFFLINE" or tostring(it.p.theme or "PARTICIPANT"))
            local meta=Text(row,"",6.8,pp.muted or p.muted,"OUTLINE");meta:SetPoint("TOPLEFT",54,-23);meta:SetJustifyH("LEFT");FitLine(meta,metaText,math.max(110,cardW-8),6.8,6.0)
            local statuses={};for _,label in pairs(it.p.statusEffects or {}) do statuses[#statuses+1]=label end;table.sort(statuses)
            local st=Text(row,"",6.7,pp.gold or p.gold,"OUTLINE");st:SetPoint("TOPLEFT",54,-38);st:SetJustifyH("LEFT");FitLine(st,#statuses>0 and table.concat(statuses," · ") or "",math.max(110,cardW-8),6.7,6.0)
            local hpt=Text(row,"",7.7,pp.pale or p.pale,"OUTLINE");hpt:SetPoint("BOTTOMRIGHT",-7,7);hpt:SetJustifyH("RIGHT");FitLine(hpt,it.p.currentHP~=nil and (it.p.currentHP.."/"..it.p.maxHP) or "—",58,7.7,6.3)
            local hb=CreateHealthBar(row,120,7,pp.border or p.border);hb:SetPoint("BOTTOMLEFT",54,7);hb:SetPoint("RIGHT",row,"RIGHT",-72,7);if it.p.currentHP~=nil then SetHealthBar(hb,it.p.currentHP,it.p.maxHP,pp.gold or p.gold) else hb:SetAlpha(.3) end
            row:SetScript("OnClick",function(_,button)
                if button=="RightButton" then PlayerControlPrompt(it.name);return end
                DM._selectedPlayers[it.name]=not DM._selectedPlayers[it.name] or nil;RPAL.RefreshDMKitUI()
            end);y=y-68
        end
    end
    if #players>maxRosterRows then local more=PKeep(panel,Text(panel,"+ "..(#players-maxRosterRows).." more player(s)",8.2,p.muted));more:SetPoint("TOPLEFT",18,y);y=y-20 end
    local pending=#DM.GetPendingDamage();if pending>0 then local pb=PKeep(panel,Button(panel,"Pending Damage  ("..pending..")",254,29,true));pb:SetPoint("TOPLEFT",14,y-2);pb:SetPoint("TOPRIGHT",-14,y-2);pb:SetScript("OnClick",PendingPrompt);y=y-37 end
    local pause=PKeep(panel,Button(panel,"Pause & Save",122,29,true));pause:SetPoint("BOTTOMLEFT",14,50);pause:SetScript("OnClick",function()ConfirmPrompt("Pause and save this campaign so it can be resumed later?",function()DM.PauseCampaign()end,"Pause Campaign")end)
    local endb=PKeep(panel,Button(panel,"Stop Campaign Session",122,29));endb:SetPoint("LEFT",pause,"RIGHT",8,0);endb:SetScript("OnClick",function()DM.EndSession()end)
    local conclude=PKeep(panel,Button(panel,"Conclude Campaign",254,29,true));conclude:SetPoint("BOTTOMLEFT",14,14);conclude:SetPoint("BOTTOMRIGHT",-14,14);conclude:SetScript("OnClick",function()ConfirmPrompt("Conclude this Campaign permanently? The active Campaign Session will stop and a read-only completed Campaign record will be created for every connected participant.",function()local ok,res=DM.ConcludeCampaign(campaign.id);if not ok then StatusToast(res)end end,"Conclude Campaign")end)
end

function RPAL.RefreshDMKitUI()
    if not mainFrame or not mainFrame:IsShown() then return end
    if mainFrame._campaignFileMenu then mainFrame._campaignFileMenu:Hide();mainFrame._campaignFileMenu=nil end
    if mainFrame._explorerContextMenu then mainFrame._explorerContextMenu:Hide();mainFrame._explorerContextMenu=nil end
    local campaign=DM.GetActiveCampaign();RefreshCampaignPanel(mainFrame.left,campaign);RefreshCenterPanel(mainFrame.center,campaign);RefreshLivePanel(mainFrame.right,campaign)
end

function RPAL.OpenDMKit()
    if not DM.IsEnabled() and not DM.GetState() then StatusToast("Enable DM Kit in Settings first.");return end
    -- The full DM Kit is a standalone workspace.  When it is launched from
    -- RPAL's book/navigation, close the book instead of leaving two large
    -- windows stacked on top of one another.
    if RPAL.UI and RPAL.UI.main and RPAL.UI.main.IsShown and RPAL.UI.main:IsShown() then
        RPAL.UI.main:Hide()
    end
    local f=EnsureMain();if RPAL.PrepareWindow then RPAL.PrepareWindow(f,nil,0.15)else f:Show();f:Raise()end;RPAL.RefreshDMKitUI()
end

-- ---------------------- 3D SHARED ROLL CHECK ----------------------
local function Normalize(v)return tostring(v or ""):lower():gsub("%s+","") end
local function UnitMatches(unit,name)
    if not UnitExists(unit) then return false end
    local n,r=UnitName(unit);if not n then return false end;r=r or GetRealmName() or "";r=tostring(r):gsub("%s+","")
    local full=r~="" and n.."-"..r or n
    return Normalize(full)==Normalize(name) or Normalize(n)==Normalize(ShortName(name))
end
local function FindUnit(name)
    if UnitMatches("player",name) then return "player" end
    if UnitMatches("target",name) then return "target" end
    if UnitMatches("focus",name) then return "focus" end
    for i=1,4 do if UnitMatches("party"..i,name) then return "party"..i end end
    for i=1,40 do if UnitMatches("raid"..i,name) then return "raid"..i end end
end

local function SetupModel(model,name,pdata)
    model:Hide();model._unit=nil
    local token=FindUnit(name)
    if not token then return false end
    model._unit=token
    pcall(function()model:ClearModel()end);if model.SetAutoDress then pcall(model.SetAutoDress,model,true) end
    local ok=pcall(function()model:SetUnit(token)end)
    if ok then if model.SetPortraitZoom then pcall(model.SetPortraitZoom,model,0)end;if model.SetCamDistanceScale then pcall(model.SetCamDistanceScale,model,.86)end;if model.SetFacing then pcall(model.SetFacing,model,.1)end;model:Show();return true end
    return false
end

local function EnsureRollFrame()
    if rollFrame then return rollFrame end
    local p=Palette();local f=CreateFrame("Frame","RPAL_DMRollCheck",UIParent,"BackdropTemplate");rollFrame=f;f:SetSize(1120,600);f:SetPoint("CENTER");if RPAL.ApplyUILayer then RPAL.ApplyUILayer(f,"MODAL",700)else f:SetFrameStrata("FULLSCREEN_DIALOG");f:SetFrameLevel(8700);f:SetToplevel(true)end;Backdrop(f,p.main,p.gold);f:EnableMouse(true);f:Hide();f.cards={}
    f.title=Text(f,"ROLL CHECK",20,p.gold,"OUTLINE");f.title:SetPoint("TOP",0,-18)
    f.info=Text(f,"",10.5,p.pale);f.info:SetPoint("TOP",0,-52);f.info:SetWidth(980);f.info:SetJustifyH("CENTER");f.info:SetWordWrap(true)
    f.dice=Text(f,"",10,p.muted,"OUTLINE");f.dice:SetPoint("TOP",0,-86)
    f.scroll=CreateFrame("ScrollFrame",nil,f);f.scroll:SetPoint("TOPLEFT",20,-116);f.scroll:SetPoint("BOTTOMRIGHT",-20,70);f.scroll:EnableMouseWheel(true);f.scroll:SetHorizontalScroll(0);f.scroll:SetScript("OnMouseWheel",function(self,d)local max=self:GetHorizontalScrollRange()or 0;self:SetHorizontalScroll(math.max(0,math.min(max,(self:GetHorizontalScroll()or 0)-d*150)))end)
    f.content=CreateFrame("Frame",nil,f.scroll);f.content:SetSize(1060,405);f.scroll:SetScrollChild(f.content)
    f.close=Button(f,"Waiting for all rolls…",190,32,false);f.close:SetPoint("BOTTOMRIGHT",-20,20);f.close:Disable();f.close:SetScript("OnClick",function()local s=DM.GetState();if s and s.roll and s.roll.complete then f:Hide() end end)
    f:SetScript("OnHide",function(self)
        -- Hidden roll cards must never keep animation OnUpdate handlers alive.
        for _,card in ipairs(self.cards or {}) do
            if card._rollDriver then card._rollDriver:SetScript("OnUpdate",nil) end
            if card._rollFadeDriver then card._rollFadeDriver:SetScript("OnUpdate",nil) end
            if card._rollAnimating then card._lastRollKey=nil end
            card._rollAnimating=nil
        end
        local s=DM.GetState();if not self._forceClose and s and s.roll and not s.roll.complete then C_Timer.After(0,function()if s==DM.GetState() and s.roll and not s.roll.complete then self:Show();self:Raise();RPAL.RefreshDMRollUI() end end) end;self._forceClose=nil
    end)
    return f
end

local function EnsureRollCard(index)
    local f=EnsureRollFrame();if f.cards[index] then return f.cards[index] end
    local p=Palette();local card=CreateFrame("Frame",nil,f.content,"BackdropTemplate");card:SetSize(192,386);Backdrop(card,p.panel2,p.border);f.cards[index]=card
    card.modelShell=CreateFrame("Frame",nil,card,"BackdropTemplate");card.modelShell:SetPoint("TOPLEFT",7,-7);card.modelShell:SetPoint("TOPRIGHT",-7,-7);card.modelShell:SetHeight(236);Backdrop(card.modelShell,{.018,.02,.024,.98},p.border,8)
    card.themeStrip=card.modelShell:CreateTexture(nil,"ARTWORK");card.themeStrip:SetTexture(WHITE);card.themeStrip:SetPoint("BOTTOMLEFT",1,1);card.themeStrip:SetPoint("BOTTOMRIGHT",-1,1);card.themeStrip:SetHeight(3)
    card.model=CreateFrame("DressUpModel",nil,card.modelShell);card.model:SetPoint("TOPLEFT",4,-4);card.model:SetPoint("BOTTOMRIGHT",-4,4);card.model:EnableMouse(false)
    card.placeholder=Text(card.modelShell,"3D MODEL\nUNAVAILABLE",9,p.muted,"OUTLINE");card.placeholder:SetPoint("CENTER",0,0);card.placeholder:SetJustifyH("CENTER")
    card.customization=Text(card.modelShell,"",6.8,p.muted,"OUTLINE");card.customization:SetPoint("BOTTOMRIGHT",-7,7);card.customization:SetWidth(115);card.customization:SetJustifyH("RIGHT");card.customization:SetWordWrap(false)
    card.name=Text(card,"Player",10.5,p.pale,"OUTLINE");card.name:SetPoint("TOP",card.modelShell,"BOTTOM",0,-9);card.name:SetWidth(174);card.name:SetJustifyH("CENTER");card.name:SetWordWrap(false)
    card.flash=card:CreateTexture(nil,"BACKGROUND",nil,1);card.flash:SetTexture(WHITE);card.flash:SetPoint("TOPLEFT",4,-4);card.flash:SetPoint("BOTTOMRIGHT",-4,4);card.flash:SetAlpha(0)
    card.result=Text(card,"—",28,p.gold,"OUTLINE");card.result:SetPoint("TOP",card.name,"BOTTOM",0,-8)
    card.outcome=Text(card,"Waiting…",9,p.muted,"OUTLINE");card.outcome:SetPoint("TOP",card.result,"BOTTOM",0,-3)
    card.action=Button(card,"ROLL",104,28,true);card.action:SetPoint("BOTTOM",0,10);card.action:Hide()
    card.whisper=Button(card,"Whisper",104,25,false);card.whisper:SetPoint("BOTTOM",0,10);card.whisper:Hide()
    return card
end

local function RefreshRollCustomization(card,name,pdata)
    local base=Palette();local pp=ParticipantPalette(pdata);local border=pp.border or pp.gold or base.border;local accent=pp.gold or pp.accent or base.gold
    Backdrop(card,pp.panel2 or base.panel2,border)
    Backdrop(card.modelShell,{.018,.02,.024,.98},border,8)
    card.themeStrip:SetVertexColor(accent[1] or 1,accent[2] or .8,accent[3] or .4,.95)
    card.name:SetTextColor((pp.pale or base.pale)[1],(pp.pale or base.pale)[2],(pp.pale or base.pale)[3],1)
    local theme=tostring(pdata and pdata.theme or "")
    local frame=tostring(pdata and pdata.portraitFrame or "")
    local label=theme~="" and theme or "ODYN"
    if frame~="" and frame~="Default" then label=label.." · "..frame end
    card.customization:SetText(label:upper())
    card.customization:SetTextColor(accent[1] or 1,accent[2] or .8,accent[3] or .4,.76)
    local sig=table.concat({tostring(name or ""),theme,frame,tostring(pdata and pdata.levelBadge or ""),tostring(pdata and pdata.level or "")},"|")
    if card._customSig~=sig then
        card._customSig=sig
        if card.customPortrait then card.customPortrait:Hide();card.customPortrait:SetParent(nil);card.customPortrait=nil end
        card.customPortrait=AddParticipantPortrait(card.modelShell,name,pdata,34);card.customPortrait:SetPoint("TOPLEFT",7,-7);card.customPortrait:SetFrameLevel(card.modelShell:GetFrameLevel()+8)
    end
end

function RPAL.OpenDMRollUI()
    local f=EnsureRollFrame();f:Show();f:Raise();RPAL.RefreshDMRollUI()
end
function RPAL.CloseDMRollUI(force)
    if rollFrame then rollFrame._forceClose=force and true or false;rollFrame:Hide() end
end

local function AnimateDMRoll(card,value,key,sides,finalColor,onComplete,naturalRoll)
    if not card then return end
    if card._lastRollKey==key and card._rollCompleteKey==key then if onComplete then onComplete() end;return end
    if card._lastRollKey==key and card._rollAnimating then return end
    card._lastRollKey=key;card._rollCompleteKey=nil;card._rollAnimating=true
    if card._rollDriver then card._rollDriver:SetScript("OnUpdate",nil) end
    if card._rollFadeDriver then card._rollFadeDriver:SetScript("OnUpdate",nil) end
    local driver=card._rollDriver or CreateFrame("Frame",nil,card);card._rollDriver=driver
    local started=GetTime();local duration=.60;local final=tonumber(value) or 0;local neutral=Palette().pale
    card.result:SetScale(1.08);card.result:SetAlpha(1);card.result:SetTextColor(neutral[1],neutral[2],neutral[3],1)
    card.outcome:SetText("ROLLING…");card.outcome:SetTextColor(Palette().muted[1],Palette().muted[2],Palette().muted[3],1)
    driver:SetScript("OnUpdate",function(self)
        local t=GetTime()-started
        if t>=duration then
            card._rollAnimating=nil;card._rollCompleteKey=key
            card.result:SetText(tostring(final)..(tonumber(naturalRoll)==tonumber(sides) and "!" or ""));card.result:SetScale(1);card.result:SetTextColor(finalColor[1],finalColor[2],finalColor[3],1);self:SetScript("OnUpdate",nil)
            card.flash:SetVertexColor(finalColor[1],finalColor[2],finalColor[3],1);card.flash:SetAlpha(.30)
            local fade=card._rollFadeDriver or CreateFrame("Frame",nil,card);card._rollFadeDriver=fade;local t0=GetTime();fade:SetScript("OnUpdate",function(fr)local a=GetTime()-t0;if a>.35 then card.flash:SetAlpha(0);fr:SetScript("OnUpdate",nil) else card.flash:SetAlpha(.30*(1-a/.35)) end end)
            if onComplete then onComplete() end
            return
        end
        card.result:SetText(tostring(math.random(1,sides)));local pulse=1.08+.10*math.sin(t/duration*math.pi*5);card.result:SetScale(pulse);card.result:SetTextColor(neutral[1],neutral[2],neutral[3],1)
    end)
end

function RPAL.RefreshDMRollUI()
    local f=EnsureRollFrame();local state=DM.GetState();local r=state and state.roll
    if not r then f._forceClose=true;f:Hide();return end
    f:Show();f.title:SetText(r.title or "ROLL CHECK");f.info:SetText(r.info or "")
    local statName=(r.stat and DM.GetRollStatLabel and DM.GetRollStatLabel(r.stat)) or nil
    f.dice:SetText("D"..tostring(r.sides)..(statName and ("  ·  "..statName.." ("..tostring(r.stat)..")") or "")..(r.minimum and ("  ·  Minimum "..r.minimum) or ""))
    local targets=r.targets or {};if #targets==0 then for _,it in ipairs(PlayerList(state,false))do targets[#targets+1]=it.name end;r.targets=targets end
    local desiredW=math.max(430,math.min(1240,(#targets*202)+60));f:SetWidth(desiredW);f.info:SetWidth(math.max(340,desiredW-90))
    for i,card in ipairs(f.cards)do card:Hide() end
    local localName=LocalFullName();local totalW=math.max(f.scroll:GetWidth() or 0,#targets*202)
    f.content:SetWidth(totalW);f.content:SetHeight(405)
    for i,name in ipairs(targets) do
        local pdata=state.participants[name] or {trp=ShortName(name)};local card=EnsureRollCard(i);card:ClearAllPoints();card:SetPoint("TOPLEFT",(i-1)*202,0);card:Show();RefreshRollCustomization(card,name,pdata);card.name:SetText(pdata.trp or ShortName(name));local modelOk=SetupModel(card.model,name,pdata);card.placeholder:SetShown(not modelOk)
        local value=r.results and r.results[name];local skipped=r.skipped and r.skipped[name]
        local raw=(r.rawResults and r.rawResults[name]) or value;local modifier=(r.modifiers and r.modifiers[name]) or 0
        card.action:Hide();card.action:SetScript("OnClick",nil);card.whisper:Hide();card.whisper:SetScript("OnClick",nil)
        if value~=nil then
            local finalColor;if raw==1 then finalColor={1,.18,.16,1} elseif raw==r.sides then finalColor={.25,1,.42,1} else local pp=ParticipantPalette(pdata);local g=pp.gold or Palette().gold;finalColor={g[1],g[2],g[3],1} end
            local function revealOutcome()
                local prefix=r.stat and (tostring(r.stat).." "..(modifier>=0 and "+" or "")..tostring(modifier).." · ") or ""
                if r.minimum then local success=value>=r.minimum;card.outcome:SetText(prefix..(success and "SUCCESS" or "FAILED"));card.outcome:SetTextColor(success and .35 or 1,success and .9 or .35,success and .4 or .3,1) else card.outcome:SetText(prefix.."ROLLED");card.outcome:SetTextColor(Palette().muted[1],Palette().muted[2],Palette().muted[3],1) end
                if DM.IsHost() then card.whisper:Show();card.whisper:SetScript("OnClick",function()DM.DirectWhisper(name)end) end
                if r.complete then
                    local animating=false;for _,other in ipairs(f.cards or {})do if other:IsShown() and other._rollAnimating then animating=true break end end
                    if not animating then f.close:Enable();f.close.text:SetText("Close");Backdrop(f.close,Palette().accent,Palette().gold) end
                end
            end
            AnimateDMRoll(card,value,r.id..":"..tostring(name)..":"..tostring(value)..":"..tostring(raw),r.sides,finalColor,revealOutcome,raw)
        elseif skipped then
            card._rollAnimating=nil;card.result:SetText("—");card.result:SetTextColor(Palette().muted[1],Palette().muted[2],Palette().muted[3],1);card.outcome:SetText("SKIPPED");card.outcome:SetTextColor(.8,.5,.3,1)
        else
            card._rollAnimating=nil;card.result:SetText("—");local g=ParticipantPalette(pdata).gold or Palette().gold;card.result:SetTextColor(g[1],g[2],g[3],1);card.outcome:SetText(r.stat and (tostring(r.stat).." check · Waiting…") or "Waiting…");card.outcome:SetTextColor(Palette().muted[1],Palette().muted[2],Palette().muted[3],1)
            if Same(name,localName) then card.action.text:SetText(r.stat and ("ROLL "..tostring(r.stat)) or "ROLL");card.action:Show();card.action:SetScript("OnClick",function()local ok,msg=DM.RollLocal();if not ok then StatusToast(msg or "Could not submit the roll.") end end)
            elseif DM.IsHost() then card.action.text:SetText("SKIP");card.action:Show();card.action:SetScript("OnClick",function()local ok,msg=DM.SkipRollPlayer(name);if not ok then StatusToast(msg or "Could not skip that player.") end end) end
        end
    end
    if r.complete then
        local animating=false;for _,card in ipairs(f.cards or {})do if card:IsShown() and card._rollAnimating then animating=true break end end
        if animating then f.close:Disable();f.close.text:SetText("Finishing rolls…");Backdrop(f.close,Palette().button,Palette().border)
        else f.close:Enable();f.close.text:SetText("Close");Backdrop(f.close,Palette().accent,Palette().gold) end
    else f.close:Disable();f.close.text:SetText("Waiting for all rolls…");Backdrop(f.close,Palette().button,Palette().border) end
end


-- Repaint every DM Kit surface from the current RPAL palette.  Most DM panels
-- are built dynamically, but their outer chrome is persistent; before this
-- hook those persistent regions kept the palette they were born with until a
-- /reload.
local function RepaintDMButtonsAndInputs(root,p,seen)
    if not root then return end
    seen=seen or {}
    if seen[root] then return end
    seen[root]=true
    if root._dmAccent~=nil then
        ApplyButtonVisual(root,false)
    elseif root._dmInput then
        Backdrop(root,p.panel2,p.border)
        if root.SetTextColor then root:SetTextColor(p.pale[1],p.pale[2],p.pale[3],1) end
    end
    if root.GetChildren then
        local children={root:GetChildren()}
        for _,child in ipairs(children) do RepaintDMButtonsAndInputs(child,p,seen) end
    end
end

function RPAL.RefreshDMKitTheme()
    -- Refresh the local participant descriptor first so party/roll/timeline
    -- cards inherit the character's newly selected theme immediately.
    if DM.RefreshLocalStyle then DM.RefreshLocalStyle() end
    local p=Palette()

    if drawer then
        Backdrop(drawer,p.main,p.border,8)
        if drawer.edge then drawer.edge:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],.48) end
        if drawerTab then
            Backdrop(drawerTab,p.panel2,p.gold,7)
            if drawerTab.text then drawerTab.text:SetTextColor(p.gold[1],p.gold[2],p.gold[3],1) end
            if drawerTab.arrow then drawerTab.arrow:SetTextColor(p.pale[1],p.pale[2],p.pale[3],1) end
        end
        if drawer.header then Backdrop(drawer.header,p.panel2,p.border,7) end
        if drawer.headerCap then drawer.headerCap:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],.72) end
        if drawer.head then drawer.head:SetTextColor(p.gold[1],p.gold[2],p.gold[3],1) end
        if drawer.campaign then drawer.campaign:SetTextColor(p.pale[1],p.pale[2],p.pale[3],1) end
        if drawer.status then drawer.status:SetTextColor(p.muted[1],p.muted[2],p.muted[3],1) end
        if drawer.roleBadge then Backdrop(drawer.roleBadge,p.button,p.border,7) end
        if drawer.roleBadge and drawer.roleBadge.text then drawer.roleBadge.text:SetTextColor(p.muted[1],p.muted[2],p.muted[3],1) end
        RepaintDMButtonsAndInputs(drawer,p)
    end

    if mainFrame then
        Backdrop(mainFrame,p.main,p.gold)
        if mainFrame.header then Backdrop(mainFrame.header,p.panel2,p.border) end
        if mainFrame.headerAccent then mainFrame.headerAccent:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],.65) end
        if mainFrame.headerTitle then mainFrame.headerTitle:SetTextColor(p.gold[1],p.gold[2],p.gold[3],1) end
        if mainFrame.headerSub then mainFrame.headerSub:SetTextColor(p.muted[1],p.muted[2],p.muted[3],1) end
        if mainFrame.left then Backdrop(mainFrame.left,p.panel,p.border) end
        if mainFrame.center then Backdrop(mainFrame.center,p.panel,p.border) end
        if mainFrame.right then Backdrop(mainFrame.right,p.panel,p.border) end
        RepaintDMButtonsAndInputs(mainFrame,p)
    end

    if notesFrame then
        Backdrop(notesFrame,p.main,p.gold)
        if notesFrame.title then notesFrame.title:SetTextColor(p.gold[1],p.gold[2],p.gold[3],1) end
        RepaintDMButtonsAndInputs(notesFrame,p)
    end
    if inviteFrame then
        Backdrop(inviteFrame,p.main,p.gold)
        if inviteFrame.title then inviteFrame.title:SetTextColor(p.gold[1],p.gold[2],p.gold[3],1) end
        if inviteFrame.body then inviteFrame.body:SetTextColor(p.pale[1],p.pale[2],p.pale[3],1) end
        RepaintDMButtonsAndInputs(inviteFrame,p)
    end

    if encounterWidget then
        Backdrop(encounterWidget,p.main,p.border)
        if encounterWidget.title then encounterWidget.title:SetTextColor(p.gold[1],p.gold[2],p.gold[3],1) end
        if encounterWidget.dragHint then encounterWidget.dragHint:SetTextColor(p.muted[1],p.muted[2],p.muted[3],1) end
        for _,row in ipairs(encounterWidget.rows or {}) do
            if row.markerIcon then row.markerIcon:SetTextColor(p.pale[1],p.pale[2],p.pale[3],1) end
            if row.markerName then row.markerName:SetTextColor(p.muted[1],p.muted[2],p.muted[3],1) end
            if row.name then row.name:SetTextColor(p.pale[1],p.pale[2],p.pale[3],1) end
            if row.npcTitle then row.npcTitle:SetTextColor(p.muted[1],p.muted[2],p.muted[3],1) end
            if row.hp then row.hp:SetTextColor(p.pale[1],p.pale[2],p.pale[3],1) end
            if row.divider then row.divider:SetVertexColor(p.border[1],p.border[2],p.border[3],.28) end
        end
        RepaintDMButtonsAndInputs(encounterWidget,p)
    end

    if initiativeFrame then
        Backdrop(initiativeFrame,p.main,p.border)
        if initiativeFrame.title then initiativeFrame.title:SetTextColor(p.gold[1],p.gold[2],p.gold[3],1) end
        if initiativeFrame.round then initiativeFrame.round:SetTextColor(p.muted[1],p.muted[2],p.muted[3],1) end
        if initiativeFrame.elapsed then initiativeFrame.elapsed:SetTextColor(p.muted[1],p.muted[2],p.muted[3],1) end
        RepaintDMButtonsAndInputs(initiativeFrame,p)
    end

    if rollFrame then
        Backdrop(rollFrame,p.main,p.gold)
        if rollFrame.title then rollFrame.title:SetTextColor(p.gold[1],p.gold[2],p.gold[3],1) end
        if rollFrame.info then rollFrame.info:SetTextColor(p.pale[1],p.pale[2],p.pale[3],1) end
        if rollFrame.dice then rollFrame.dice:SetTextColor(p.muted[1],p.muted[2],p.muted[3],1) end
        for _,card in ipairs(rollFrame.cards or {}) do
            Backdrop(card,p.panel2,p.border)
            if card.name then card.name:SetTextColor(p.pale[1],p.pale[2],p.pale[3],1) end
            if card.placeholder then card.placeholder:SetTextColor(p.muted[1],p.muted[2],p.muted[3],1) end
        end
        RepaintDMButtonsAndInputs(rollFrame,p)
    end

    -- Rebuild dynamic content after repainting the persistent chrome.  This is
    -- intentionally lightweight and preserves open/closed/dragged state.
    if drawer then RPAL.RefreshDMDrawer() end
    if mainFrame and mainFrame:IsShown() then RPAL.RefreshDMKitUI() end
    if encounterWidget then RPAL.RefreshDMEncounterWidget() end
    if rollFrame and rollFrame:IsShown() then RPAL.RefreshDMRollUI() end
    if initiativeFrame then RPAL.RefreshDMInitiativeUI() end
end

-- Repaint and initial availability after the main UI has loaded.
C_Timer.After(0.2,function()RPAL.RefreshDMKitAvailability();RPAL.RefreshDMEncounterWidget();if RPAL.RefreshDMInitiativeUI then RPAL.RefreshDMInitiativeUI() end end)
