local ADDON_NAME, RPAL = ...

-- ============================================================
-- RPAL ADVENTURER'S DESK — THEME-AWARE SHELL
-- TravelBook assets are neutralized so every surface follows the
-- currently equipped RPAL theme. Navigation uses the user-supplied
-- Game Icon Pack and lightweight hover transitions.
-- ============================================================

local UI = RPAL.UI
local TEX = "Interface\\AddOns\\ODYN\\Textures\\"
local TB  = TEX.."TravelBookTheme\\"
local GI  = RPAL.ICON_ROOT
local WHITE = "Interface\\Buttons\\WHITE8X8"
local FONT = STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF"

local ASSET = {
    COVER=TB.."UI_TravelBook_BookCover01a.tga",
    PAGE_L=TB.."UI_TravelBook_BookPageLeft01a.tga",
    PAGE_R=TB.."UI_TravelBook_BookPageRight01a.tga",
    FRAME=TB.."UI_TravelBook_Frame01a.tga",
    FRAME_ACTIVE=TB.."UI_TravelBook_FrameSelect01a.tga",
    POPUP=TB.."UI_TravelBook_Popup01a.tga",
    BAR=TB.."UI_TravelBook_Bar01a.tga",
    FILL=TB.."UI_TravelBook_Fill01a.tga",
    SLOT=TB.."UI_TravelBook_Slot01c.tga",
    SELECT=TB.."UI_TravelBook_Select01a.tga",
    POINT=TB.."UI_TravelBook_Point01a.tga",
    ARROW=GI.."arrow_right.tga",

    OVERVIEW=GI.."home.tga",
    ACHIEVEMENTS=GI.."trophy.tga",
    CHRONICLE=GI.."scroll.tga",
    CALENDAR=GI.."list.tga",
    CUSTOMIZE=GI.."customize.tga",
    ATLAS=GI.."map.tga",
    TOOLS=GI.."tools.tga",
    SKILLSET=GI.."badge.tga",
    INJURY=GI.."warning.tga",
    SETTINGS=GI.."settings.tga",
    DMKIT=GI.."people.tga",
    PLAY=GI.."play.tga",
    MINIGAMES=GI.."play.tga",
    -- Currency needs its own visual identity; the old token fallback was the
    -- same medal artwork used by Skill Set on some installs.
    CURRENCIES=GI.."barrel.tga",
    PERSONALITY=GI.."emote_neutral.tga",
    PAUSE=GI.."pause.tga",
    EDIT=GI.."edit.tga",
    MOMENTS=GI.."scroll.tga",
    MEMOIR=GI.."book.tga",
    PEOPLE=GI.."people.tga",
    CLOCK=GI.."replay.tga",
}

local desk
local sideRows={}

local DEFAULT_NAV_ORDER={"overview","personality","atlas","chronicle","achievements","calendar","injury","skillset","minigames","customize","tools","dmkit"}
local PREVIOUS_DEFAULT_NAV_ORDER={"overview","atlas","chronicle","achievements","calendar","injury","currencies","skillset","minigames","customize","tools","dmkit"}
local NAV_ORDER_VERSION=5
local NAV_LABELS={overview="Overview",achievements="Achievements",chronicle="Sessions",minigames="Mini Games",calendar="Calendar",injury="Health",personality="Persona Beta",currencies="Currencies",skillset="Skill Set",customize="Customize",atlas="Atlas",tools="Tools",dmkit="DM Kit"}
local VALID_LAYOUTS={desk=true,focus=true,compact=true,wide=true}
local LAYOUTS={
    desk={w=1240,h=760,nav=166,right=218,sidebar=true,header=166,progress=60,outer=8,gap=8},
    focus={w=1180,h=760,nav=154,right=0,sidebar=false,header=160,progress=60,outer=8,gap=10},
    compact={w=1080,h=720,nav=136,right=0,sidebar=false,header=148,progress=54,outer=8,gap=8},
    wide={w=1280,h=790,nav=164,right=224,sidebar=true,header=172,progress=62,outer=8,gap=10},
}

local function SameOrder(a,b)
    if type(a)~="table" or type(b)~="table" or #a~=#b then return false end
    for i=1,#a do if a[i]~=b[i] then return false end end
    return true
end

local function SanitizedNavOrder()
    local settings=RPAL.GetSettings and RPAL.GetSettings() or {}
    local saved=type(settings.uiNavOrder)=="table" and settings.uiNavOrder or {}
    -- Persona replaces the old Personality/Currencies slot and is now a fixed
    -- second-tier destination directly below Overview. Preserve every other
    -- destination's relative order while promoting Persona to position 2.
    for i,key in ipairs(saved) do if key=="currencies" then saved[i]="personality" end end
    if math.floor(tonumber(settings.uiNavOrderVersion) or 0)<NAV_ORDER_VERSION then
        local rebuilt={"overview","personality"}
        local seen={overview=true,personality=true}
        for _,key in ipairs(saved) do if key~="overview" and key~="personality" and not seen[key] then rebuilt[#rebuilt+1]=key;seen[key]=true end end
        for _,key in ipairs(DEFAULT_NAV_ORDER) do if not seen[key] then rebuilt[#rebuilt+1]=key;seen[key]=true end end
        saved=rebuilt;settings.uiNavOrder=saved;settings.uiNavOrderVersion=NAV_ORDER_VERSION
    end
    local valid,want={},{}
    for _,key in ipairs(DEFAULT_NAV_ORDER) do valid[key]=true end
    for _,key in ipairs(saved) do if valid[key] and not want[key] then want[key]=true end end
    local out={}
    for _,key in ipairs(saved) do if valid[key] and want[key] then out[#out+1]=key;want[key]=nil end end
    for _,key in ipairs(DEFAULT_NAV_ORDER) do
        local found=false;for _,existing in ipairs(out) do if existing==key then found=true break end end
        if not found then out[#out+1]=key end
    end
    settings.uiNavOrder={};for i,key in ipairs(out) do settings.uiNavOrder[i]=key end
    return out
end

function RPAL.GetDeskNavigationOrder()
    local src=SanitizedNavOrder();local out={};for i,key in ipairs(src) do out[i]=key end;return out
end
function RPAL.GetDeskNavigationLabel(key) return NAV_LABELS[tostring(key or "")] or tostring(key or "") end
function RPAL.IsDeskNavigationVisible(key)
    local settings=RPAL.GetSettings and RPAL.GetSettings() or {}
    settings.uiNavHidden=type(settings.uiNavHidden)=="table" and settings.uiNavHidden or {}
    return settings.uiNavHidden[tostring(key or "")]~=true
end
function RPAL.SetDeskNavigationVisible(key,visible)
    key=tostring(key or "")
    local valid=false;for _,candidate in ipairs(DEFAULT_NAV_ORDER) do if candidate==key then valid=true break end end
    if not valid then return false end
    local settings=RPAL.GetSettings and RPAL.GetSettings() or {}
    settings.uiNavHidden=type(settings.uiNavHidden)=="table" and settings.uiNavHidden or {}
    settings.uiNavHidden[key]=visible==false and true or nil
    if RPAL.RefreshBookChrome then RPAL.RefreshBookChrome() end
    return true
end
function RPAL.ResetDeskNavigationVisibility()
    local settings=RPAL.GetSettings and RPAL.GetSettings() or {};settings.uiNavHidden={}
    if RPAL.RefreshBookChrome then RPAL.RefreshBookChrome() end
end
function RPAL.MoveDeskNavigationItem(key,delta)
    key=tostring(key or "");delta=tonumber(delta) or 0
    local order=SanitizedNavOrder();local at
    for i,v in ipairs(order) do if v==key then at=i break end end
    if not at then return false end
    local to=math.max(1,math.min(#order,at+(delta<0 and -1 or 1)))
    if to==at then return false end
    order[at],order[to]=order[to],order[at]
    local settings=RPAL.GetSettings and RPAL.GetSettings() or {};settings.uiNavOrder=order
    if RPAL.RefreshBookChrome then RPAL.RefreshBookChrome() end
    return true
end
function RPAL.ResetDeskNavigationOrder()
    local settings=RPAL.GetSettings and RPAL.GetSettings() or {};settings.uiNavOrder={}
    for i,key in ipairs(DEFAULT_NAV_ORDER) do settings.uiNavOrder[i]=key end
    settings.uiNavOrderVersion=NAV_ORDER_VERSION
    if RPAL.RefreshBookChrome then RPAL.RefreshBookChrome() end
end
function RPAL.SetDeskLayout(layout)
    layout=tostring(layout or "desk"):lower();if not VALID_LAYOUTS[layout] then layout="desk" end
    local settings=RPAL.GetSettings and RPAL.GetSettings() or {};settings.uiLayout=layout
    if RPAL.RefreshBookChrome then RPAL.RefreshBookChrome() end
    return layout
end
function RPAL.GetDeskLayout()
    local settings=RPAL.GetSettings and RPAL.GetSettings() or {};local layout=tostring(settings.uiLayout or "desk"):lower();return VALID_LAYOUTS[layout] and layout or "desk"
end

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

local function Copy(c)
    return {c[1] or 0,c[2] or 0,c[3] or 0,c[4] or 1}
end

local function Palette()
    if RPAL.GetCurrentThemePalette then
        local p=RPAL.GetCurrentThemePalette()
        if p then return p end
    end
    return {
        gold={.86,.63,.28,1},pale={.88,.82,.68,1},muted={.57,.50,.40,1},
        panel={.105,.065,.035,.98},panel2={.16,.105,.055,.98},border={.42,.28,.12,1},
        main={.075,.042,.022,.99},page={.18,.125,.067,.96},pageAlt={.125,.080,.042,.97},
        button={.105,.070,.040,1},buttonHover={.17,.115,.060,1},
        accent={.66,.45,.16,1},accentHover={.78,.56,.22,1},
    }
end

local function Mul(c,f,a)
    return {
        math.max(0,math.min(1,(c[1] or 0)*f)),
        math.max(0,math.min(1,(c[2] or 0)*f)),
        math.max(0,math.min(1,(c[3] or 0)*f)),
        a or c[4] or 1
    }
end

local function Mix(a,b,t,alpha)
    t=math.max(0,math.min(1,t or 0))
    return {
        (a[1] or 0)*(1-t)+(b[1] or 0)*t,
        (a[2] or 0)*(1-t)+(b[2] or 0)*t,
        (a[3] or 0)*(1-t)+(b[3] or 0)*t,
        alpha or ((a[4] or 1)*(1-t)+(b[4] or 1)*t)
    }
end

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

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

local function SetTextColor(fs,c)
    if fs and c then fs:SetTextColor(c[1],c[2],c[3],c[4] or 1) end
end

local function AddTravelTexture(parent,path,layer,sublevel,alpha)
    local t=parent:CreateTexture(nil,layer or "BACKGROUND",nil,sublevel or 0)
    t:SetTexture(path);t:SetAllPoints(parent);t:SetAlpha(alpha or 1)
    return t
end

local function Tooltip(title,body)
    if RPAL.ShowSimpleCursorTooltip then
        RPAL.ShowSimpleCursorTooltip(title,body,Palette().gold,"ODYN")
    end
end

local function HideTooltip()
    if RPAL.HideCursorTooltip then RPAL.HideCursorTooltip() end
end

local function SetIconTexture(tex,path)
    tex:SetTexture(path)
    tex:SetTexCoord(0.06,0.94,0.06,0.94)
end

local function TransitionContent(action)
    if not action then return end
    if RPAL.ClosePersonalityUI then RPAL.ClosePersonalityUI(true) end
    if not UI or not UI.content then action();return end
    local host=UI.content
    if not desk.transition then
        local cover=CreateFrame("Frame",nil,host,"BackdropTemplate")
        cover:SetAllPoints(host);cover:SetFrameLevel((host:GetFrameLevel() or 1)+180);cover:EnableMouse(true);cover:Hide()
        cover:SetBackdrop({bgFile=WHITE});cover:SetBackdropColor(0,0,0,1)
        desk.transition=cover
    end
    local cover=desk.transition
    local p=Palette();cover:SetBackdropColor(p.main[1],p.main[2],p.main[3],1)
    cover._pending=action;cover._age=0;cover._switched=nil;cover:SetAlpha(0);cover:Show()
    cover:SetScript("OnUpdate",function(self,elapsed)
        self._age=(self._age or 0)+(elapsed or 0)
        local a=self._age
        if a<0.09 then
            self:SetAlpha(math.min(0.82,a/0.09*0.82))
        elseif not self._switched then
            self._switched=true
            local fn=self._pending;self._pending=nil
            if fn then
                if RPAL.Guard then RPAL.Guard("main-navigation",fn) else pcall(fn) end
            end
        elseif a<0.25 then
            self:SetAlpha(math.max(0,0.82*(1-((a-0.09)/0.16))))
        else
            self:SetScript("OnUpdate",nil);self._switched=nil;self:Hide();self:SetAlpha(0)
        end
    end)
end

-- Shared page transition used by the main navigation. Skill Set is a normal
-- content page, so it participates in the same cross-fade as Overview and
-- Achievements instead of behaving like a separate window.
RPAL.TransitionContent = TransitionContent

local function AnimatePanelScale(frame,goal,maxScale)
    if not frame then return end
    frame._rpalScaleGoal=goal or 0;frame._rpalScaleMax=maxScale or 0.03
    if frame:GetScript("OnUpdate") then return end
    frame:SetScript("OnUpdate",function(self,elapsed)
        local c=tonumber(self._rpalScaleT) or 0;local g=tonumber(self._rpalScaleGoal) or 0
        c=c+((g-c)*math.min(1,(elapsed or 0)*11));if math.abs(g-c)<0.01 then c=g end
        self._rpalScaleT=c;self:SetScale(1+(c*(self._rpalScaleMax or 0.03)))
        if c==g then self:SetScript("OnUpdate",nil) end
    end)
end


local function ShowOverview()
    if not UI then return end
    if RPAL.HideChronicle then RPAL.HideChronicle(true) end
    if UI.main and not UI.main:IsShown() then UI.main:Show() end
    if RPAL.SetMainContentMode then RPAL.SetMainContentMode("overview")
    elseif RPAL.Refresh then RPAL.Refresh() end
end
RPAL.ShowOverview = ShowOverview

local function ShowAchievements()
    if not UI then return end
    if RPAL.HideChronicle then RPAL.HideChronicle(true) end
    if UI.main and not UI.main:IsShown() then UI.main:Show() end
    if RPAL.SetMainContentMode then RPAL.SetMainContentMode("achievements")
    elseif RPAL.Refresh then RPAL.Refresh() end
end

local function ApplyNavVisual(b)
    if not b then return end
    local p=Palette()
    local h=tonumber(b._hoverT) or 0
    local active=b._active and true or false
    local base=active and p.accent or p.button
    local hover=active and p.accentHover or p.buttonHover
    local fill=Mix(base,hover,h,0.98)
    local border=Mix(p.border,p.gold,active and 0.82 or (0.18+(h*0.62)),0.98)

    SetBackdrop(b,fill,border)
    if b.paper then
        b.paper:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],1)
        b.paper:SetAlpha(active and 0.24 or (0.10+(h*0.10)))
    end
    if b.active then
        b.active:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],1)
        b.active:SetAlpha(active and (0.30+(h*0.12)) or (h*0.10))
        b.active:SetShown(active or h>0.01)
    end
    if b.art then
        local ic=active and p.pale or Mix(p.pale,p.gold,0.35+(h*0.35))
        b.art:SetVertexColor(ic[1],ic[2],ic[3],1)
        local size=30+(h*3)
        b.art:SetSize(size,size)
    end
    if b.txt then
        SetTextColor(b.txt,active and p.pale or Mix(p.pale,p.gold,0.28+(h*0.28)))
        -- Keep the label inside its fitted one-line lane.  The width is
        -- calculated from the real navigation-button width, so compact and
        -- high global font-scale layouts never fall back to WoW's ellipsis.
        local left=tonumber(b._labelLeft) or 54
        b.txt:ClearAllPoints();b.txt:SetPoint("LEFT",left+(h*2),1)
    end
    if b.arrow then
        b.arrow:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],0.28+(h*0.58))
    end
    if b.hoverGlow then
        b.hoverGlow:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],h*0.18)
    end
    if b.activeRail then
        b.activeRail:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],1)
        b.activeRail:SetAlpha(active and (0.82+(h*0.18)) or (h*0.34))
        b.activeRail:SetShown(active or h>0.01)
    end
    if b.SetScale then b:SetScale(1+(h*0.055)) end
end

local function AnimateNav(b,target)
    if not b then return end
    b._hoverGoal=target
    if b:GetScript("OnUpdate") then return end
    b:SetScript("OnUpdate",function(self,elapsed)
        local current=tonumber(self._hoverT) or 0
        local goal=tonumber(self._hoverGoal) or 0
        local speed=math.min(1,(elapsed or 0)*10)
        current=current+((goal-current)*speed)
        if math.abs(goal-current)<0.012 then current=goal end
        self._hoverT=current
        ApplyNavVisual(self)
        if current==goal then self:SetScript("OnUpdate",nil) end
    end)
end

local function FitNavButtonLabel(b)
    if not b or not b.txt then return end
    local width=math.max(126,tonumber(b:GetWidth()) or 158)
    -- Compact layout has less horizontal room.  Let the icon/text gap tighten
    -- slightly there before reducing type size, which keeps long destinations
    -- such as ACHIEVEMENTS and CUSTOMIZE as readable as possible.
    local left=math.max(43,math.min(54,math.floor((width*0.34)+0.5)))
    local rightReserve=22 -- arrow + a small safety gap
    local maxWidth=math.max(58,width-left-rightReserve)
    b._labelLeft=left
    b.txt:ClearAllPoints();b.txt:SetPoint("LEFT",left,1)
    if b.arrow then b.arrow:Show();b._labelArrowSuppressed=false end
    if RPAL.FitFontStringToWidth then
        RPAL.FitFontStringToWidth(b.txt,maxWidth,10.2,7.4)
        -- The arrow is decorative.  On very narrow layouts, prefer a larger,
        -- fully readable destination name over crushing it to tiny type.
        local meta=RPAL._scalableFontObjects and RPAL._scalableFontObjects[b.txt]
        if b.arrow and meta and (tonumber(meta.size) or 10.2)<8.6 then
            b.arrow:Hide();b._labelArrowSuppressed=true
            maxWidth=math.max(68,width-left-8)
            RPAL.FitFontStringToWidth(b.txt,maxWidth,10.2,7.4)
        end
    else
        b.txt:SetWidth(maxWidth);b.txt:SetWordWrap(false)
    end
end

local function MakeNavButton(parent,index,label,icon,click,tip)
    local b=CreateFrame("Button",nil,parent,"BackdropTemplate")
    b:SetSize(158,52)
    b:SetPoint("TOPLEFT",4,-8-((index-1)*55))
    b:SetFrameLevel(parent:GetFrameLevel()+8)
    b._hoverT=0;b._hoverGoal=0;b._active=false

    local paper=b:CreateTexture(nil,"BACKGROUND",nil,-1)
    paper:SetTexture(ASSET.FRAME)
    paper:SetPoint("TOPLEFT",3,-3);paper:SetPoint("BOTTOMRIGHT",-3,3)
    b.paper=paper

    local active=b:CreateTexture(nil,"BACKGROUND",nil,0)
    active:SetTexture(ASSET.FRAME_ACTIVE)
    active:SetPoint("TOPLEFT",1,-1);active:SetPoint("BOTTOMRIGHT",-1,1)
    active:Hide();b.active=active

    local hoverGlow=b:CreateTexture(nil,"BACKGROUND",nil,1)
    hoverGlow:SetTexture(WHITE);hoverGlow:SetPoint("TOPLEFT",5,-5);hoverGlow:SetPoint("BOTTOMRIGHT",-5,5)
    hoverGlow:SetBlendMode("ADD");b.hoverGlow=hoverGlow

    local activeRail=b:CreateTexture(nil,"OVERLAY",nil,2)
    activeRail:SetTexture(WHITE);activeRail:SetWidth(3);activeRail:SetPoint("TOPLEFT",3,-5);activeRail:SetPoint("BOTTOMLEFT",3,5)
    activeRail:Hide();b.activeRail=activeRail

    local art=b:CreateTexture(nil,"OVERLAY")
    SetIconTexture(art,icon);art:SetSize(28,28);art:SetPoint("LEFT",14,0);b.art=art

    local txt=Text(b,label,10.2)
    txt:SetPoint("LEFT",54,1);txt:SetWidth(82);txt:SetJustifyH("LEFT");txt:SetWordWrap(false);b.txt=txt

    local arrow=b:CreateTexture(nil,"OVERLAY")
    arrow:SetTexture(ASSET.ARROW);arrow:SetSize(15,13);arrow:SetPoint("RIGHT",-7,0);b.arrow=arrow
    FitNavButtonLabel(b)

    b:SetScript("OnEnter",function(self)
        self:SetFrameLevel(parent:GetFrameLevel()+24)
        AnimateNav(self,1)
        Tooltip(label,tip or "Open "..label..".")
    end)
    b:SetScript("OnLeave",function(self)
        self:SetFrameLevel(parent:GetFrameLevel()+8)
        AnimateNav(self,0);HideTooltip()
    end)
    b:SetScript("OnMouseDown",function(self)
        if self.art then self.art:SetPoint("LEFT",15,-1) end
    end)
    b:SetScript("OnMouseUp",function(self)
        if self.art then self.art:SetPoint("LEFT",14,0) end
    end)
    b:SetScript("OnClick",function()if RPAL.ClosePersonalityUI then RPAL.ClosePersonalityUI(true) end;if click then click() end end)
    b._label=label
    ApplyNavVisual(b)
    return b
end

local function ApplySmallVisual(b)
    if not b then return end
    local p=Palette()
    local h=tonumber(b._hoverT) or 0
    local active=b._sceneActive and true or false
    local base=active and Mix(p.button,p.accent,0.52) or p.button
    local hover=active and p.accentHover or p.buttonHover
    SetBackdrop(b,Mix(base,hover,h,0.98),Mix(p.border,p.gold,0.26+(h*0.58)+(active and 0.18 or 0),0.98))
    if b.art then
        local c=Mix(p.pale,p.gold,0.35+(h*0.38))
        b.art:SetVertexColor(c[1],c[2],c[3],1)
    end
    if b.label then SetTextColor(b.label,active and p.pale or Mix(p.pale,p.gold,0.20+(h*0.32))) end
    if b.glow then b.glow:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],h*0.12) end
end

local function AnimateSmall(b,target)
    b._hoverGoal=target
    if b:GetScript("OnUpdate") then return end
    b:SetScript("OnUpdate",function(self,elapsed)
        local c=tonumber(self._hoverT) or 0
        local g=tonumber(self._hoverGoal) or 0
        c=c+((g-c)*math.min(1,(elapsed or 0)*10))
        if math.abs(c-g)<0.012 then c=g end
        self._hoverT=c;ApplySmallVisual(self)
        if c==g then self:SetScript("OnUpdate",nil) end
    end)
end

local function MakeSmallButton(parent,label,icon,w)
    local b=CreateFrame("Button",nil,parent,"BackdropTemplate")
    b:SetSize(w or 75,34);b._hoverT=0;b._hoverGoal=0
    local art=b:CreateTexture(nil,"ARTWORK");SetIconTexture(art,icon);art:SetSize(20,20);art:SetPoint("LEFT",9,0);b.art=art
    local txt=Text(b,label,9.5);txt:SetPoint("LEFT",34,0);b.label=txt
    local glow=b:CreateTexture(nil,"BACKGROUND",nil,1);glow:SetTexture(WHITE);glow:SetPoint("TOPLEFT",4,-4);glow:SetPoint("BOTTOMRIGHT",-4,4);glow:SetBlendMode("ADD");b.glow=glow
    b:SetScript("OnEnter",function(self)self:SetScale(1.035);AnimateSmall(self,1);Tooltip(label,"ODYN quick action.")end)
    b:SetScript("OnLeave",function(self)self:SetScale(1);AnimateSmall(self,0);HideTooltip()end)
    b:SetScript("OnMouseDown",function(self)self.art:SetPoint("LEFT",10,-1)end)
    b:SetScript("OnMouseUp",function(self)self.art:SetPoint("LEFT",9,0)end)
    ApplySmallVisual(b)
    return b
end

local function MonthData(year,month)
    local nextY,nextM=year,month+1
    if nextM>12 then nextM=1;nextY=year+1 end
    local last=time({year=nextY,month=nextM,day=1,hour=12,min=0,sec=0})-86400
    local days=tonumber(date("%d",last)) or 30
    local w=tonumber(date("%w",time({year=year,month=month,day=1,hour=12,min=0,sec=0}))) or 1
    return days,(w+6)%7
end

local function RenderMiniCalendar()
    if not desk or not desk.calendarGrid then return end
    local p=Palette()
    local now=date("*t")
    desk.calendarMonth:SetText(string.upper(date("%B %Y")))
    SetTextColor(desk.calendarMonth,p.pale)

    -- Build the visible month once.  The previous implementation asked the
    -- calendar module to scan and sort the complete event feed separately for
    -- every day button, which made otherwise small UI refreshes needlessly
    -- expensive when the community feed was large.
    local eventsByDay={}
    if RPAL.GetCalendarDisplayEvents then
        for _,event in ipairs(RPAL.GetCalendarDisplayEvents("all") or {}) do
            if tonumber(event.year)==now.year and tonumber(event.month)==now.month then
                local day=tonumber(event.day)
                if day then
                    eventsByDay[day]=eventsByDay[day] or {}
                    eventsByDay[day][#eventsByDay[day]+1]=event
                end
            end
        end
    end

    local days,first=MonthData(now.year,now.month)
    for i,b in ipairs(desk.calendarDays) do
        local day=i-first
        if day>=1 and day<=days then
            b:Show();b.day=day;b.text:SetText(tostring(day))
            local ev=eventsByDay[day]
            if not ev and not RPAL.GetCalendarDisplayEvents then
                ev=(RPAL.GetCalendarDisplayEventsForDate and RPAL.GetCalendarDisplayEventsForDate(now.year,now.month,day,"all")) or (RPAL.GetCalendarEventsForDate and RPAL.GetCalendarEventsForDate(now.year,now.month,day))
            end
            ev=ev or {}
            b._calendarEvents=ev
            local count=#ev;b.dot:SetShown(count>0)
            if day==now.day then
                SetBackdrop(b,Mix(p.button,p.accent,0.58),p.gold)
                SetTextColor(b.text,p.pale)
            elseif count>0 then
                SetBackdrop(b,Mix(p.button,p.accent,0.24),Mix(p.border,p.gold,0.54))
                SetTextColor(b.text,Mix(p.pale,p.gold,0.38))
            else
                SetBackdrop(b,Mul(p.button,.82,.86),Mul(p.border,.86,.74))
                SetTextColor(b.text,p.muted)
            end
            b.dot:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],0.88)
        else
            b:Hide();b.day=nil
        end
    end
end

local function ClearDynamic(parent)
    if parent and parent._items then
        for _,x in ipairs(parent._items) do if x and x.Hide then x:Hide() end end
    end
    if parent then parent._items={} end
end

local function K(parent,x)
    parent._items[#parent._items+1]=x
    return x
end

local function SidebarPaletteSignature(p)
    p=p or Palette();local parts={}
    for _,key in ipairs({"gold","pale","muted","panel","panel2","border","main"}) do
        local c=p[key] or {};parts[#parts+1]=string.format("%.3f,%.3f,%.3f,%.3f",c[1] or 0,c[2] or 0,c[3] or 0,c[4] or 1)
    end
    return table.concat(parts,"|")
end

local function RenderPinnedMoments()
    if not desk or not desk.momentsContent then return end
    local p=Palette()
    local moments=RPAL.GetPinnedMoments and RPAL.GetPinnedMoments() or {}
    local signatureParts={SidebarPaletteSignature(p)}
    for i=1,math.min(3,#moments) do local m=moments[i] or{};signatureParts[#signatureParts+1]=table.concat({tostring(m.id or""),tostring(m.title or""),tostring(m.zone or""),tostring(m.at or 0),tostring(m.duration or 0),tostring(m.interactions or 0),tostring(m.renownGained or 0),tostring(m.text or""),tostring(#(m.peopleDetails or m.people or{})),tostring(#(m.locations or{}))},"\031")end
    local signature=table.concat(signatureParts,"\030")
    if desk._momentsSignature==signature then return end
    desk._momentsSignature=signature
    local parent=desk.momentsContent;ClearDynamic(parent)
    local y=-6
    if #moments==0 then
        local empty=K(parent,Text(parent,"No pinned Sessions yet.",9.2,p.muted));empty:SetPoint("TOP",0,-18)
        return
    end
    for i=1,math.min(3,#moments) do
        local m=moments[i]
        local row=K(parent,CreateFrame("Button",nil,parent,"BackdropTemplate"));row:SetSize(198,46);row:SetPoint("TOP",0,y);SetBackdrop(row,Mul(p.panel2,.82,.94),Mul(p.border,.86,.78))
        local icon=K(parent,row:CreateTexture(nil,"ARTWORK"));SetIconTexture(icon,ASSET.MOMENTS);icon:SetSize(25,25);icon:SetPoint("LEFT",8,0);icon:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],.94)
        local t=K(parent,Text(row,m.title or "A Saved Session",9.5,p.pale));t:SetPoint("TOPLEFT",40,-8);t:SetPoint("RIGHT",-6,0);t:SetWordWrap(false)
        local meta=(m.zone and m.zone~="") and m.zone or (m.text and m.text~="" and m.text or "Remembered session")
        local s=K(parent,Text(row,meta,8,p.muted));s:SetPoint("TOPLEFT",40,-27);s:SetPoint("RIGHT",-6,0);s:SetWordWrap(false)
        row:SetScript("OnEnter",function(self)self:SetScale(1.025);self:SetBackdropBorderColor(p.gold[1],p.gold[2],p.gold[3],.88)end)
        row:SetScript("OnLeave",function(self)self:SetScale(1);local c=Mul(p.border,.86,.78);self:SetBackdropBorderColor(c[1],c[2],c[3],c[4])end)
        row:SetScript("OnClick",function()if RPAL.OpenMomentDetail then RPAL.OpenMomentDetail(m) elseif RPAL.OpenChronicle then RPAL.OpenChronicle("Moments") end end)
        y=y-50
    end
end

local function RenderMemoirs()
    if not desk or not desk.memoirContent then return end
    local p=Palette()
    local memoirs=RPAL.GetUnlockedMemoirs and RPAL.GetUnlockedMemoirs() or {}
    local defs=RPAL.MEMOIR_DEFINITIONS or {}
    local signatureParts={SidebarPaletteSignature(p)}
    for i=1,math.min(3,#memoirs) do local entry=memoirs[i] or{};local def=(entry.id and defs[entry.id])or entry or{};signatureParts[#signatureParts+1]=table.concat({tostring(entry.id or""),tostring(entry.title or def.title or def.name or""),tostring(entry.unlockedAt or 0)},"\031")end
    local signature=table.concat(signatureParts,"\030")
    if desk._memoirSignature==signature then return end
    desk._memoirSignature=signature
    local parent=desk.memoirContent;ClearDynamic(parent)
    local y=-6
    if #memoirs==0 then
        local empty=K(parent,Text(parent,"No Memoirs unlocked yet.",9.2,p.muted));empty:SetPoint("TOP",0,-18)
        return
    end
    for i=1,math.min(3,#memoirs) do
        local entry=memoirs[i] or {};local def=(entry.id and defs[entry.id]) or entry or {}
        local title=entry.title or def.title or def.name or "Memoir"
        local row=K(parent,CreateFrame("Button",nil,parent,"BackdropTemplate"));row:SetSize(198,42);row:SetPoint("TOP",0,y);SetBackdrop(row,Mul(p.panel2,.82,.94),Mul(p.border,.86,.78))
        local star=K(parent,row:CreateTexture(nil,"ARTWORK"));SetIconTexture(star,ASSET.MEMOIR);star:SetSize(25,25);star:SetPoint("LEFT",9,0);star:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],.96)
        local t=K(parent,Text(row,title,9.3,p.pale));t:SetPoint("LEFT",40,0);t:SetPoint("RIGHT",-6,0);t:SetWordWrap(false)
        row:SetScript("OnEnter",function(self)self:SetScale(1.025);self:SetBackdropBorderColor(p.gold[1],p.gold[2],p.gold[3],.88)end)
        row:SetScript("OnLeave",function(self)self:SetScale(1);local c=Mul(p.border,.86,.78);self:SetBackdropBorderColor(c[1],c[2],c[3],c[4])end)
        row:SetScript("OnClick",function()if RPAL.OpenTalentAtlas then RPAL.OpenTalentAtlas() end end)
        y=y-46
    end
end

function RPAL.ResolveGlanceIcon(icon)
    if type(icon)=="number" then return icon end

    -- Be tolerant of TRP3 icon values copied through different APIs.
    if type(icon)=="table" then
        if icon.GetFileID then
            local ok,value=pcall(icon.GetFileID,icon)
            if ok and value then return value end
        end
        if icon.GetFilePath then
            local ok,value=pcall(icon.GetFilePath,icon)
            if ok and value then return value end
        end
        icon=icon.path or icon.texture or icon.file or icon.fileName or icon.fileID or icon.id
    end

    icon=tostring(icon or "")
    if icon=="" then return RPAL.ICONS.question end

    -- Handle texture markup such as |TInterface\\Icons\\Foo:20|t.
    local markup=icon:match("^|T([^:|]+)")
    if markup and markup~="" then icon=markup end

    local numeric=tonumber(icon)
    if numeric then return numeric end

    if icon:find("Interface\\",1,true) or icon:find("interface\\",1,true)
        or icon:find("/",1,true) or icon:find("%.tga",1,true)
        or icon:find("%.blp",1,true) or icon:find("%.png",1,true) then
        return icon
    end

    -- Let TRP3 resolve its own icon representation when available.
    if TRP3_API and TRP3_API.utils and TRP3_API.utils.getIconTexture then
        local ok,value=pcall(TRP3_API.utils.getIconTexture,icon)
        if ok and value then return value end
    end

    return "Interface\\Icons\\"..icon
end

local function RepairMissingTRPGlanceIcons()
    if not (RPAL.char and RPAL.char.profile) then return end
    if not (TRP3_API and TRP3_API.profile and TRP3_API.profile.getData) then return end
    local ok,misc=pcall(TRP3_API.profile.getData,"player/misc")
    if not ok or type(misc)~="table" or type(misc.PE)~="table" then return end

    local glances=RPAL.char.profile.glances or {}
    for slot=1,5 do
        local entry=misc.PE[slot]
        if type(entry)=="table" then
            local icon=entry.IC or entry.icon or entry.texture
            if icon and icon~="" and type(glances[slot])=="table"
                and (not glances[slot].icon or glances[slot].icon=="") then
                if type(icon)=="table" then
                    if icon.GetFileID then
                        local okID,value=pcall(icon.GetFileID,icon)
                        if okID and value then icon=value end
                    end
                    if type(icon)=="table" and icon.GetFilePath then
                        local okPath,value=pcall(icon.GetFilePath,icon)
                        if okPath and value then icon=value end
                    end
                end
                glances[slot].icon=tostring(icon or "")
            end
        end
    end
end

local function RenderGlances()
    if not desk or not desk.glanceCards then return end
    RepairMissingTRPGlanceIcons()
    local p=Palette()
    local glances=(RPAL.char and RPAL.char.profile and RPAL.char.profile.glances) or {}
    for index,card in ipairs(desk.glanceCards) do
        local g=glances[index]
        local hasGlance=g and ((g.title or "")~="" or (g.description or "")~="")
        if g and g.icon and g.icon~="" then
            card.icon:SetTexture(RPAL.ResolveGlanceIcon(g.icon))
            card.icon:SetDesaturated(false)
            card.icon:SetVertexColor(1,1,1,1)
            card.icon:SetAlpha(1)
            card.icon:SetTexCoord(.02,.98,.02,.98)
            if card.tint then
                card.tint:SetColorTexture(p.gold[1],p.gold[2],p.gold[3],1)
                card.tint:SetAlpha(.06)
            end
            card._hasGlance=true
        else
            card.icon:SetTexture(RPAL.ICONS.question)
            card.icon:SetDesaturated(true)
            card.icon:SetVertexColor(p.muted[1],p.muted[2],p.muted[3],1)
            card.icon:SetAlpha(.14)
            card.icon:SetTexCoord(.08,.92,.08,.92)
            if card.tint then
                card.tint:SetColorTexture(p.muted[1],p.muted[2],p.muted[3],1)
                card.tint:SetAlpha(.035)
            end
            card._hasGlance=false
        end
        if card.shade then
            card.shade:SetColorTexture(p.main[1],p.main[2],p.main[3],hasGlance and .025 or .25)
        end
        if card.premadeFrame then
            card.premadeFrame:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],1)
            card.premadeFrame:SetAlpha(hasGlance and .78 or .34)
        end
        SetBackdrop(card,Mul(p.panel2,.82,.96),Mix(p.border,p.gold,.12,.46))
    end
end

local function BuildGlances(header)
    local p=Palette()
    local holder=CreateFrame("Frame",nil,header)
    local cardSize=40
    local gap=5
    holder:SetSize((cardSize*5)+(gap*4),cardSize)
    -- Share the Token Reserve's top edge; the two header widgets now read as
    -- one deliberate row instead of staggered controls.
    holder:SetPoint("TOPRIGHT",-112,-20)
    holder:SetFrameLevel(header:GetFrameLevel()+6)
    desk.glanceHolder=holder
    desk.glanceCards={}

    for index=1,5 do
        local card=CreateFrame("Frame",nil,holder,"BackdropTemplate")
        card:SetSize(cardSize,cardSize)
        card:SetPoint("TOPLEFT",(index-1)*(cardSize+gap),0)
        card:EnableMouse(true)

        -- Leave a small inset so the socket backdrop/border and its corners
        -- remain visible around the full-bleed icon.
        card.icon=card:CreateTexture(nil,"ARTWORK",nil,0)
        card.icon:SetSize(cardSize-10,cardSize-10)
        card.icon:SetPoint("CENTER",0,0)
        card.icon:SetTexCoord(.02,.98,.02,.98)

        card.tint=card:CreateTexture(nil,"ARTWORK",nil,-1)
        card.tint:SetPoint("TOPLEFT",3,-3)
        card.tint:SetPoint("BOTTOMRIGHT",-3,3)
        card.tint:SetBlendMode("ADD")
        card.tint:SetColorTexture(p.gold[1],p.gold[2],p.gold[3],.20)

        card.shade=card:CreateTexture(nil,"ARTWORK",nil,-2)
        card.shade:SetPoint("TOPLEFT",3,-3)
        card.shade:SetPoint("BOTTOMRIGHT",-3,3)
        card.shade:SetColorTexture(p.main[1],p.main[2],p.main[3],.08)

        card.premadeFrame=card:CreateTexture(nil,"OVERLAY",nil,2)
        card.premadeFrame:SetTexture((RPAL.PremadeGlanceFrames or {})[index])
        card.premadeFrame:SetPoint("TOPLEFT",-2,2)
        card.premadeFrame:SetPoint("BOTTOMRIGHT",2,-2)
        card.premadeFrame:SetBlendMode("BLEND")
        card.premadeFrame:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],1)
        card.premadeFrame:SetAlpha(.34)

        card:SetScript("OnEnter",function(self)
            local currentPalette=Palette()
            local g=(RPAL.char and RPAL.char.profile and RPAL.char.profile.glances or {})[index]
            if g and ((g.title or "")~="" or (g.description or "")~="") then
                Tooltip(g.title or "At a Glance",g.description or "")
            end
            self:SetBackdropBorderColor(currentPalette.gold[1],currentPalette.gold[2],currentPalette.gold[3],1)
            if self.icon then self.icon:SetAlpha(g and g.icon and g.icon~="" and 1 or .22) end
            if self.tint then self.tint:SetAlpha(g and g.icon and g.icon~="" and .10 or .06) end
            if self.premadeFrame then self.premadeFrame:SetAlpha(1) end
            AnimatePanelScale(self,1,.025)
        end)
        card:SetScript("OnLeave",function(self)
            HideTooltip()
            AnimatePanelScale(self,0,.025)
            RenderGlances()
        end)
        desk.glanceCards[index]=card
    end
    RenderGlances()
end

local function BuildRightSidebar(main)
    local bar=CreateFrame("Frame",nil,main,"BackdropTemplate")
    bar:SetPoint("TOPRIGHT",-8,-14);bar:SetPoint("BOTTOMRIGHT",-8,12);bar:SetWidth(218)
    -- Keep the persistent sidebar above the ledger page at all times.  The old
    -- level was low enough that the content page could paint over it; hovering
    -- the calendar temporarily raised only that panel, which made the whole
    -- rail look like it appeared on mouseover.
    bar:SetFrameLevel(main:GetFrameLevel()+70)
    bar:SetAlpha(1)
    bar.texture=AddTravelTexture(bar,ASSET.COVER,"BACKGROUND",-1,.24)
    desk.sidebar=bar

    local cal=CreateFrame("Button",nil,bar,"BackdropTemplate");cal:SetSize(210,244);cal:SetPoint("TOP",0,-10);cal:SetFrameLevel(bar:GetFrameLevel()+2);cal:SetAlpha(1)
    cal.page=cal:CreateTexture(nil,"BACKGROUND",nil,-1);cal.page:SetTexture(ASSET.PAGE_R);cal.page:SetPoint("TOPLEFT",4,-4);cal.page:SetPoint("BOTTOMRIGHT",-4,4);cal.page:SetAlpha(.34)
    local calIcon=cal:CreateTexture(nil,"ARTWORK");SetIconTexture(calIcon,ASSET.CALENDAR);calIcon:SetSize(18,18);calIcon:SetPoint("TOPLEFT",12,-9);cal.icon=calIcon
    cal.title=Text(cal,"TODAY IN AZEROTH",10.5);cal.title:SetPoint("TOP",8,-12)
    local month=Text(cal,"",10.5);month:SetPoint("TOP",0,-37);desk.calendarMonth=month
    cal.weekdays={}
    local weekdays={"M","T","W","T","F","S","S"}
    for i,l in ipairs(weekdays) do local t=Text(cal,l,8);t:SetPoint("TOPLEFT",12+((i-1)*25),-60);t:SetWidth(22);t:SetJustifyH("CENTER");cal.weekdays[i]=t end
    local grid=CreateFrame("Frame",nil,cal);grid:SetSize(180,148);grid:SetPoint("TOP",0,-78);desk.calendarGrid=grid;desk.calendarDays={}
    for i=1,42 do
        local row=math.floor((i-1)/7);local col=(i-1)%7
        local d=CreateFrame("Button",nil,grid,"BackdropTemplate");d:SetSize(23,21);d:SetPoint("TOPLEFT",4+(col*25),-(row*23))
        d.text=Text(d,"",8.3);d.text:SetPoint("CENTER",0,1)
        d.dot=d:CreateTexture(nil,"OVERLAY");d.dot:SetTexture(ASSET.POINT);d.dot:SetSize(6,6);d.dot:SetPoint("BOTTOM",0,1);d.dot:Hide()
        d:SetScript("OnClick",function(self)if self.day and RPAL.OpenCalendar then local now=date("*t");RPAL.OpenCalendar(now.year,now.month,self.day)end end)
        d:SetScript("OnEnter",function(self)
            local events=self._calendarEvents or {}
            if #events==0 then return end
            local lines={}
            for n,event in ipairs(events) do
                if n>6 then lines[#lines+1]="+"..tostring(#events-6).." more…";break end
                local hour=tonumber(event.hour) or 20;local minute=tonumber(event.minute) or 0
                local source=event._community and tostring(event.source or "Community") or "ODYN"
                lines[#lines+1]=string.format("%02d:%02d  %s  |cff79dff2%s|r",hour,minute,tostring(event.title or "Event"),source)
            end
            Tooltip(tostring(self.day).." "..string.upper(date("%B")),table.concat(lines,"\n"))
        end)
        d:SetScript("OnLeave",function()HideTooltip()end)
        desk.calendarDays[i]=d
    end
    cal:SetScript("OnClick",function()if RPAL.OpenCalendar then RPAL.OpenCalendar() end end)
    cal:SetScript("OnEnter",function(self)local p=Palette();self:SetBackdropBorderColor(p.gold[1],p.gold[2],p.gold[3],1);AnimatePanelScale(self,1,0.028);Tooltip("Today in Azeroth","Open the full Calendar and manage your RP events.")end)
    cal:SetScript("OnLeave",function(self)local p=Palette();local border=Mix(p.border,p.gold,.26,.96);AnimatePanelScale(self,0,0.028);HideTooltip();self:SetBackdropBorderColor(border[1],border[2],border[3],border[4] or 1)end)
    desk.calendarPanel=cal

    local moments=CreateFrame("Frame",nil,bar,"BackdropTemplate");moments:SetSize(210,164);moments:SetPoint("TOP",cal,"BOTTOM",0,-8);moments:SetFrameLevel(bar:GetFrameLevel()+2);moments:SetAlpha(1)
    local mi=moments:CreateTexture(nil,"ARTWORK");SetIconTexture(mi,ASSET.MOMENTS);mi:SetSize(17,17);mi:SetPoint("TOPLEFT",10,-8);moments.icon=mi
    moments.title=Text(moments,"PINNED SESSIONS",10.5);moments.title:SetPoint("TOPLEFT",33,-10)
    moments.rule=moments:CreateTexture(nil,"ARTWORK");moments.rule:SetTexture(ASSET.BAR);moments.rule:SetHeight(5);moments.rule:SetPoint("TOPLEFT",10,-32);moments.rule:SetPoint("TOPRIGHT",-10,-32)
    local mc=CreateFrame("Frame",nil,moments);mc:SetPoint("TOPLEFT",2,-39);mc:SetPoint("BOTTOMRIGHT",-2,4);desk.momentsContent=mc;desk.momentsPanel=moments

    local memoir=CreateFrame("Frame",nil,bar,"BackdropTemplate");memoir:SetPoint("TOP",moments,"BOTTOM",0,-8);memoir:SetPoint("BOTTOM",0,10);memoir:SetWidth(210);memoir:SetFrameLevel(bar:GetFrameLevel()+2);memoir:SetAlpha(1)
    local ri=memoir:CreateTexture(nil,"ARTWORK");SetIconTexture(ri,ASSET.MEMOIR);ri:SetSize(17,17);ri:SetPoint("TOPLEFT",10,-8);memoir.icon=ri
    memoir.title=Text(memoir,"MEMOIR HIGHLIGHTS",10.5);memoir.title:SetPoint("TOPLEFT",33,-10)
    memoir.rule=memoir:CreateTexture(nil,"ARTWORK");memoir.rule:SetTexture(ASSET.BAR);memoir.rule:SetHeight(5);memoir.rule:SetPoint("TOPLEFT",10,-32);memoir.rule:SetPoint("TOPRIGHT",-10,-32)
    local rc=CreateFrame("Frame",nil,memoir);rc:SetPoint("TOPLEFT",2,-39);rc:SetPoint("BOTTOMRIGHT",-2,4);desk.memoirContent=rc;desk.memoirPanel=memoir
end

local function HideMiniGamesPage()
    if desk then desk.miniGamesOpen=false;if desk.miniGamesPage then desk.miniGamesPage:Hide() end end
end
RPAL.HideMiniGamesPage=HideMiniGamesPage

local function ShowMiniGames()
    if not UI or not UI.content then return end
    if RPAL.SetSkillSetMode then RPAL.SetSkillSetMode(false,true) end
    if RPAL.HideChronicle then RPAL.HideChronicle(true) end
    if RPAL.CloseInjuryUI then RPAL.CloseInjuryUI(nil,false) end
    if not desk.miniGamesPage then
        local page=CreateFrame("Frame",nil,UI.content,"BackdropTemplate");page:SetAllPoints(UI.content);page:SetFrameLevel((UI.content:GetFrameLevel() or 1)+120);page:EnableMouse(true);SetBackdrop(page,Palette().page or Palette().panel,Palette().border);desk.miniGamesPage=page
        local wash=page:CreateTexture(nil,"BACKGROUND",nil,-1);wash:SetTexture(ASSET.PAGE_L);wash:SetAllPoints();wash:SetVertexColor(1,.84,.54,.12)
        local title=Text(page,"MINI GAMES",21,Palette().gold);title:SetPoint("TOPLEFT",28,-24)
        local sub=Text(page,"Shared ODYN tables. Every game supports another ODYN player; Chess and Checkers also include bot practice and player-owned table cosmetics.",9.0,Palette().muted);sub:SetPoint("TOPLEFT",30,-58);sub:SetWidth(760);sub:SetHeight(34);sub:SetWordWrap(true);sub:SetJustifyV("TOP")

        local function MakeGameCard(x,w,name,desc,tag,accent,openFn)
            local card=CreateFrame("Button",nil,page,"BackdropTemplate");card:SetSize(w,232);card:SetPoint("TOPLEFT",x,-105);SetBackdrop(card,Mul(Palette().panel2,.92,.98),Mix(Palette().border,accent,.58));card:SetFrameLevel(page:GetFrameLevel()+4)
            -- Give launch cards a deliberately stronger frame than ordinary desk
            -- panels; the previous 11px tooltip edge disappeared into the page.
            card:SetBackdrop({bgFile=WHITE,edgeFile="Interface\\Tooltips\\UI-Tooltip-Border",tile=false,tileSize=16,edgeSize=18,insets={left=4,right=4,top=4,bottom=4}})
            local baseBorder=Mix(Palette().border,accent,.58);card:SetBackdropColor((Mul(Palette().panel2,.92,.98))[1],(Mul(Palette().panel2,.92,.98))[2],(Mul(Palette().panel2,.92,.98))[3],.98);card:SetBackdropBorderColor(baseBorder[1],baseBorder[2],baseBorder[3],1)
            local topRule=card:CreateTexture(nil,"BORDER");topRule:SetTexture(WHITE);topRule:SetPoint("TOPLEFT",12,-8);topRule:SetPoint("TOPRIGHT",-12,-8);topRule:SetHeight(2);topRule:SetColorTexture(accent[1],accent[2],accent[3],.58)
            local art=card:CreateTexture(nil,"ARTWORK");SetIconTexture(art,ASSET.MINIGAMES);art:SetSize(48,48);art:SetPoint("TOPLEFT",18,-24);art:SetVertexColor(accent[1],accent[2],accent[3],1)
            local label=Text(card,name,12.6,Palette().pale);label:SetPoint("TOPLEFT",78,-22);label:SetWidth(w-94);label:SetHeight(46);label:SetWordWrap(true);label:SetJustifyH("LEFT");label:SetJustifyV("MIDDLE")
            local body=Text(card,desc,8.45,Palette().muted);body:SetPoint("TOPLEFT",18,-92);body:SetWidth(w-36);body:SetHeight(68);body:SetWordWrap(true);body:SetJustifyV("TOP")
            local meta=Text(card,tag,7.25,accent);meta:SetPoint("BOTTOMLEFT",18,61);meta:SetWidth(w-36);meta:SetHeight(24);meta:SetWordWrap(true);meta:SetJustifyV("BOTTOM")
            local launch=MakeSmallButton(card,"PLAY",ASSET.PLAY,112);launch:SetPoint("BOTTOMRIGHT",-16,16)
            card:SetScript("OnClick",openFn);launch:SetScript("OnClick",openFn)
            card:SetScript("OnEnter",function(self)self:SetBackdropBorderColor(accent[1],accent[2],accent[3],1);topRule:SetColorTexture(accent[1],accent[2],accent[3],.95);AnimatePanelScale(self,1,.018)end)
            card:SetScript("OnLeave",function(self)AnimatePanelScale(self,0,.018);local c=Mix(Palette().border,accent,.58);self:SetBackdropBorderColor(c[1],c[2],c[3],1);topRule:SetColorTexture(accent[1],accent[2],accent[3],.58)end)
            return card
        end

        MakeGameCard(28,244,"UNOFFICIAL\nHEARTHSTONE","Build decks, collect cards and cosmetics, then play across a shared two-sided arena.","ONLINE  •  BOT  •  CCG",Palette().gold,function()if RPAL.OpenHearthstone then RPAL.OpenHearthstone("play") end end)
        MakeGameCard(292,244,"CHESS","A full strategy board with split table themes, custom piece sets and legal-move enforcement.","ONLINE  •  BOT  •  STRATEGY",{.52,.75,1,1},function()if RPAL.OpenBoardGame then RPAL.OpenBoardGame("chess","play") end end)
        MakeGameCard(556,244,"CHECKERS","Fast draughts with mandatory captures, chained jumps, kings and the same two-sided cosmetic system.","ONLINE  •  BOT  •  DRAUGHTS",{1,.51,.29,1},function()if RPAL.OpenBoardGame then RPAL.OpenBoardGame("checkers","play") end end)

        local note=Text(page,"Chess and Checkers reuse the Hearthstone window language and page layout, but each game keeps its own palette and table identity.",8.8,Palette().muted);note:SetPoint("TOPLEFT",30,-366);note:SetWidth(760);note:SetHeight(34);note:SetWordWrap(true);note:SetJustifyV("TOP")
    end
    desk.miniGamesOpen=true;desk.miniGamesPage:Show();desk.miniGamesPage:Raise()
    if UI.main and not UI.main:IsShown() then UI.main:Show() end
    if RPAL.RefreshBookChrome then RPAL.RefreshBookChrome(true) end
end

RPAL.OpenMiniGames=ShowMiniGames

local function BuildLeftNavigation(main)
    local nav=CreateFrame("Frame",nil,main,"BackdropTemplate")
    nav:SetPoint("TOPLEFT",8,-14);nav:SetPoint("BOTTOMLEFT",8,12);nav:SetWidth(166)
    nav:SetFrameLevel(main:GetFrameLevel()+9)
    nav.texture=AddTravelTexture(nav,ASSET.COVER,"BACKGROUND",-1,.22)
    desk.nav=nav

    local navs={
        {"overview","OVERVIEW",ASSET.OVERVIEW,function()HideMiniGamesPage();if RPAL.CloseInjuryUI then RPAL.CloseInjuryUI(nil,false)end;TransitionContent(ShowOverview)end,"Character overview and social statistics."},
        {"personality","PERSONA BETA",ASSET.PERSONALITY,function()HideMiniGamesPage();if RPAL.CloseInjuryUI then RPAL.CloseInjuryUI(nil,false)end;if RPAL.SetSkillSetMode then RPAL.SetSkillSetMode(false,true) end;TransitionContent(function()if RPAL.OpenPersonalityUI then RPAL.OpenPersonalityUI() end end)end,"Persona Beta: calibrate once, then let the model develop through IC roleplay."},
        {"atlas","ATLAS",ASSET.ATLAS,function()HideMiniGamesPage();if RPAL.CloseInjuryUI then RPAL.CloseInjuryUI(nil,false)end;if RPAL.SetSkillSetMode then RPAL.SetSkillSetMode(false) end;if RPAL.OpenTalentAtlas then RPAL.OpenTalentAtlas()end end,"Open the full-screen Renown Atlas."},
        {"achievements","ACHIEVEMENTS",ASSET.ACHIEVEMENTS,function()HideMiniGamesPage();if RPAL.CloseInjuryUI then RPAL.CloseInjuryUI(nil,false)end;TransitionContent(ShowAchievements)end,"Open your personal Achievement pages."},
        {"chronicle","SESSIONS",ASSET.CHRONICLE,function()HideMiniGamesPage();if RPAL.CloseInjuryUI then RPAL.CloseInjuryUI(nil,false)end;TransitionContent(function()if RPAL.OpenChronicle then RPAL.OpenChronicle("Scenes")end end)end,"Active, paused and recorded Sessions, plus Bonds and Story Arcs."},
        {"minigames","MINI GAMES",ASSET.MINIGAMES,function()TransitionContent(ShowMiniGames)end,"Launch Unofficial Hearthstone, Chess and Checkers."},
        {"calendar","CALENDAR",ASSET.CALENDAR,function()HideMiniGamesPage();if RPAL.CloseInjuryUI then RPAL.CloseInjuryUI(nil,false)end;if RPAL.SetSkillSetMode then RPAL.SetSkillSetMode(false) end;if RPAL.OpenCalendar then RPAL.OpenCalendar()end end,"Plan RP dates and review upcoming events."},
        {"injury","HEALTH",ASSET.INJURY,function()HideMiniGamesPage();if RPAL.OpenInjurySystem then RPAL.OpenInjurySystem()end end,"Track injuries, permanent changes, notes and recovery."},
        {"skillset","SKILL SET",ASSET.SKILLSET,function()HideMiniGamesPage();if RPAL.CloseInjuryUI then RPAL.CloseInjuryUI(nil,false)end;if RPAL.OpenSkillSet then RPAL.OpenSkillSet()end end,"Character knowledge and proficiency for roleplay."},
        {"customize","CUSTOMIZE",ASSET.CUSTOMIZE,function()HideMiniGamesPage();if RPAL.CloseInjuryUI then RPAL.CloseInjuryUI(nil,false)end;if RPAL.SetSkillSetMode then RPAL.SetSkillSetMode(false) end;if RPAL.OpenCustomization then RPAL.OpenCustomization()end end,"Open the Appearance Vault."},
        {"tools","TOOLS",ASSET.TOOLS,function()HideMiniGamesPage();if RPAL.CloseInjuryUI then RPAL.CloseInjuryUI(nil,false)end;if RPAL.SetSkillSetMode then RPAL.SetSkillSetMode(false) end;if RPAL.OpenTools then RPAL.OpenTools()end end,"Import, export, sharing and maintenance."},
        {"dmkit","DM KIT",ASSET.DMKIT,function()HideMiniGamesPage();if RPAL.CloseInjuryUI then RPAL.CloseInjuryUI(nil,false)end;if RPAL.SetSkillSetMode then RPAL.SetSkillSetMode(false) end;if RPAL.OpenDMKit then RPAL.OpenDMKit()end end,"Campaign files, NPCs and live DM session controls."},
    }
    desk.navButtons={}
    for i,d in ipairs(navs) do desk.navButtons[d[1]]=MakeNavButton(nav,i,d[2],d[3],d[4],d[5]) end

    local scene=MakeSmallButton(nav,"START SESSION",ASSET.PLAY,148);scene:SetPoint("BOTTOM",0,45);desk.sceneButton=scene
    scene:SetScript("OnClick",function()
        if RPAL.IsSceneActive and RPAL.IsSceneActive() then
            if RPAL.RequestEndScene then RPAL.RequestEndScene() end
        elseif RPAL.StartScene then RPAL.StartScene() end
        RPAL.RefreshBookChrome(true)
    end)

    local settings=MakeSmallButton(nav,"SETTINGS",ASSET.SETTINGS,148);settings:SetPoint("BOTTOM",0,8);desk.settingsButton=settings
    settings:SetScript("OnClick",function()if RPAL.OpenSettings then RPAL.OpenSettings()end end)
end

function RPAL.OpenDeskNavigation(key)
    key=tostring(key or "")
    if RPAL.ShowMainUI then RPAL.ShowMainUI() elseif UI.main and not UI.main:IsShown() then UI.main:Show() end
    local button=desk and desk.navButtons and desk.navButtons[key]
    local handler=button and button.GetScript and button:GetScript("OnClick")
    if handler then handler(button,"LeftButton");return true end
    return false
end

local function BuildTitlePlaque(main)
    -- Keep the branding physically inside the Desk, but give it its own
    -- non-interactive presentation layer.  Main ornaments are rendered on
    -- child frames above the base frame, so font strings created directly on
    -- `main` can be covered even when they use the OVERLAY draw layer.  A
    -- dedicated high-level child frame fixes that ordering without bringing
    -- back the old floating plaque or any mouse hit rectangle.
    local brandLayer=CreateFrame("Frame",nil,main)
    brandLayer:SetAllPoints(main)
    brandLayer:SetFrameStrata(main:GetFrameStrata())
    brandLayer:SetFrameLevel((main:GetFrameLevel() or 0)+180)
    brandLayer:EnableMouse(false)
    brandLayer:EnableMouseWheel(false)

    local backing=brandLayer:CreateTexture(nil,"BACKGROUND",nil,2)
    backing:SetTexture(WHITE);backing:SetSize(250,18);backing:SetPoint("TOP",brandLayer,"TOP",0,-1)

    local leftLine=brandLayer:CreateTexture(nil,"ARTWORK",nil,3)
    leftLine:SetTexture(WHITE);leftLine:SetSize(34,1);leftLine:SetPoint("RIGHT",brandLayer,"TOP",-78,-9)
    local rightLine=brandLayer:CreateTexture(nil,"ARTWORK",nil,3)
    rightLine:SetTexture(WHITE);rightLine:SetSize(34,1);rightLine:SetPoint("LEFT",brandLayer,"TOP",78,-9)

    local title=Text(brandLayer,"ODYN",10.8)
    title:SetPoint("TOP",brandLayer,"TOP",-57,-3)
    title:SetWordWrap(false)

    local mark=Text(brandLayer,"•",7.2)
    mark:SetPoint("LEFT",title,"RIGHT",7,0)
    mark:SetWordWrap(false)

    local sub=Text(brandLayer,"made in Argent Dawn!",7.4)
    sub:SetPoint("LEFT",mark,"RIGHT",7,0)
    sub:SetWordWrap(false)

    -- Small, non-intrusive update notice. WoW addons cannot query CurseForge
    -- directly, so UI.lua feeds this from newer ODYN peers and (when Huginn
    -- exposes it) companion-provided release metadata.
    local update=Text(brandLayer,"UPDATE AVAILABLE",7.5,{1.00,0.55,0.28,1})
    update:SetPoint("TOPRIGHT",brandLayer,"TOPRIGHT",-48,-3)
    update:SetWidth(190);update:SetJustifyH("RIGHT");update:SetWordWrap(false);update:Hide()

    desk.brandLayer=brandLayer
    desk.brandBacking=backing
    desk.brandLineLeft=leftLine
    desk.brandLineRight=rightLine
    desk.brandTitle=title
    desk.brandMark=mark
    desk.brandSub=sub
    desk.updateBadge=update
    desk.titlePlaque=nil
end

local function RefreshUpdateNotice()
    if not desk or not desk.updateBadge then return end
    if RPAL.RefreshDetectedUpdateFromHuginn then RPAL.RefreshDetectedUpdateFromHuginn() end
    local info=RPAL.GetAvailableUpdateInfo and RPAL.GetAvailableUpdateInfo() or nil
    if not info then desk.updateBadge:Hide();return end
    local version=tostring(info.version or ""):gsub("^%s+",""):gsub("%s+$","")
    local build=tostring(info.build or ""):gsub("^%s+",""):gsub("%s+$","")
    local shortBuild=build:gsub("[Hh][Oo][Tt][Ff][Ii][Xx]%s*","HF")
    local suffix=(shortBuild~="" and shortBuild) or version
    desk.updateBadge:SetText("UPDATE AVAILABLE"..((suffix~="") and ("  •  "..suffix) or ""))
    desk.updateBadge:Show()
end

local function HideLegacyOuterPlate(main)
    -- Retire the pre-Desk bound-book layer. The Desk now owns a single,
    -- opaque theme-colored shell, so no translucent glass/world gaps remain.
    main._rpalBgRole=nil;main._rpalBorderRole=nil
    if main._rpalDropShadow then main._rpalDropShadow:Hide() end
    if main._rpalLeather then main._rpalLeather:Hide() end
    if main._rpalVignette then main._rpalVignette:Hide() end
    if main._rpalSpine then main._rpalSpine:Hide() end
    for _,corner in ipairs(main._rpalBookCorners or {}) do if corner and corner.Hide then corner:Hide() end end
    if main._rpalSurfaceSheen then main._rpalSurfaceSheen:Hide() end
    if main._rpalSurfaceShade then main._rpalSurfaceShade:Hide() end
    if UI and UI.brandPlate then UI.brandPlate:Hide() end
    if UI and UI.brandText then UI.brandText:Hide() end
end

local function StyleMainPages(main,header,content,progression)
    main:SetSize(1240,760)
    HideLegacyOuterPlate(main)
    if not desk.backplate then
        desk.backplate=main:CreateTexture(nil,"BACKGROUND",nil,-8)
        desk.backplate:SetTexture(WHITE);desk.backplate:SetAllPoints(main)
    end
    if not desk.shellTexture then
        desk.shellTexture=main:CreateTexture(nil,"BACKGROUND",nil,-6)
        desk.shellTexture:SetTexture(ASSET.COVER)
        desk.shellTexture:SetPoint("TOPLEFT",4,-4);desk.shellTexture:SetPoint("BOTTOMRIGHT",-4,4)
        desk.shellTexture:SetAlpha(0.10)
    end
    if not desk.topAccent then
        desk.topAccent=main:CreateTexture(nil,"BORDER",nil,-1);desk.topAccent:SetTexture(WHITE);desk.topAccent:SetHeight(2);desk.topAccent:SetPoint("TOPLEFT",14,-8);desk.topAccent:SetPoint("TOPRIGHT",-14,-8)
        desk.bottomAccent=main:CreateTexture(nil,"BORDER",nil,-1);desk.bottomAccent:SetTexture(WHITE);desk.bottomAccent:SetHeight(1);desk.bottomAccent:SetPoint("BOTTOMLEFT",18,8);desk.bottomAccent:SetPoint("BOTTOMRIGHT",-18,8)
        desk.innerGlow=main:CreateTexture(nil,"BACKGROUND",nil,-5);desk.innerGlow:SetTexture(WHITE);desk.innerGlow:SetPoint("TOPLEFT",8,-8);desk.innerGlow:SetPoint("TOPRIGHT",-8,-8);desk.innerGlow:SetHeight(70);desk.innerGlow:SetBlendMode("ADD")
        desk.centerBloom=main:CreateTexture(nil,"BACKGROUND",nil,-5);desk.centerBloom:SetTexture("Interface\\Cooldown\\star4");desk.centerBloom:SetBlendMode("ADD");desk.centerBloom:SetSize(500,500);desk.centerBloom:SetPoint("CENTER",42,-30);desk.centerBloom:SetAlpha(.018)
        desk.navRail=main:CreateTexture(nil,"BORDER",nil,-1);desk.navRail:SetTexture(WHITE);desk.navRail:SetWidth(1);desk.navRail:SetPoint("TOPLEFT",174,-20);desk.navRail:SetPoint("BOTTOMLEFT",174,20);desk.navRail:SetAlpha(.22)
        desk.sideRail=main:CreateTexture(nil,"BORDER",nil,-1);desk.sideRail:SetTexture(WHITE);desk.sideRail:SetWidth(1);desk.sideRail:SetPoint("TOPRIGHT",-226,-20);desk.sideRail:SetPoint("BOTTOMRIGHT",-226,20);desk.sideRail:SetAlpha(.18)
    end

    header:ClearAllPoints();header:SetPoint("TOPLEFT",174,-14);header:SetPoint("TOPRIGHT",-226,-14);header:SetHeight(166)
    header._rpalBgRole=nil;header._rpalBorderRole=nil
    header.deskPage=header:CreateTexture(nil,"BACKGROUND",nil,-1);header.deskPage:SetTexture(ASSET.PAGE_L);header.deskPage:SetPoint("TOPLEFT",4,-4);header.deskPage:SetPoint("BOTTOMRIGHT",-4,4);header.deskPage:SetAlpha(.25);desk.headerPage=header.deskPage

    content:ClearAllPoints();content:SetPoint("TOPLEFT",174,-180);content:SetPoint("BOTTOMRIGHT",-226,72)
    content._rpalBgRole=nil;content._rpalBorderRole=nil
    content.deskPage=content:CreateTexture(nil,"BACKGROUND",nil,-1);content.deskPage:SetTexture(ASSET.PAGE_L);content.deskPage:SetPoint("TOPLEFT",4,-4);content.deskPage:SetPoint("BOTTOMRIGHT",-4,4);content.deskPage:SetAlpha(.22);desk.contentPage=content.deskPage

    progression:ClearAllPoints();progression:SetPoint("BOTTOMLEFT",174,12);progression:SetPoint("BOTTOMRIGHT",-226,12);progression:SetHeight(60)
    progression._rpalBgRole=nil;progression._rpalBorderRole=nil
    progression.deskPage=progression:CreateTexture(nil,"BACKGROUND",nil,-1);progression.deskPage:SetTexture(ASSET.FRAME);progression.deskPage:SetPoint("TOPLEFT",3,-3);progression.deskPage:SetPoint("BOTTOMRIGHT",-3,3);progression.deskPage:SetAlpha(.20);desk.progressPage=progression.deskPage

    if UI.levelText then UI.levelText:ClearAllPoints();UI.levelText:SetPoint("LEFT",22,5) end
    if UI.levelSubText then UI.levelSubText:ClearAllPoints();UI.levelSubText:SetPoint("TOPLEFT",UI.levelText,"BOTTOMLEFT",0,-2) end
    if UI.renownBarTitle then UI.renownBarTitle:ClearAllPoints();UI.renownBarTitle:SetPoint("LEFT",180,14) end
    if UI.renownBar then UI.renownBar:ClearAllPoints();UI.renownBar:SetPoint("LEFT",180,-7);UI.renownBar:SetSize(360,20) end
    if UI.nextLevelText then UI.nextLevelText:ClearAllPoints();UI.nextLevelText:SetPoint("LEFT",570,9) end
    if UI.nextLevelNeed then UI.nextLevelNeed:ClearAllPoints();UI.nextLevelNeed:SetPoint("TOPLEFT",UI.nextLevelText,"BOTTOMLEFT",0,-3) end

    if UI.preview then
        UI.preview:ClearAllPoints();UI.preview:SetPoint("TOPLEFT",14,-8);UI.preview:SetPoint("BOTTOMRIGHT",-118,8)
    end
    if UI.crestCapsule then
        UI.crestCapsule:ClearAllPoints();UI.crestCapsule:SetPoint("TOPRIGHT",-18,-20);UI.crestCapsule:SetSize(82,30)
        UI.crestCapsule:SetFrameLevel(header:GetFrameLevel()+6)
        UI.crestCapsule._rpalBgRole=nil;UI.crestCapsule._rpalBorderRole=nil
    end

    for _,key in ipairs({"edit","ledgerToggle","tools","customize","settings","talentAtlas","sceneQuick","chronicle"}) do
        if UI[key] then UI[key]:Hide() end
    end
    for _,region in ipairs({header:GetRegions()}) do
        if region and region.GetObjectType and region:GetObjectType()=="FontString" and region.GetText then
            local txt=tostring(region:GetText() or "")
            if (txt=="STONE RESERVE" or txt=="TOKEN RESERVE") or txt=="LEDGER CONTROLS" or txt=="QUICK ACTIONS" then region:Hide() end
        elseif region and region.GetObjectType and region:GetObjectType()=="Texture" and region.GetWidth and region.GetHeight then
            local w,h=tonumber(region:GetWidth()) or 0,tonumber(region:GetHeight()) or 0
            if w<=2 and h>70 then region:Hide() end
        end
    end

    desk.stoneLabel=Text(header,"TOKEN RESERVE",8);desk.stoneLabel:SetPoint("TOPRIGHT",-18,-27)
    BuildGlances(header)

    local edit=MakeSmallButton(header,"EDIT",ASSET.EDIT,76)
    edit:SetPoint("BOTTOMRIGHT",-16,12);edit:SetFrameLevel(header:GetFrameLevel()+40)
    edit:SetScript("OnClick",function()if UI.edit and UI.edit.Click then UI.edit:Click()end end);desk.editButton=edit
end

local function ApplyDeskTheme()
    if not desk or not desk.main then return end
    local p=Palette()
    HideLegacyOuterPlate(desk.main)
    SetBackdrop(desk.main,Mix(p.main,p.panel,.08,1.0),Mix(p.border,p.gold,.26,0.98))
    if desk.backplate then desk.backplate:SetVertexColor(p.main[1],p.main[2],p.main[3],1);desk.backplate:SetAlpha(1) end
    if desk.shellTexture then desk.shellTexture:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],1);desk.shellTexture:SetAlpha(.075) end
    if desk.topAccent then desk.topAccent:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],.62) end
    if desk.bottomAccent then desk.bottomAccent:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],.28) end
    if desk.innerGlow then desk.innerGlow:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],.026) end
    if desk.centerBloom then desk.centerBloom:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],1) end
    if desk.navRail then desk.navRail:SetColorTexture(p.gold[1],p.gold[2],p.gold[3],.20) end
    if desk.sideRail then desk.sideRail:SetColorTexture(p.gold[1],p.gold[2],p.gold[3],.15) end

    SetBackdrop(UI.header,Mix(p.pageAlt,p.main,.15,.985),Mix(p.border,p.gold,.18,.96))
    SetBackdrop(UI.content,Mix(p.page,p.main,.12,.985),Mix(p.border,p.gold,.15,.96))
    SetBackdrop(UI.progression,Mix(p.panel2,p.main,.18,.99),Mix(p.border,p.gold,.32,.96))
    if desk.headerPage then desk.headerPage:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],.22) end
    if desk.contentPage then desk.contentPage:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],.20) end
    if desk.progressPage then desk.progressPage:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],.24) end

    if UI.crestCapsule then SetBackdrop(UI.crestCapsule,Mul(p.panel2,.70,.95),Mix(p.border,p.gold,.24,.96)) end
    SetTextColor(desk.stoneLabel,p.muted)
    -- Glance cards are intentionally icon-only.  Do not assume legacy
    -- title/description FontStrings exist here.
    for _,card in ipairs(desk.glanceCards or {}) do
        if card.icon then
            local a=card.icon:GetAlpha() or 1
            if card._hasGlance then
                card.icon:SetVertexColor(1,1,1,a)
            else
                card.icon:SetVertexColor(p.muted[1],p.muted[2],p.muted[3],a)
            end
        end
        if card.tint then
            card.tint:SetColorTexture(p.gold[1],p.gold[2],p.gold[3],card.tint:GetAlpha() or .20)
        end
        if card.shade then
            card.shade:SetColorTexture(p.main[1],p.main[2],p.main[3],card.shade:GetAlpha() or .08)
        end
        if card.premadeFrame then
            card.premadeFrame:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],1)
            card.premadeFrame:SetAlpha(card._hasGlance and .78 or .34)
        end
    end

    SetBackdrop(desk.nav,Mix(p.main,p.panel,.28,.96),Mix(p.border,p.gold,.22,.94))
    desk.nav.texture:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],1);desk.nav.texture:SetAlpha(.10)

    SetBackdrop(desk.sidebar,Mix(p.main,p.panel,.24,.96),Mix(p.border,p.gold,.22,.94))
    desk.sidebar.texture:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],1);desk.sidebar.texture:SetAlpha(.10)

    -- Integrated, non-interactive wordmark in the Desk top rail.
    if desk.brandBacking then
        local shell=Mix(p.main,p.panel,.10,1)
        desk.brandBacking:SetVertexColor(shell[1],shell[2],shell[3],1)
        desk.brandBacking:SetAlpha(.98)
    end
    for _,line in ipairs({desk.brandLineLeft,desk.brandLineRight}) do
        if line then line:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],.48) end
    end
    if desk.brandTitle then SetTextColor(desk.brandTitle,p.gold) end
    if desk.brandMark then SetTextColor(desk.brandMark,Mix(p.gold,p.muted,.45,1)) end
    if desk.brandSub then SetTextColor(desk.brandSub,p.muted) end
    RefreshUpdateNotice()

    local cal=desk.calendarPanel
    SetBackdrop(cal,Mix(p.pageAlt,p.main,.18,.98),Mix(p.border,p.gold,.26,.96))
    cal.page:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],1);cal.page:SetAlpha(.15)
    cal.icon:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],.94);SetTextColor(cal.title,p.gold)
    for _,w in ipairs(cal.weekdays or {}) do SetTextColor(w,p.muted) end

    for _,box in ipairs({desk.momentsPanel,desk.memoirPanel}) do
        SetBackdrop(box,Mix(p.panel,p.main,.20,.98),Mix(p.border,p.gold,.20,.92))
        box.rule:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],.52)
        box.icon:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],.92)
        SetTextColor(box.title,p.gold)
    end

    for _,b in pairs(desk.navButtons or {}) do ApplyNavVisual(b) end
    ApplySmallVisual(desk.sceneButton);ApplySmallVisual(desk.settingsButton);ApplySmallVisual(desk.editButton)
end

function RPAL.AttachBookChrome(main,header,ui)
    UI=ui or UI
    if not main or not header or not UI or not UI.content or not UI.progression then return end
    if desk and desk.main==main then return end
    if desk and desk.main~=main and desk.root then desk.root:Hide();desk.root:SetParent(nil) end

    desk={main=main}
    RPAL._deskUI=desk
    StyleMainPages(main,header,UI.content,UI.progression)
    BuildTitlePlaque(main)
    BuildLeftNavigation(main)
    BuildRightSidebar(main)
    RPAL.RefreshBookChrome()
end

function RPAL.RefreshDeskSidebar()
    if not desk or not desk.main or not desk.main:IsShown() then return end
    RenderMiniCalendar();RenderPinnedMoments();RenderMemoirs()
end

function RPAL.RefreshBookChrome(skipPreviewRefresh)
    if UI and UI.closeButton and UI.main then UI.closeButton:SetFrameLevel((UI.main:GetFrameLevel() or 0)+190);UI.closeButton:Show() end
    if not desk or not desk.main then return end
    if desk.brandLayer then
        desk.brandLayer:SetFrameStrata(desk.main:GetFrameStrata())
        desk.brandLayer:SetFrameLevel((desk.main:GetFrameLevel() or 0)+180)
    end

    local lite=RPAL.IsLiteKit and RPAL.IsLiteKit()
    local layout=RPAL.GetDeskLayout and RPAL.GetDeskLayout() or "desk"
    local def=LAYOUTS[layout] or LAYOUTS.desk
    local mainW,mainH=def.w,def.h
    local navW,rightW=def.nav,def.right
    local sidebarShown=def.sidebar and rightW>0
    local leftEdge=(def.outer or 8)+navW+(def.gap or 8)
    local rightInset=sidebarShown and ((def.outer or 8)+rightW+(def.gap or 8)) or 12
    local contentLeft=leftEdge
    local contentRight=-rightInset
    local headerH=def.header or 166
    local baseProgressH=def.progress or 60
    -- Character Level and Season progression now share the footer horizontally
    -- as two equal cards.  A single, slightly taller footer is enough in every
    -- layout and avoids the cramped two-row stack used by the Open Beta draft.
    local progressH=math.max(baseProgressH,88)
    local contentTop=14+headerH+0
    local progressionBottom=12
    local progressionReserve=lite and 12 or (progressionBottom+progressH)

    desk.main:SetSize(mainW,mainH)
    if desk.nav then desk.nav:SetWidth(navW) end
    if desk.sidebar then
        desk.sidebar:SetShown(sidebarShown)
        desk.sidebar:SetAlpha(1)
        desk.sidebar:SetFrameLevel((desk.main:GetFrameLevel() or 0)+70)
        if sidebarShown then desk.sidebar:SetWidth(rightW) end
        for _,panel in ipairs({desk.calendarPanel,desk.momentsPanel,desk.memoirPanel}) do
            if panel then panel:SetAlpha(1);panel:SetFrameLevel(desk.sidebar:GetFrameLevel()+2) end
        end
    end
    if desk.navRail then
        desk.navRail:ClearAllPoints();desk.navRail:SetPoint("TOPLEFT",contentLeft-4,-20);desk.navRail:SetPoint("BOTTOMLEFT",contentLeft-4,20)
    end
    if desk.sideRail then
        desk.sideRail:SetShown(sidebarShown)
        if sidebarShown then
            desk.sideRail:ClearAllPoints();desk.sideRail:SetPoint("TOPRIGHT",contentRight+4,-20);desk.sideRail:SetPoint("BOTTOMRIGHT",contentRight+4,20)
        end
    end
    if desk.centerBloom then
        desk.centerBloom:ClearAllPoints();desk.centerBloom:SetPoint("CENTER",desk.main,"CENTER",(navW-(sidebarShown and rightW or 0))/2,-22)
        desk.centerBloom:SetSize(layout=="wide" and 570 or (layout=="compact" and 420 or 500),layout=="wide" and 570 or (layout=="compact" and 420 or 500))
    end
    if UI and UI.header then
        UI.header:ClearAllPoints()
        UI.header:SetPoint("TOPLEFT",contentLeft,-14)
        UI.header:SetPoint("TOPRIGHT",contentRight,-14)
        UI.header:SetHeight(headerH)
    end
    if UI and UI.content then
        UI.content:ClearAllPoints()
        UI.content:SetPoint("TOPLEFT",contentLeft,-(14+headerH))
        UI.content:SetPoint("BOTTOMRIGHT",contentRight,progressionReserve)
    end
    if UI and UI.progression then
        UI.progression:ClearAllPoints()
        UI.progression:SetPoint("BOTTOMLEFT",contentLeft,progressionBottom)
        UI.progression:SetPoint("BOTTOMRIGHT",contentRight,progressionBottom)
        UI.progression:SetHeight(progressH)
        UI.progression:SetShown(not lite)
    end
    if UI and UI.levelProgressCard then
        UI.levelProgressCard:ClearAllPoints()
        UI.levelProgressCard:SetPoint("TOPLEFT",UI.progression,"TOPLEFT",8,-7)
        UI.levelProgressCard:SetPoint("BOTTOMLEFT",UI.progression,"BOTTOMLEFT",8,7)
        UI.levelProgressCard:SetPoint("RIGHT",UI.progression,"CENTER",-4,0)
        UI.levelProgressCard:SetShown(not lite)
        UI.levelProgressCard:SetFrameLevel((UI.progression:GetFrameLevel() or 0)+20)
    end
    if UI and UI.seasonStrip then
        UI.seasonStrip:ClearAllPoints()
        UI.seasonStrip:SetPoint("TOPRIGHT",UI.progression,"TOPRIGHT",-8,-7)
        UI.seasonStrip:SetPoint("BOTTOMRIGHT",UI.progression,"BOTTOMRIGHT",-8,7)
        UI.seasonStrip:SetPoint("LEFT",UI.progression,"CENTER",4,0)
        UI.seasonStrip:SetShown(not lite)
        UI.seasonStrip:SetFrameLevel((UI.progression:GetFrameLevel() or 0)+20)
    end
    -- The legacy single-column footer remains as a compatibility data surface,
    -- but its visual widgets stay hidden behind the new two-card presentation.
    for _,widget in ipairs({UI.levelText,UI.levelSubText,UI.renownBarTitle,UI.renownBar,UI.nextLevelText,UI.nextLevelNeed,UI.progressionDivider}) do
        if widget and widget.Hide then widget:Hide() end
    end

    -- Full Kit exposes the complete progression/customization navigation.  The
    -- order is account-configurable so players can promote the pages they use
    -- most without rebuilding the UI.  Hidden Lite/DM Kit entries simply close
    -- their slot instead of leaving dead gaps.
    if desk.navButtons and desk.nav then
        local dmEnabled=(RPAL.DMKit and RPAL.DMKit.IsEnabled and RPAL.DMKit.IsEnabled()) or false
        local order=RPAL.GetDeskNavigationOrder and RPAL.GetDeskNavigationOrder() or DEFAULT_NAV_ORDER
        local function NavVisible(key)
            local visible=(not RPAL.IsDeskNavigationVisible) or RPAL.IsDeskNavigationVisible(key)
            if (key=="customize" or key=="atlas") and lite then visible=false end
            if key=="dmkit" and not dmEnabled then visible=false end
            return visible
        end
        local visibleCount=0
        for _,key in ipairs(order) do if desk.navButtons[key] and NavVisible(key) then visibleCount=visibleCount+1 end end
        -- Twelve full-kit destinations still fit above the permanent Session /
        -- Settings controls. Compact layout tightens the rhythm slightly more.
        local navStep=visibleCount>=12 and (layout=="compact" and 50 or 52) or 55
        local slot=0
        for _,key in ipairs(order) do
            local btn=desk.navButtons[key]
            if btn then
                local visible=NavVisible(key)
                btn:SetShown(visible)
                btn:SetWidth(math.max(126,navW-8))
                FitNavButtonLabel(btn)
                if visible then
                    btn:ClearAllPoints()
                    btn:SetPoint("TOPLEFT",4,-8-(slot*navStep))
                    slot=slot+1
                end
            end
        end
        for key,btn in pairs(desk.navButtons) do
            local found=false;for _,ordered in ipairs(order) do if ordered==key then found=true break end end
            if not found then btn:Hide() end
        end
        if desk.sceneButton then desk.sceneButton:SetWidth(math.max(118,navW-18)) end
        if desk.settingsButton then desk.settingsButton:SetWidth(math.max(118,navW-18)) end
    end

    local ledgerOpen=UI and UI.ledger and UI.ledger:IsShown()
    local editorOpen=UI and UI.editor and UI.editor:IsShown()
    local function Active(btn,on)
        if not btn then return end
        btn._active=on and true or false
        ApplyNavVisual(btn)
    end
    local chronicleOpen=RPAL.IsChronicleEmbeddedOpen and RPAL.IsChronicleEmbeddedOpen()
    local skillSetOpen=RPAL.IsSkillSetOpen and RPAL.IsSkillSetOpen()
    local personalityOpen=RPAL.IsPersonalityOpen and RPAL.IsPersonalityOpen()
    Active(desk.navButtons and desk.navButtons.overview,not ledgerOpen and not editorOpen and not chronicleOpen and not skillSetOpen and not personalityOpen and not desk.miniGamesOpen)
    Active(desk.navButtons and desk.navButtons.achievements,ledgerOpen and not chronicleOpen and not skillSetOpen)
    Active(desk.navButtons and desk.navButtons.chronicle,chronicleOpen)
    Active(desk.navButtons and desk.navButtons.minigames,desk.miniGamesOpen and true or false)
    Active(desk.navButtons and desk.navButtons.injury,RPAL.IsInjuryOpen and RPAL.IsInjuryOpen())
    Active(desk.navButtons and desk.navButtons.skillset,skillSetOpen)
    Active(desk.navButtons and desk.navButtons.personality,personalityOpen)
    if desk.navButtons and desk.navButtons.dmkit then
        local enabled=(RPAL.DMKit and RPAL.DMKit.IsEnabled and RPAL.DMKit.IsEnabled()) or false
        desk.navButtons.dmkit:SetShown(enabled)
        Active(desk.navButtons.dmkit,false)
    end

    if desk.sceneButton then
        local active=RPAL.IsSceneActive and RPAL.IsSceneActive()
        desk.sceneButton._sceneActive=active
        desk.sceneButton.label:SetText(active and "END SESSION" or "START SESSION")
        if desk.sceneButton.art then SetIconTexture(desk.sceneButton.art,active and ASSET.PAUSE or ASSET.PLAY) end
    end

    if desk.editButton then
        if chronicleOpen or personalityOpen then
            desk.editButton.label:SetText("EDIT");desk.editButton:Disable();desk.editButton:SetAlpha(0.34)
            if desk.editButton.art and desk.editButton.art.SetDesaturated then desk.editButton.art:SetDesaturated(true) end
        else
            desk.editButton:Enable();desk.editButton:SetAlpha(1);desk.editButton.label:SetText(editorOpen and "DONE" or "EDIT")
            if desk.editButton.art and desk.editButton.art.SetDesaturated then desk.editButton.art:SetDesaturated(false) end
        end
    end

    RenderGlances()
    local ok,err=pcall(ApplyDeskTheme)
    if not ok and err then
        -- Never leave the main header in a partially refreshed state because
        -- an optional decorative element failed to theme.
    end
    if not skipPreviewRefresh and UI and UI.preview and RPAL.RefreshCharacterPreview then
        pcall(RPAL.RefreshCharacterPreview)
    end
    RPAL.RefreshDeskSidebar()
    if personalityOpen and RPAL.RefreshPersonalityUI then RPAL.RefreshPersonalityUI() end
end
