local ADDON_NAME, RPAL = ...


local TEX="Interface\\AddOns\\ODYN\\Textures\\"
local NATIVE_CREST_ICON=RPAL.ICONS.token
local NATIVE_MAGNIFY_ICON=RPAL.ICONS.magnify

-- User-supplied premade SVG containers, converted to transparent TGA line art.
-- Keeping the paths on RPAL lets BookChromeUI reuse the exact same sockets
-- without adding more top-level locals to this already-large Lua chunk.
RPAL.PremadeGlanceFrames={
    TEX.."PremadeUI\\glance_frame_1.tga",
    TEX.."PremadeUI\\glance_frame_2.tga",
    TEX.."PremadeUI\\glance_frame_3.tga",
    TEX.."PremadeUI\\glance_frame_4.tga",
    TEX.."PremadeUI\\glance_frame_5.tga",
}
RPAL.PremadeDetailFrame=TEX.."PremadeUI\\detail_frame.tga"

local ASSET={
    BOOK=TEX.."book_bg.tga",
    PAGE=TEX.."page_bg.tga",
    SPINE=TEX.."book_spine.tga",
    CORNER=TEX.."corner.tga",
    CARD=TEX.."card_bg.tga",
    PARCHMENT=TEX.."parchment_bar.tga",
    PROGRESS_PANEL=TEX.."progress_panel.tga",
    PROGRESS_TRACK=TEX.."progress_track.tga",
    PROGRESS_FILL=TEX.."progress_fill.tga",
    LEVEL_BADGE=TEX.."level_badge.tga",
    CLOSE=RPAL.ICONS.close,
    CLOSE_HOVER=RPAL.ICONS.close,
}
local STAT_ICON={
    characters_met=RPAL.ICONS.people,
    campaigns_joined=RPAL.ICONS.book or RPAL.ICONS.trophy,
    renown=RPAL.ICONS.trophy,
    wave=RPAL.ICONS.emoteWave, pet=RPAL.ICONS.emotePet, hug=RPAL.ICONS.emoteHeart, peer=RPAL.ICONS.emotePeer,
    eye=RPAL.ICONS.eyeFocus,
    smirk=RPAL.ICONS.emoteSmirk, snicker=RPAL.ICONS.emoteSnicker, cry=RPAL.ICONS.emoteCry,
    frown=RPAL.ICONS.emoteFrown, smile=RPAL.ICONS.emoteSmile, dance=RPAL.ICONS.music,
    rude=RPAL.ICONS.emoteAngry, cheer=RPAL.ICONS.emoteCheer, nod=RPAL.ICONS.check, no=RPAL.ICONS.emoteNo,
    shy=RPAL.ICONS.emoteShy, flirt=RPAL.ICONS.emoteFlirt, gasp=RPAL.ICONS.emoteGasp,
    bye=RPAL.ICONS.emoteBye, yawn=RPAL.ICONS.emoteYawn,
    glance=RPAL.ICONS.emoteGlance, hiss=RPAL.ICONS.emoteHiss,
}

local GOLD = { 0.86, 0.63, 0.28 }
local PALE = { 0.88, 0.82, 0.68 }
local MUTED = { 0.57, 0.50, 0.40 }
local PANEL = { 0.105, 0.065, 0.035, 0.98 }
local PANEL2 = { 0.16, 0.105, 0.055, 0.98 }
local BORDER = { 0.42, 0.28, 0.12, 1 }
local WORG = { 0.95, 0.56, 0.67 }
local HUNGER = { 1.00, 0.58, 0.22 }
local THIRST = { 0.32, 0.72, 1.00 }

local MAIN_BG = {0.075,0.042,0.022,0.99}
local PAGE_BG = {0.18,0.125,0.067,0.96}
local PAGE_BG_ALT = {0.125,0.080,0.042,0.97}
local BUTTON_BG = {0.105,0.070,0.040,1}
local BUTTON_HOVER = {0.17,0.115,0.060,1}
local ACCENT_BG = {0.66,0.45,0.16,1}
local ACCENT_HOVER = {0.78,0.56,0.22,1}

local THEMES = {
    ["Ancient Book"] = {
        gold={0.86,0.63,0.28}, pale={0.88,0.82,0.68}, muted={0.57,0.50,0.40},
        panel={0.105,0.065,0.035,0.98}, panel2={0.16,0.105,0.055,0.98},
        border={0.42,0.28,0.12,1}, main={0.075,0.042,0.022,0.99},
        page={0.18,0.125,0.067,0.96}, pageAlt={0.125,0.080,0.042,0.97},
        button={0.105,0.070,0.040,1}, buttonHover={0.17,0.115,0.060,1},
        accent={0.66,0.45,0.16,1}, accentHover={0.78,0.56,0.22,1},
    },
    ["Midnight"] = {
        gold={0.62,0.74,0.95}, pale={0.84,0.88,0.95}, muted={0.48,0.55,0.68},
        panel={0.025,0.035,0.075,0.98}, panel2={0.04,0.055,0.11,0.98},
        border={0.20,0.28,0.50,1}, main={0.010,0.016,0.042,0.99},
        page={0.035,0.050,0.095,0.97}, pageAlt={0.025,0.038,0.075,0.97},
        button={0.035,0.045,0.080,1}, buttonHover={0.060,0.080,0.135,1},
        accent={0.32,0.47,0.78,1}, accentHover={0.40,0.58,0.92,1},
    },
    ["Arcane"] = {
        gold={0.78,0.53,0.96}, pale={0.91,0.84,0.97}, muted={0.60,0.49,0.67},
        panel={0.070,0.030,0.090,0.98}, panel2={0.105,0.045,0.135,0.98},
        border={0.43,0.21,0.56,1}, main={0.040,0.016,0.055,0.99},
        page={0.105,0.045,0.125,0.97}, pageAlt={0.073,0.032,0.092,0.97},
        button={0.080,0.035,0.095,1}, buttonHover={0.135,0.055,0.165,1},
        accent={0.48,0.22,0.68,1}, accentHover={0.61,0.30,0.82,1},
    },
    ["Fel"] = {
        gold={0.52,0.92,0.28}, pale={0.83,0.93,0.76}, muted={0.48,0.62,0.43},
        panel={0.030,0.070,0.035,0.98}, panel2={0.045,0.105,0.050,0.98},
        border={0.24,0.48,0.20,1}, main={0.012,0.040,0.017,0.99},
        page={0.045,0.100,0.050,0.97}, pageAlt={0.030,0.070,0.035,0.97},
        button={0.035,0.075,0.040,1}, buttonHover={0.060,0.125,0.065,1},
        accent={0.30,0.62,0.17,1}, accentHover={0.39,0.78,0.22,1},
    },
    ["Blood"] = {
        gold={0.94,0.55,0.48}, pale={0.94,0.83,0.79}, muted={0.65,0.47,0.45},
        panel={0.095,0.025,0.025,0.98}, panel2={0.145,0.035,0.035,0.98},
        border={0.50,0.18,0.18,1}, main={0.052,0.012,0.014,0.99},
        page={0.130,0.032,0.034,0.97}, pageAlt={0.085,0.022,0.025,0.97},
        button={0.090,0.025,0.027,1}, buttonHover={0.150,0.040,0.043,1},
        accent={0.66,0.18,0.18,1}, accentHover={0.82,0.25,0.24,1},
    },
    ["Kaldorei"] = {
        gold={0.69,0.68,0.98}, pale={0.87,0.86,0.96}, muted={0.53,0.52,0.68},
        panel={0.045,0.040,0.090,0.98}, panel2={0.070,0.062,0.130,0.98},
        border={0.30,0.27,0.56,1}, main={0.020,0.018,0.055,0.99},
        page={0.065,0.057,0.120,0.97}, pageAlt={0.044,0.040,0.088,0.97},
        button={0.050,0.045,0.095,1}, buttonHover={0.080,0.072,0.145,1},
        accent={0.39,0.34,0.72,1}, accentHover={0.51,0.45,0.88,1},
    },
    ["Stormwind"] = {
        gold={0.95,0.77,0.25}, pale={0.90,0.91,0.95}, muted={0.53,0.59,0.69},
        panel={0.030,0.060,0.120,0.98}, panel2={0.045,0.085,0.160,0.98},
        border={0.18,0.34,0.64,1}, main={0.014,0.034,0.075,0.99},
        page={0.040,0.078,0.145,0.97}, pageAlt={0.028,0.055,0.105,0.97},
        button={0.030,0.065,0.120,1}, buttonHover={0.050,0.100,0.185,1},
        accent={0.72,0.50,0.08,1}, accentHover={0.88,0.64,0.12,1},
    },
    ["Monochrome"] = {
        gold={0.80,0.80,0.80}, pale={0.90,0.90,0.90}, muted={0.56,0.56,0.56},
        panel={0.055,0.055,0.055,0.98}, panel2={0.085,0.085,0.085,0.98},
        border={0.29,0.29,0.29,1}, main={0.025,0.025,0.025,0.99},
        page={0.075,0.075,0.075,0.97}, pageAlt={0.052,0.052,0.052,0.97},
        button={0.060,0.060,0.060,1}, buttonHover={0.105,0.105,0.105,1},
        accent={0.45,0.45,0.45,1}, accentHover={0.60,0.60,0.60,1},
    },
    ["Deep Sea"] = {
        gold={0.42,0.90,0.92}, pale={0.80,0.94,0.94}, muted={0.42,0.63,0.65},
        panel={0.025,0.080,0.085,0.98}, panel2={0.035,0.115,0.120,0.98},
        border={0.16,0.48,0.50,1}, main={0.010,0.045,0.050,0.99},
        page={0.028,0.100,0.105,0.97}, pageAlt={0.020,0.070,0.075,0.97},
        button={0.028,0.082,0.087,1}, buttonHover={0.045,0.135,0.140,1},
        accent={0.16,0.58,0.61,1}, accentHover={0.22,0.72,0.75,1},
    },
    ["Frost"] = {
        gold={0.68,0.88,1.00}, pale={0.89,0.95,1.00}, muted={0.57,0.68,0.78},
        panel={0.045,0.070,0.100,0.98}, panel2={0.060,0.095,0.135,0.98},
        border={0.28,0.48,0.66,1}, main={0.022,0.040,0.065,0.99},
        page={0.052,0.085,0.120,0.97}, pageAlt={0.038,0.060,0.088,0.97},
        button={0.045,0.072,0.105,1}, buttonHover={0.072,0.115,0.160,1},
        accent={0.35,0.67,0.88,1}, accentHover={0.47,0.79,0.98,1},
    },
    ["Ember"] = {
        gold={1.00,0.60,0.25}, pale={0.97,0.84,0.69}, muted={0.67,0.50,0.37},
        panel={0.100,0.045,0.020,0.98}, panel2={0.150,0.065,0.025,0.98},
        border={0.55,0.25,0.08,1}, main={0.055,0.022,0.010,0.99},
        page={0.130,0.055,0.020,0.97}, pageAlt={0.090,0.038,0.016,0.97},
        button={0.100,0.042,0.020,1}, buttonHover={0.165,0.072,0.025,1},
        accent={0.78,0.31,0.06,1}, accentHover={0.94,0.43,0.08,1},
    },
    ["Emerald"] = {
        gold={0.48,0.93,0.63}, pale={0.82,0.95,0.86}, muted={0.44,0.64,0.50},
        panel={0.025,0.085,0.050,0.98}, panel2={0.040,0.125,0.075,0.98},
        border={0.18,0.50,0.29,1}, main={0.012,0.045,0.027,0.99},
        page={0.035,0.110,0.065,0.97}, pageAlt={0.024,0.078,0.047,0.97},
        button={0.030,0.090,0.055,1}, buttonHover={0.050,0.145,0.085,1},
        accent={0.20,0.65,0.36,1}, accentHover={0.28,0.80,0.45,1},
    },
    ["Voidglass"] = {
        gold={0.76,0.62,1.00}, pale={0.91,0.86,0.99}, muted={0.58,0.50,0.70},
        panel={0.050,0.030,0.090,0.98}, panel2={0.075,0.045,0.135,0.98},
        border={0.34,0.23,0.58,1}, main={0.025,0.014,0.050,0.99},
        page={0.068,0.038,0.120,0.97}, pageAlt={0.048,0.027,0.085,0.97},
        button={0.055,0.032,0.095,1}, buttonHover={0.090,0.052,0.150,1},
        accent={0.43,0.24,0.72,1}, accentHover={0.56,0.34,0.88,1},
    },
    ["Rosewood"] = {
        gold={0.96,0.62,0.72}, pale={0.95,0.84,0.87}, muted={0.66,0.49,0.54},
        panel={0.095,0.040,0.055,0.98}, panel2={0.135,0.058,0.075,0.98},
        border={0.50,0.22,0.30,1}, main={0.052,0.020,0.028,0.99},
        page={0.120,0.050,0.067,0.97}, pageAlt={0.085,0.035,0.048,0.97},
        button={0.095,0.040,0.055,1}, buttonHover={0.150,0.065,0.085,1},
        accent={0.65,0.27,0.39,1}, accentHover={0.82,0.38,0.52,1},
    },
    ["Silvermoon"] = {
        gold={1.00,0.79,0.35}, pale={0.96,0.87,0.74}, muted={0.68,0.54,0.43},
        panel={0.095,0.025,0.035,0.98}, panel2={0.145,0.038,0.050,0.98},
        border={0.55,0.18,0.20,1}, main={0.052,0.012,0.018,0.99},
        page={0.125,0.030,0.043,0.97}, pageAlt={0.088,0.021,0.032,0.97},
        button={0.095,0.025,0.036,1}, buttonHover={0.155,0.040,0.055,1},
        accent={0.72,0.16,0.18,1}, accentHover={0.90,0.25,0.27,1},
    },
    ["Necromantic"] = {
        gold={0.70,0.88,0.42}, pale={0.87,0.92,0.77}, muted={0.56,0.63,0.45},
        panel={0.060,0.075,0.028,0.98}, panel2={0.085,0.110,0.038,0.98},
        border={0.34,0.46,0.16,1}, main={0.032,0.040,0.014,0.99},
        page={0.078,0.098,0.032,0.97}, pageAlt={0.055,0.070,0.023,0.97},
        button={0.063,0.080,0.030,1}, buttonHover={0.105,0.130,0.045,1},
        accent={0.48,0.62,0.15,1}, accentHover={0.62,0.76,0.22,1},
    },
    ["Sunset"] = {
        gold={1.00,0.66,0.45}, pale={0.97,0.85,0.80}, muted={0.67,0.50,0.52},
        panel={0.105,0.040,0.065,0.98}, panel2={0.155,0.055,0.095,0.98},
        border={0.56,0.23,0.38,1}, main={0.060,0.020,0.040,0.99},
        page={0.140,0.048,0.082,0.97}, pageAlt={0.095,0.032,0.058,0.97},
        button={0.105,0.038,0.065,1}, buttonHover={0.170,0.062,0.105,1},
        accent={0.78,0.31,0.42,1}, accentHover={0.94,0.43,0.56,1},
    },
    ["Royal"] = {
        gold={0.93,0.76,0.32}, pale={0.91,0.86,0.96}, muted={0.59,0.50,0.67},
        panel={0.065,0.035,0.095,0.98}, panel2={0.095,0.050,0.140,0.98},
        border={0.42,0.27,0.58,1}, main={0.035,0.018,0.055,0.99},
        page={0.085,0.045,0.125,0.97}, pageAlt={0.060,0.032,0.090,0.97},
        button={0.070,0.038,0.100,1}, buttonHover={0.115,0.060,0.155,1},
        accent={0.58,0.35,0.74,1}, accentHover={0.72,0.47,0.89,1},
    },
    ["Bronze"] = {
        gold={0.90,0.65,0.34}, pale={0.91,0.82,0.69}, muted={0.61,0.51,0.40},
        panel={0.085,0.060,0.035,0.98}, panel2={0.125,0.085,0.048,0.98},
        border={0.45,0.30,0.14,1}, main={0.045,0.032,0.018,0.99},
        page={0.110,0.075,0.042,0.97}, pageAlt={0.075,0.052,0.030,0.97},
        button={0.088,0.060,0.034,1}, buttonHover={0.140,0.095,0.052,1},
        accent={0.61,0.40,0.16,1}, accentHover={0.76,0.52,0.23,1},
    },
    ["Scarlet"] = {
        gold={1.00,0.48,0.42}, pale={0.97,0.88,0.85}, muted={0.69,0.50,0.48},
        panel={0.105,0.025,0.025,0.98}, panel2={0.160,0.036,0.035,0.98},
        border={0.58,0.16,0.15,1}, main={0.060,0.010,0.012,0.99},
        page={0.140,0.030,0.030,0.97}, pageAlt={0.098,0.020,0.022,0.97},
        button={0.105,0.023,0.025,1}, buttonHover={0.170,0.037,0.040,1},
        accent={0.78,0.12,0.11,1}, accentHover={0.94,0.20,0.18,1},
    },
    ["Dream"] = {
        gold={0.58,0.94,0.72}, pale={0.87,0.94,0.90}, muted={0.50,0.66,0.58},
        panel={0.035,0.075,0.062,0.98}, panel2={0.050,0.110,0.090,0.98},
        border={0.22,0.48,0.38,1}, main={0.017,0.042,0.034,0.99},
        page={0.045,0.098,0.080,0.97}, pageAlt={0.032,0.070,0.058,0.97},
        button={0.038,0.080,0.066,1}, buttonHover={0.060,0.130,0.102,1},
        accent={0.28,0.67,0.48,1}, accentHover={0.38,0.82,0.61,1},
    },
    ["Drust"] = {
        gold={0.68,0.76,0.82}, pale={0.86,0.89,0.91}, muted={0.52,0.58,0.62},
        panel={0.045,0.055,0.060,0.98}, panel2={0.068,0.080,0.086,0.98},
        border={0.27,0.34,0.37,1}, main={0.022,0.028,0.032,0.99},
        page={0.060,0.070,0.075,0.97}, pageAlt={0.043,0.052,0.056,0.97},
        button={0.048,0.058,0.063,1}, buttonHover={0.080,0.093,0.100,1},
        accent={0.36,0.48,0.54,1}, accentHover={0.48,0.61,0.67,1},
    },
    ["Titan"] = {
        gold={0.38,0.92,1.00}, pale={0.86,0.95,0.98}, muted={0.47,0.66,0.70},
        panel={0.028,0.070,0.085,0.98}, panel2={0.040,0.105,0.125,0.98},
        border={0.16,0.47,0.56,1}, main={0.012,0.040,0.050,0.99},
        page={0.035,0.092,0.108,0.97}, pageAlt={0.024,0.066,0.078,0.97},
        button={0.030,0.075,0.090,1}, buttonHover={0.048,0.120,0.145,1},
        accent={0.16,0.62,0.72,1}, accentHover={0.23,0.78,0.88,1},
    },
    ["Ivory & Gold"] = {
        gold={0.96,0.80,0.43}, pale={0.98,0.95,0.86}, muted={0.70,0.65,0.55},
        panel={0.120,0.105,0.080,0.98}, panel2={0.165,0.145,0.105,0.98},
        border={0.55,0.44,0.24,1}, main={0.060,0.052,0.040,0.99},
        page={0.145,0.128,0.095,0.97}, pageAlt={0.105,0.092,0.070,0.97},
        button={0.115,0.100,0.075,1}, buttonHover={0.175,0.152,0.108,1},
        accent={0.72,0.53,0.20,1}, accentHover={0.88,0.67,0.28,1},
    },
    ["Obsidian Crown"] = {
        gold={0.90,0.72,0.34}, pale={0.90,0.89,0.86}, muted={0.52,0.52,0.50},
        panel={0.035,0.035,0.040,0.98}, panel2={0.055,0.055,0.064,0.98},
        border={0.32,0.29,0.22,1}, main={0.012,0.012,0.016,0.99},
        page={0.048,0.047,0.054,0.97}, pageAlt={0.030,0.030,0.036,0.97},
        button={0.042,0.042,0.048,1}, buttonHover={0.078,0.075,0.070,1},
        accent={0.58,0.43,0.16,1}, accentHover={0.76,0.58,0.22,1},
    },
    ["Nightborne"] = {
        gold={0.72,0.58,1.00}, pale={0.92,0.88,0.98}, muted={0.59,0.52,0.69},
        panel={0.060,0.036,0.100,0.98}, panel2={0.086,0.050,0.145,0.98},
        border={0.39,0.25,0.62,1}, main={0.030,0.018,0.055,0.99},
        page={0.074,0.043,0.126,0.97}, pageAlt={0.052,0.030,0.090,0.97},
        button={0.064,0.037,0.108,1}, buttonHover={0.105,0.058,0.168,1},
        accent={0.48,0.29,0.80,1}, accentHover={0.62,0.40,0.95,1},
    },
    ["Jade Temple"] = {
        gold={0.48,0.88,0.66}, pale={0.88,0.94,0.88}, muted={0.49,0.63,0.54},
        panel={0.036,0.080,0.060,0.98}, panel2={0.050,0.116,0.084,0.98},
        border={0.21,0.49,0.34,1}, main={0.018,0.042,0.031,0.99},
        page={0.045,0.100,0.073,0.97}, pageAlt={0.032,0.071,0.052,0.97},
        button={0.040,0.084,0.062,1}, buttonHover={0.064,0.132,0.094,1},
        accent={0.25,0.64,0.42,1}, accentHover={0.35,0.80,0.55,1},
    },
    ["Ashen Steel"] = {
        gold={0.72,0.78,0.82}, pale={0.91,0.92,0.93}, muted={0.55,0.59,0.62},
        panel={0.055,0.060,0.066,0.98}, panel2={0.078,0.086,0.095,0.98},
        border={0.34,0.39,0.43,1}, main={0.026,0.030,0.034,0.99},
        page={0.068,0.074,0.082,0.97}, pageAlt={0.048,0.053,0.059,0.97},
        button={0.058,0.064,0.070,1}, buttonHover={0.095,0.104,0.113,1},
        accent={0.43,0.50,0.56,1}, accentHover={0.58,0.65,0.70,1},
    },
    ["Sapphire Court"] = {
        gold={0.55,0.78,1.00}, pale={0.88,0.93,0.99}, muted={0.48,0.59,0.72},
        panel={0.028,0.055,0.100,0.98}, panel2={0.042,0.078,0.145,0.98},
        border={0.18,0.38,0.66,1}, main={0.012,0.026,0.055,0.99},
        page={0.035,0.068,0.125,0.97}, pageAlt={0.024,0.048,0.090,0.97},
        button={0.032,0.058,0.108,1}, buttonHover={0.052,0.092,0.168,1},
        accent={0.22,0.49,0.84,1}, accentHover={0.31,0.62,0.98,1},
    },
    ["Crimson Velvet"] = {
        gold={0.95,0.67,0.43}, pale={0.96,0.86,0.84}, muted={0.66,0.49,0.50},
        panel={0.095,0.030,0.042,0.98}, panel2={0.140,0.043,0.058,0.98},
        border={0.50,0.19,0.24,1}, main={0.050,0.014,0.022,0.99},
        page={0.122,0.037,0.052,0.97}, pageAlt={0.086,0.025,0.037,0.97},
        button={0.095,0.029,0.041,1}, buttonHover={0.153,0.048,0.064,1},
        accent={0.68,0.20,0.27,1}, accentHover={0.84,0.30,0.37,1},
    },
    ["Moonwell"] = {
        gold={0.63,0.90,1.00}, pale={0.89,0.95,0.98}, muted={0.50,0.66,0.72},
        panel={0.035,0.067,0.082,0.98}, panel2={0.048,0.096,0.116,0.98},
        border={0.20,0.44,0.54,1}, main={0.017,0.037,0.047,0.99},
        page={0.043,0.084,0.102,0.97}, pageAlt={0.030,0.060,0.073,0.97},
        button={0.038,0.071,0.086,1}, buttonHover={0.061,0.113,0.135,1},
        accent={0.28,0.66,0.78,1}, accentHover={0.39,0.81,0.92,1},
    },
    ["Vermilion Rite"] = {
        gold={1.00,0.28,0.08}, pale={1.00,0.91,0.84}, muted={0.76,0.48,0.38},
        panel={0.155,0.020,0.012,0.98}, panel2={0.235,0.031,0.017,0.98},
        border={0.78,0.105,0.035,1}, main={0.045,0.004,0.003,0.99},
        page={0.185,0.024,0.012,0.97}, pageAlt={0.105,0.010,0.007,0.97},
        button={0.145,0.018,0.010,1}, buttonHover={0.255,0.036,0.017,1},
        accent={0.96,0.115,0.025,1}, accentHover={1.00,0.29,0.055,1},
    },
    ["Viridian Pulse"] = {
        gold={0.10,1.00,0.49}, pale={0.84,1.00,0.91}, muted={0.40,0.75,0.55},
        panel={0.010,0.125,0.065,0.98}, panel2={0.014,0.205,0.098,0.98},
        border={0.035,0.72,0.30,1}, main={0.003,0.039,0.020,0.99},
        page={0.012,0.155,0.074,0.97}, pageAlt={0.007,0.090,0.046,0.97},
        button={0.010,0.120,0.060,1}, buttonHover={0.018,0.225,0.105,1},
        accent={0.035,0.86,0.34,1}, accentHover={0.10,1.00,0.52,1},
    },
    ["Electric Cyan"] = {
        gold={0.06,0.94,1.00}, pale={0.85,0.98,1.00}, muted={0.39,0.72,0.78},
        panel={0.008,0.095,0.125,0.98}, panel2={0.010,0.155,0.200,0.98},
        border={0.025,0.62,0.78,1}, main={0.003,0.026,0.043,0.99},
        page={0.008,0.120,0.158,0.97}, pageAlt={0.005,0.068,0.098,0.97},
        button={0.007,0.090,0.125,1}, buttonHover={0.012,0.175,0.225,1},
        accent={0.015,0.78,0.94,1}, accentHover={0.08,0.96,1.00,1},
    },
    ["Cobalt Arc"] = {
        gold={0.22,0.54,1.00}, pale={0.88,0.92,1.00}, muted={0.43,0.56,0.79},
        panel={0.018,0.040,0.145,0.98}, panel2={0.025,0.060,0.225,0.98},
        border={0.095,0.30,0.82,1}, main={0.004,0.010,0.052,0.99},
        page={0.020,0.050,0.175,0.97}, pageAlt={0.011,0.027,0.105,0.97},
        button={0.016,0.038,0.140,1}, buttonHover={0.028,0.070,0.255,1},
        accent={0.075,0.32,0.98,1}, accentHover={0.20,0.54,1.00,1},
    },
    ["Ultraviolet"] = {
        gold={0.92,0.27,1.00}, pale={0.98,0.87,1.00}, muted={0.72,0.43,0.78},
        panel={0.105,0.012,0.145,0.98}, panel2={0.170,0.018,0.230,0.98},
        border={0.62,0.055,0.78,1}, main={0.034,0.003,0.055,0.99},
        page={0.130,0.014,0.178,0.97}, pageAlt={0.073,0.007,0.105,0.97},
        button={0.100,0.010,0.140,1}, buttonHover={0.185,0.020,0.250,1},
        accent={0.73,0.055,0.95,1}, accentHover={0.94,0.22,1.00,1},
    },
    ["Solar Flare"] = {
        gold={1.00,0.84,0.12}, pale={1.00,0.96,0.80}, muted={0.80,0.64,0.34},
        panel={0.155,0.075,0.005,0.98}, panel2={0.235,0.120,0.008,0.98},
        border={0.78,0.42,0.025,1}, main={0.046,0.018,0.001,0.99},
        page={0.185,0.088,0.006,0.97}, pageAlt={0.105,0.045,0.003,0.97},
        button={0.145,0.067,0.004,1}, buttonHover={0.255,0.130,0.010,1},
        accent={0.96,0.52,0.025,1}, accentHover={1.00,0.80,0.09,1},
    },
}

local THEME_ORDER={"Obsidian Crown","Ancient Book","Class Specific","Midnight","Arcane","Fel","Blood","Kaldorei","Stormwind","Monochrome","Deep Sea","Frost","Ember","Emerald","Voidglass","Rosewood","Silvermoon","Necromantic","Sunset","Royal","Bronze","Scarlet","Dream","Drust","Titan","Ivory & Gold","Nightborne","Jade Temple","Ashen Steel","Sapphire Court","Crimson Velvet","Moonwell","Vermilion Rite","Viridian Pulse","Electric Cyan","Cobalt Arc","Ultraviolet","Solar Flare"}

local THEME_COSTS={
    ["Ancient Book"]=0,
    ["Class Specific"]=0,
    ["Midnight"]=90,
    ["Arcane"]=120,
    ["Fel"]=120,
    ["Blood"]=150,
    ["Kaldorei"]=150,
    ["Stormwind"]=150,
    ["Monochrome"]=90,
    ["Deep Sea"]=110,
    ["Frost"]=120,
    ["Ember"]=120,
    ["Emerald"]=120,
    ["Voidglass"]=140,
    ["Rosewood"]=120,
    ["Silvermoon"]=160,
    ["Necromantic"]=140,
    ["Sunset"]=130,
    ["Royal"]=150,
    ["Bronze"]=100,
    ["Scarlet"]=150,
    ["Dream"]=140,
    ["Drust"]=120,
    ["Titan"]=160,
    ["Ivory & Gold"]=170,
    ["Obsidian Crown"]=0,
    ["Nightborne"]=180,
    ["Jade Temple"]=155,
    ["Ashen Steel"]=135,
    ["Sapphire Court"]=175,
    ["Crimson Velvet"]=165,
    ["Moonwell"]=170,
    ["Vermilion Rite"]=205,
    ["Viridian Pulse"]=205,
    ["Electric Cyan"]=210,
    ["Cobalt Arc"]=205,
    ["Ultraviolet"]=215,
    ["Solar Flare"]=210,
}

-- The Fall economy keeps roughly half the former Token catalogue as direct
-- purchases. Those remaining store items cost exactly twice their previous
-- displayed price. The old shop first applied its 1.12 multiplier and rounded
-- to the nearest 5 Tokens, so preserve that exact visible price before doubling.
function RPAL.GetAdjustedCustomizationPrice(value)
    value=math.max(0,tonumber(value) or 0)
    if value<=0 then return 0 end
    local previous=math.max(5,math.floor(((value*1.12)/5)+0.5)*5)
    return previous*2
end

local function ThemeDisplayName(name)
    if name=="Obsidian Crown" then return "Default" end
    if name=="Ancient Book" then return "Classic" end
    return name
end

local function ThemeSwatch(name)
    if name=="Class Specific" then
        local p=RPAL.char and RPAL.char.profile
        local c=p and p.classColor
        if c and c.r~=nil and c.g~=nil and c.b~=nil then
            return tonumber(c.r) or 0.72,tonumber(c.g) or 0.72,tonumber(c.b) or 0.72
        end
        return 0.72,0.72,0.72
    end
    local t=THEMES[name] or THEMES["Obsidian Crown"]
    return t.gold[1],t.gold[2],t.gold[3]
end




local ATMOSPHERE_TEXTURES={
    ["None"]="",
    ["Candlelit Parchment"]=TEX.."Atmospheres\\atmosphere_candlelit.tga",
    ["Moonlit Mist"]=TEX.."Atmospheres\\atmosphere_moonmist.tga",
    ["Arcane Constellations"]=TEX.."Atmospheres\\atmosphere_arcane.tga",
    ["Celestial Chart"]=TEX.."Atmospheres\\atmosphere_celestial_chart.tga",
    ["Astral Rays"]=TEX.."Atmospheres\\atmosphere_astral_rays.tga",
    ["Zodiac Veil"]=TEX.."Atmospheres\\atmosphere_zodiac_veil.tga",
    ["Starfall Veil"]=TEX.."Atmospheres\\atmosphere_starfall_veil.tga",
    ["Dreambloom"]=TEX.."Atmospheres\\atmosphere_dream.tga",
    ["Ember Ash"]=TEX.."Atmospheres\\atmosphere_ember.tga",
    ["Frosted Glass"]=TEX.."Atmospheres\\atmosphere_frost.tga",
    ["Void Veil"]=TEX.."Atmospheres\\atmosphere_voidveil.tga",
    ["Stormwind Dawn"]=TEX.."Atmospheres\\atmosphere_stormdawn.tga",
    ["Graveyard Fog"]=TEX.."Atmospheres\\atmosphere_gravefog.tga",
    ["Amber Archive"]=TEX.."Atmospheres\\atmosphere_candlelit.tga",
    ["Blue Hour"]=TEX.."Atmospheres\\atmosphere_stormdawn.tga",
    ["Rosemist"]=TEX.."Atmospheres\\atmosphere_dream.tga",
    ["Grave Lanterns"]=TEX.."Atmospheres\\atmosphere_gravefog.tga",
    ["Runic Haze"]=TEX.."Atmospheres\\atmosphere_arcane.tga",
    ["Ashen Sunset"]=TEX.."Atmospheres\\atmosphere_ember.tga",
    ["Open Beta Expanse"]=TEX.."Talent\\atlas_nebula.tga",
    ["Sunwell Reverie"]=TEX.."Atmospheres\\atmosphere_sunwell_reverie.tga",
    ["Rainwashed Window"]=TEX.."Atmospheres\\atmosphere_rainwashed_window.tga",
    ["Sable Aurora"]=TEX.."Atmospheres\\atmosphere_sable_aurora.tga",
    ["Verdant Canopy"]=TEX.."Atmospheres\\atmosphere_verdant_canopy.tga",
    ["Scarlet Eclipse"]=TEX.."Atmospheres\\atmosphere_scarlet_eclipse.tga",
    ["Library Dust"]=TEX.."Atmospheres\\atmosphere_library_dust.tga",

    -- Legacy scenery aliases. They are not shown in the new collection, but
    -- old SavedVariables still render gracefully until migration runs.
    ["Stormwind Keep"]=TEX.."Atmospheres\\atmosphere_stormdawn.tga",
    ["Moonlit Towers"]=TEX.."Atmospheres\\atmosphere_moonmist.tga",
    ["Ruined Citadel"]=TEX.."Atmospheres\\atmosphere_gravefog.tga",
    ["Arcane Spire"]=TEX.."Atmospheres\\atmosphere_arcane.tga",
    ["Forest Gate"]=TEX.."Atmospheres\\atmosphere_dream.tga",
    ["Cathedral Nave"]=TEX.."Atmospheres\\atmosphere_candlelit.tga",
    ["Harbor Watch"]=TEX.."Atmospheres\\atmosphere_moonmist.tga",
    ["Mountain Hold"]=TEX.."Atmospheres\\atmosphere_frost.tga",
    ["Elven Ruins"]=TEX.."Atmospheres\\atmosphere_dream.tga",
    ["Graveyard Gate"]=TEX.."Atmospheres\\atmosphere_gravefog.tga",
    ["Roadside Camp"]=TEX.."Atmospheres\\atmosphere_candlelit.tga",
}

local BACKGROUND_ORDER={
    "None","Candlelit Parchment","Moonlit Mist","Arcane Constellations","Celestial Chart","Astral Rays","Zodiac Veil","Starfall Veil","Dreambloom",
    "Ember Ash","Frosted Glass","Void Veil","Stormwind Dawn","Graveyard Fog","Amber Archive","Blue Hour","Rosemist","Grave Lanterns","Runic Haze","Ashen Sunset",
    "Sunwell Reverie","Rainwashed Window","Sable Aurora","Verdant Canopy","Scarlet Eclipse","Library Dust","Open Beta Expanse"
}
local BACKGROUND_COSTS={
    ["None"]=0,
    ["Candlelit Parchment"]=95,
    ["Moonlit Mist"]=120,
    ["Arcane Constellations"]=165,
    ["Celestial Chart"]=185,
    ["Astral Rays"]=185,
    ["Zodiac Veil"]=205,
    ["Starfall Veil"]=190,
    ["Dreambloom"]=150,
    ["Ember Ash"]=135,
    ["Frosted Glass"]=145,
    ["Void Veil"]=165,
    ["Stormwind Dawn"]=130,
    ["Graveyard Fog"]=140,
    ["Amber Archive"]=165,
    ["Blue Hour"]=190,
    Rosemist=180,
    ["Grave Lanterns"]=195,
    ["Runic Haze"]=185,
    ["Ashen Sunset"]=175,
    ["Sunwell Reverie"]=205,
    ["Rainwashed Window"]=155,
    ["Sable Aurora"]=200,
    ["Verdant Canopy"]=175,
    ["Scarlet Eclipse"]=205,
    ["Library Dust"]=165,
    -- Open Beta Expanse is earned from Season progression; it has no Token price.
}

local COSMETICS={
    portrait={
        order={
            "Default","Gilded","Arcane","Kaldorei","Fel","Voidglass","Bloodthorn","Frostbound","Emberforged","Dreamwoven",
            "Astral Orbit","Thorned Halo","Gilded Compass","Moonwarden","Open Beta Halo",
            "Maplefall Frame","Moonlit Frame","Ember Frame","Raven Frame","Autumn Frame",
        },
        costs={
            Default=0,Gilded=75,Arcane=120,Kaldorei=140,Fel=140,
            Voidglass=155,Bloodthorn=150,Frostbound=150,Emberforged=150,Dreamwoven=155,
            ["Astral Orbit"]=170,["Thorned Halo"]=165,["Gilded Compass"]=175,Moonwarden=180,
            -- Season portrait frames are earned from Fall 2026 progression.
        },
        assets={
            Gilded=TEX.."Cosmetics\\portrait_gilded.tga",
            Arcane=TEX.."Cosmetics\\portrait_arcane.tga",
            Kaldorei=TEX.."Cosmetics\\portrait_kaldorei.tga",
            Fel=TEX.."Cosmetics\\portrait_fel.tga",
            Voidglass=TEX.."Cosmetics\\portrait_voidglass.tga",
            Bloodthorn=TEX.."Cosmetics\\portrait_bloodthorn.tga",
            Frostbound=TEX.."Cosmetics\\portrait_frostbound.tga",
            Emberforged=TEX.."Cosmetics\\portrait_emberforged.tga",
            Dreamwoven=TEX.."Cosmetics\\portrait_dreamwoven.tga",
            ["Astral Orbit"]=TEX.."portrait_frame_arcane.tga",
            ["Thorned Halo"]=TEX.."portrait_frame_fel.tga",
            ["Gilded Compass"]=TEX.."portrait_frame_gilded.tga",
            Moonwarden=TEX.."portrait_frame_kaldorei.tga",
            ["Open Beta Halo"]=TEX.."Cosmetics\\portrait_open_beta.tga",
            ["Maplefall Frame"]=TEX.."Cosmetics\\portrait_maplefall.tga",
            ["Moonlit Frame"]=TEX.."Cosmetics\\portrait_moonlit.tga",
            ["Ember Frame"]=TEX.."Cosmetics\\portrait_ember_season.tga",
            ["Raven Frame"]=TEX.."Cosmetics\\portrait_raven.tga",
            ["Autumn Frame"]=TEX.."Cosmetics\\portrait_autumn.tga",
        },
        procedural={
            Gilded={color={0.95,0.75,0.34},style="gilded"},
            Arcane={color={0.70,0.43,1.00},style="arcane"},
            Kaldorei={color={0.58,0.72,1.00},style="kaldorei"},
            Fel={color={0.52,0.95,0.24},style="fel"},
            Voidglass={color={0.61,0.35,0.92},style="voidglass"},
            Bloodthorn={color={0.92,0.25,0.28},style="bloodthorn"},
            Frostbound={color={0.48,0.82,1.00},style="frostbound"},
            Emberforged={color={1.00,0.52,0.18},style="emberforged"},
            Dreamwoven={color={0.35,0.88,0.58},style="dreamwoven"},
            ["Astral Orbit"]={color={0.62,0.72,1.00},style="astral"},
            ["Thorned Halo"]={color={0.50,0.92,0.28},style="thorned"},
            ["Gilded Compass"]={color={0.96,0.76,0.30},style="compass"},
            Moonwarden={color={0.62,0.78,1.00},style="moonwarden"},
        },
    },
    level={
        order={"Initiate Seal","Verdant Mark","Azure Sigil","Amethyst Crest","Rose Emblem","Sunforged Seal","Crimson Legend","Legacy Chronicle","Captain's Radiance","Open Beta Crest","Maplefall Seal","Northstar Seal","Archivist Seal","Wayglass Seal","Wolf Seal"},
        costs={},
        assets={
            ["Initiate Seal"]=TEX.."Cosmetics\\Badges2\\initiate_seal.tga",
            ["Verdant Mark"]=TEX.."Cosmetics\\Badges2\\verdant_mark.tga",
            ["Azure Sigil"]=TEX.."Cosmetics\\Badges2\\azure_sigil.tga",
            ["Amethyst Crest"]=TEX.."Cosmetics\\Badges2\\amethyst_crest.tga",
            ["Rose Emblem"]=TEX.."Cosmetics\\Badges2\\rose_emblem.tga",
            ["Sunforged Seal"]=TEX.."Cosmetics\\Badges2\\sunforged_seal.tga",
            ["Crimson Legend"]=TEX.."Cosmetics\\Badges2\\crimson_legend.tga",
            ["Legacy Chronicle"]=TEX.."Cosmetics\\Badges2\\legacy_chronicle.tga",
            ["Captain's Radiance"]=TEX.."Cosmetics\\Badges2\\captains_radiance.tga",
            ["Open Beta Crest"]=TEX.."Cosmetics\\Badges2\\open_beta_crest.tga",
            ["Maplefall Seal"]=TEX.."Cosmetics\\Badges2\\maplefall_seal.tga",
            ["Northstar Seal"]=TEX.."Cosmetics\\Badges2\\northstar_seal.tga",
            ["Archivist Seal"]=TEX.."Cosmetics\\Badges2\\archivist_seal.tga",
            ["Wayglass Seal"]=TEX.."Cosmetics\\Badges2\\wayglass_seal.tga",
            ["Wolf Seal"]=TEX.."Cosmetics\\Badges2\\wolf_seal.tga",
        },
        levels={
            ["Initiate Seal"]=1,["Verdant Mark"]=5,["Azure Sigil"]=15,["Amethyst Crest"]=30,
            ["Rose Emblem"]=40,["Sunforged Seal"]=50,["Crimson Legend"]=60,
        },
        sources={
            ["Legacy Chronicle"]="LEGACY CALIBRATION",
            ["Captain's Radiance"]="MAIN CHARACTER ONLY",
            ["Open Beta Crest"]="OPEN BETA 2026 • LEVEL 6",
            ["Maplefall Seal"]="FALL 2026 • LEVEL 2",
            ["Northstar Seal"]="FALL 2026 • LEVEL 9",
            ["Archivist Seal"]="FALL 2026 • LEVEL 11",
            ["Wayglass Seal"]="FALL 2026 • LEVEL 15",
            ["Wolf Seal"]="FALL 2026 • LEVEL 19",
        },
    },
    header={
        order={"None","Kaldorei","Arcane","Void","Royal","Blood","Frost","Dream","Ember","Maplefall Crest","Heirloom Crest"},
        costs={
            None=0,Kaldorei=110,Arcane=110,Void=120,
            Royal=150,Blood=145,Frost=145,Dream=150,Ember=145
        },
        assets={
            Kaldorei=TEX.."Cosmetics\\header_kaldorei.tga",
            Arcane=TEX.."Cosmetics\\header_arcane.tga",
            Void=TEX.."Cosmetics\\header_void.tga",
            Royal=TEX.."Cosmetics\\header_royal.tga",
            Blood=TEX.."Cosmetics\\header_blood.tga",
            Frost=TEX.."Cosmetics\\header_frost.tga",
            Dream=TEX.."Cosmetics\\header_dream.tga",
            Ember=TEX.."Cosmetics\\header_ember.tga",
            ["Maplefall Crest"]=TEX.."Interaction\\large01_header.tga",
            ["Heirloom Crest"]=TEX.."Interaction\\large02_header.tga",
        },
        procedural={
            Kaldorei={color={0.60,0.70,1.00},style="kaldorei"},
            Arcane={color={0.72,0.48,1.00},style="arcane"},
            Void={color={0.62,0.35,0.92},style="void"},
            Royal={color={0.94,0.74,0.30},style="royal"},
            Blood={color={0.92,0.28,0.30},style="blood"},
            Frost={color={0.52,0.86,1.00},style="frost"},
            Dream={color={0.38,0.90,0.60},style="dream"},
            Ember={color={1.00,0.55,0.20},style="ember"},
        },
        fullWidth={["Maplefall Crest"]=true,["Heirloom Crest"]=true},
    },
    background={
        order=BACKGROUND_ORDER,
        costs=BACKGROUND_COSTS,
        arts=ATMOSPHERE_TEXTURES,
    },
}

local PROFILE_AURAS={
    order={
        "None","Golden Radiance","Moonlit","Frost Veil","Fel Spark","Blood Oath","Dreamweave","Arcane Orbit","Void Whisper",
        "Sunwell Halo","Emerald Reverie","Crimson Eclipse","Astral Crown","Void Singularity","Celestial Choir"
    },
    costs={
        None=0,
        ["Golden Radiance"]=125,["Moonlit"]=150,["Frost Veil"]=155,["Fel Spark"]=155,["Blood Oath"]=160,
        Dreamweave=165,["Arcane Orbit"]=170,["Void Whisper"]=165,
        ["Sunwell Halo"]=260,["Emerald Reverie"]=275,["Crimson Eclipse"]=300,
        ["Astral Crown"]=325,["Void Singularity"]=360,["Celestial Choir"]=400,
    },
    styles={
        ["Golden Radiance"]={color={1.00,0.78,0.30},secondary={0.95,0.56,0.12},mode="pulse",speed=1.7,rings=2,particles=0,tier=1},
        ["Arcane Orbit"]={color={0.72,0.46,1.00},secondary={0.38,0.20,0.80},mode="orbit",speed=0.85,rings=2,particles=8,tier=2},
        ["Fel Spark"]={color={0.48,1.00,0.20},secondary={0.18,0.56,0.08},mode="flicker",speed=3.8,rings=1,particles=9,tier=1},
        ["Frost Veil"]={color={0.52,0.88,1.00},secondary={0.22,0.58,0.86},mode="veil",speed=1.35,rings=2,particles=8,tier=1},
        ["Void Whisper"]={color={0.65,0.32,0.95},secondary={0.22,0.08,0.46},mode="pulse",speed=1.1,rings=2,particles=5,tier=2},
        ["Blood Oath"]={color={0.95,0.24,0.28},secondary={0.48,0.06,0.08},mode="double",speed=1.45,rings=2,particles=5,tier=2},
        Dreamweave={color={0.35,0.92,0.60},secondary={0.10,0.48,0.30},mode="orbit",speed=0.55,rings=2,particles=9,tier=2},
        Moonlit={color={0.62,0.82,1.00},secondary={0.34,0.46,0.82},mode="veil",speed=0.85,rings=2,particles=7,tier=1},

        -- High-tier auras deliberately become more theatrical.  They remain
        -- hollow around the portrait: all animation lives outside the face.
        ["Sunwell Halo"]={color={1.00,0.82,0.34},secondary={0.38,0.78,1.00},mode="sunwell",speed=0.70,rings=3,particles=12,tier=3},
        ["Emerald Reverie"]={color={0.24,1.00,0.54},secondary={0.72,1.00,0.32},mode="reverie",speed=0.52,rings=3,particles=14,tier=3},
        ["Crimson Eclipse"]={color={1.00,0.18,0.26},secondary={0.42,0.015,0.05},mode="eclipse",speed=0.82,rings=3,particles=12,tier=3},
        ["Astral Crown"]={color={0.62,0.58,1.00},secondary={0.95,0.78,1.00},mode="crown",speed=0.48,rings=3,particles=16,tier=4},
        ["Void Singularity"]={color={0.66,0.20,1.00},secondary={0.12,0.02,0.30},mode="singularity",speed=0.62,rings=4,particles=18,tier=4},
        ["Celestial Choir"]={color={1.00,0.82,0.28},secondary={0.48,0.76,1.00},mode="choir",speed=0.42,rings=4,particles=20,tier=5},
    },
}

local UI_ORNAMENTS={
    order={"Classic","Royal Filigree","Runic Corners","Arcane Lattice","Kaldorei Vines","Void Etching","Ember Rivets","Frost Runes","Maplefall Binding","Heirloom Folio"},
    costs={
        Classic=0,
        ["Royal Filigree"]=145,
        ["Runic Corners"]=135,
        ["Arcane Lattice"]=165,
        ["Kaldorei Vines"]=160,
        ["Void Etching"]=165,
        ["Ember Rivets"]=150,
        ["Frost Runes"]=150,
    },
    styles={
        Classic={
            color=nil,style="classic",displayName="Archivist's Binding",
            corner=TEX.."Cosmetics\\ornament_classic_corner.tga",
            seal=TEX.."Cosmetics\\ornament_classic_seal.tga",
        },
        ["Royal Filigree"]={
            color={0.96,0.76,0.30},style="royal",motion="gleam",displayName="Crown of the Gilded Court",
            corner=TEX.."Cosmetics\\ornament_royal_corner.tga",
            seal=TEX.."Cosmetics\\ornament_royal_seal.tga",
            atmosphere=TEX.."Atmospheres\\atmosphere_candlelit.tga",
        },
        ["Runic Corners"]={
            color={0.52,0.78,1.00},style="runes",motion="runes",displayName="Runebound Grimoire",
            corner=TEX.."Cosmetics\\ornament_runic_corner.tga",
            seal=TEX.."Cosmetics\\ornament_runic_seal.tga",
            atmosphere=TEX.."Atmospheres\\atmosphere_frost.tga",
        },
        ["Arcane Lattice"]={
            color={0.70,0.42,1.00},style="lattice",motion="arcane",displayName="Arcanist's Constellation",
            corner=TEX.."Cosmetics\\ornament_arcane_corner.tga",
            seal=TEX.."Cosmetics\\ornament_arcane_seal.tga",
            atmosphere=TEX.."Atmospheres\\atmosphere_arcane.tga",
        },
        ["Kaldorei Vines"]={
            color={0.45,0.78,0.88},style="vines",motion="moon",displayName="Moonbough Binding",
            corner=TEX.."Cosmetics\\ornament_kaldorei_corner.tga",
            seal=TEX.."Cosmetics\\ornament_kaldorei_seal.tga",
            atmosphere=TEX.."Atmospheres\\atmosphere_moonmist.tga",
        },
        ["Void Etching"]={
            color={0.60,0.30,0.92},style="etch",motion="void",displayName="Umbral Scripture",
            corner=TEX.."Cosmetics\\ornament_void_corner.tga",
            seal=TEX.."Cosmetics\\ornament_void_seal.tga",
            atmosphere=TEX.."Atmospheres\\atmosphere_voidveil.tga",
        },
        ["Ember Rivets"]={
            color={1.00,0.52,0.18},style="rivets",motion="ember",displayName="Emberforged Reliquary",
            corner=TEX.."Cosmetics\\ornament_ember_corner.tga",
            seal=TEX.."Cosmetics\\ornament_ember_seal.tga",
            atmosphere=TEX.."Atmospheres\\atmosphere_ember.tga",
        },
        ["Frost Runes"]={
            color={0.52,0.88,1.00},style="frost",motion="frost",displayName="Rimebound Codex",
            corner=TEX.."Cosmetics\\ornament_frost_corner.tga",
            seal=TEX.."Cosmetics\\ornament_frost_seal.tga",
            atmosphere=TEX.."Atmospheres\\atmosphere_frost.tga",
        },
        ["Maplefall Binding"]={
            color={0.64,0.82,1.00},style="maplefall",displayName="Maplefall Binding",
            corner=TEX.."Interaction\\large01_corner.tga",
            seal=TEX.."Cosmetics\\ornament_kaldorei_seal.tga",
        },
        ["Heirloom Folio"]={
            color={1.00,0.82,0.36},style="heirloom",displayName="Heirloom Folio",
            corner=TEX.."Interaction\\large02_corner.tga",
            seal=TEX.."Cosmetics\\ornament_royal_seal.tga",
        },
    },
}

-- HOTFIX9 platforms use individually authored transparent silhouettes. The
-- renderer lives in PlatformFX.lua; this catalog owns palette, asset, and
-- motion identity so previews and live profile models stay identical.
local PROFILE_CUSTOMIZATION={platforms={
    order={"Default","Runic Halo","Arcane Convergence","Emberforged Dais","Frostbound Ring","Void Eclipse","Moonwell Bloom"},
    costs={
        Default=0,["Runic Halo"]=90,["Arcane Convergence"]=130,["Emberforged Dais"]=130,
        ["Frostbound Ring"]=130,["Void Eclipse"]=150,["Moonwell Bloom"]=145,
    },
    styles={
        Default={label="Astral Plinth",asset="default",color=nil,secondary=nil,mode="classic"},
        ["Runic Halo"]={asset="runic_halo",color={0.48,0.78,1.00},secondary={0.82,0.92,1.00},mode="runes"},
        ["Arcane Convergence"]={asset="arcane_convergence",color={0.70,0.42,1.00},secondary={0.96,0.72,1.00},mode="arcane"},
        ["Emberforged Dais"]={asset="emberforged_dais",color={1.00,0.43,0.12},secondary={1.00,0.78,0.25},mode="ember"},
        ["Frostbound Ring"]={asset="frostbound_ring",color={0.42,0.82,1.00},secondary={0.84,0.96,1.00},mode="frost"},
        ["Void Eclipse"]={asset="void_eclipse",color={0.54,0.20,0.92},secondary={0.88,0.52,1.00},mode="void"},
        ["Moonwell Bloom"]={asset="moonwell_bloom",color={0.34,0.88,0.68},secondary={0.56,0.80,1.00},mode="moon"},
    },
}}
RPAL.PROFILE_PLATFORM_STYLES=PROFILE_CUSTOMIZATION.platforms.styles

-- These are the animation-backed emote sequences exposed by the player model.
-- The command label is discovered from the client's localized EMOTE*_CMD*
-- globals when available, with the English command retained as a safe fallback.
PROFILE_CUSTOMIZATION.animationBase={
    {key="Stand",token="STAND",command="/stand",sequence=0,duration=0,cost=0},
    {key="Talk",token="TALK",command="/talk",sequence=60,duration=3.00,cost=70},
    {key="Eat",token="EAT",command="/eat",sequence=61,duration=4.60,cost=80},
    {key="Work",token="WORK",command="/work",sequence=62,duration=4.20,cost=80},
    {key="Exclaim",token="TALKEX",command="/talkex",sequence=64,duration=2.40,cost=85},
    {key="Question",token="TALKQ",command="/talkq",sequence=65,duration=2.60,cost=85},
    {key="Bow",token="BOW",command="/bow",sequence=66,duration=2.80,cost=90},
    {key="Wave",token="WAVE",command="/wave",sequence=67,duration=2.50,cost=0},
    {key="Cheer",token="CHEER",command="/cheer",sequence=68,duration=3.00,cost=100},
    {key="Dance",token="DANCE",command="/dance",sequence=69,duration=6.00,cost=135},
    {key="Laugh",token="LAUGH",command="/laugh",sequence=70,duration=3.20,cost=95},
    {key="Sleep",token="SLEEP",command="/sleep",sequence=71,duration=5.00,cost=105},
    {key="Sit",token="SIT",command="/sit",sequence=72,duration=3.00,cost=85},
    {key="Rude",token="RUDE",command="/rude",sequence=73,duration=2.40,cost=125},
    {key="Roar",token="ROAR",command="/roar",sequence=74,duration=3.20,cost=110},
    {key="Kneel",token="KNEEL",command="/kneel",sequence=75,duration=3.00,cost=100},
    {key="Kiss",token="KISS",command="/kiss",sequence=76,duration=2.40,cost=110},
    {key="Cry",token="CRY",command="/cry",sequence=77,duration=4.00,cost=100},
    {key="Chicken",token="CHICKEN",command="/chicken",sequence=78,duration=3.20,cost=120},
    {key="Beg",token="BEG",command="/beg",sequence=79,duration=3.20,cost=110},
    {key="Applaud",token="APPLAUD",command="/applaud",sequence=80,duration=3.20,cost=105},
    {key="Shout",token="SHOUT",command="/shout",sequence=81,duration=2.50,cost=100},
    {key="Flex",token="FLEX",command="/flex",sequence=82,duration=3.00,cost=115},
    {key="Shy",token="SHY",command="/shy",sequence=83,duration=3.00,cost=105},
    {key="Point",token="POINT",command="/point",sequence=84,duration=2.40,cost=95},
}
PROFILE_CUSTOMIZATION.animationsByKey={}
PROFILE_CUSTOMIZATION.state={tab="Themes",scroll={}}
for _,definition in ipairs(PROFILE_CUSTOMIZATION.animationBase) do PROFILE_CUSTOMIZATION.animationsByKey[definition.key]=definition end

function RPAL.BuildProfileAnimationCatalog()
    if PROFILE_CUSTOMIZATION.animationCatalog then
        return PROFILE_CUSTOMIZATION.animationCatalog
    end
    local discovered={}
    for index=1,2400 do
        local token=_G["EMOTE"..tostring(index).."_TOKEN"]
        if token and token~="" then
            token=tostring(token):upper()
            for commandIndex=1,4 do
                local command=_G["EMOTE"..tostring(index).."_CMD"..tostring(commandIndex)]
                if type(command)=="string" and command:sub(1,1)=="/" and not discovered[token] then
                    discovered[token]=command
                end
            end
        end
    end
    local catalog={}
    for _,source in ipairs(PROFILE_CUSTOMIZATION.animationBase) do
        local entry={}
        for key,value in pairs(source) do entry[key]=value end
        entry.command=discovered[entry.token] or entry.command
        catalog[#catalog+1]=entry
    end
    PROFILE_CUSTOMIZATION.animationCatalog=catalog
    return catalog
end

function RPAL.GetProfileAnimationDefinition(key)
    return PROFILE_CUSTOMIZATION.animationsByKey[key] or PROFILE_CUSTOMIZATION.animationsByKey.Stand
end


RPAL.ATMOSPHERE_STYLE_OVERRIDES={
    ["Amber Archive"]={tint={1.00,0.69,0.28},boost=1.06},
    ["Blue Hour"]={tint={0.38,0.60,0.92},boost=1.04},
    Rosemist={tint={0.92,0.46,0.68},boost=1.02},
    ["Grave Lanterns"]={tint={0.42,0.66,0.58},boost=1.08},
    ["Runic Haze"]={tint={0.68,0.50,1.00},boost=1.07},
    ["Ashen Sunset"]={tint={0.94,0.46,0.22},boost=1.00},
    ["Sunwell Reverie"]={tint={1.00,0.78,0.28},boost=1.12},
    ["Rainwashed Window"]={tint={0.46,0.72,1.00},boost=1.06},
    ["Sable Aurora"]={tint={0.62,0.36,0.96},boost=1.12},
    ["Verdant Canopy"]={tint={0.35,0.88,0.52},boost=1.08},
    ["Scarlet Eclipse"]={tint={0.92,0.16,0.23},boost=1.11},
    ["Library Dust"]={tint={0.94,0.70,0.34},boost=1.07},
}

local function RenderAtmosphere(frame,name,color,scale,animated)
    if not frame then return nil end
    name=name or "None"
    local asset=ATMOSPHERE_TEXTURES[name]
    color=color or GOLD
    local atmosphereOverride=RPAL.ATMOSPHERE_STYLE_OVERRIDES[name]
    local themeTinted=name=="Celestial Chart" or name=="Astral Rays" or name=="Zodiac Veil" or name=="Starfall Veil" or name=="Open Beta Expanse"
    local seasonTint=(name=="Open Beta Expanse") and {0.56,0.40,1.00} or (atmosphereOverride and atmosphereOverride.tint)
    scale=tonumber(scale) or 1
    animated=animated~=false
    -- The Open Beta reward should read as a real season backdrop rather than a
    -- barely visible tint. Keep the boost local to this atmosphere so normal
    -- backgrounds retain their existing contrast.
    local visibilityBoost=(name=="Open Beta Expanse") and 1.28 or ((atmosphereOverride and atmosphereOverride.boost) or 1)

    local function ApplyAtmosphereTint(holder)
        if not holder then return end
        local r,g,b=1,1,1
        if seasonTint then r,g,b=seasonTint[1],seasonTint[2],seasonTint[3]
        elseif themeTinted then r,g,b=color[1],color[2],color[3] end
        for _,texture in ipairs({
            holder._rpalAtmosphereMain,
            holder._rpalAtmosphereBloom,
            holder._rpalAtmosphereContrast,
            holder._rpalAtmosphereSheen,
        }) do
            if texture and texture.SetVertexColor then texture:SetVertexColor(r,g,b) end
        end
    end

    local function UpdateAtmosphere(self,elapsed)
        if not self:IsShown() then return end
        if GetTime then
            self._rpalAtmospherePhase=GetTime()
        else
            self._rpalAtmospherePhase=(self._rpalAtmospherePhase or 0)+(elapsed or 0)
        end
        local pulse=(math.sin((self._rpalAtmospherePhase or 0)*0.42)+1)*0.5
        local activeScale=(tonumber(self._rpalAtmosphereScale) or 1)*(tonumber(self._rpalAtmosphereVisibilityBoost) or 1)
        local seasonMode=self._rpalAtmosphereName=="Open Beta Expanse"
        self._rpalAtmosphereMain:SetAlpha(math.min(seasonMode and 0.80 or 0.68,(0.50+(pulse*0.10))*activeScale))
        self._rpalAtmosphereBloom:SetAlpha(math.min(seasonMode and 0.38 or 0.30,(0.18+(pulse*0.07))*activeScale))
        if self._rpalAtmosphereContrast then
            self._rpalAtmosphereContrast:SetAlpha(math.min(seasonMode and 0.30 or 0.24,(0.125+(pulse*0.055))*activeScale))
        end
        if self._rpalAtmosphereSheen then
            self._rpalAtmosphereSheen:SetAlpha(math.min(seasonMode and 0.23 or 0.18,(0.065+(pulse*0.040))*activeScale))
        end
    end

    -- Inspect profiles arrive as a stream of small addon packets.  The page is
    -- refreshed for each packet, but its atmosphere is a persistent visual
    -- layer: reusing the holder keeps both the animation phase and the alpha
    -- pulse continuous while Memoirs, Saved Sessions, or achievements are updated.
    local existing=frame._rpalAtmosphereHolder
    if name=="None" or not asset or asset=="" then
        if existing and existing.Hide then
            existing:SetScript("OnUpdate",nil)
            existing:Hide()
        end
        frame._rpalAtmosphereName="None"
        return nil
    end
    if existing and frame._rpalAtmosphereName==name then
        if frame.GetFrameLevel and existing.SetFrameLevel then
            existing:SetFrameLevel((frame:GetFrameLevel() or 0)+1)
        end
        existing._rpalAtmosphereScale=scale
        existing._rpalAtmosphereVisibilityBoost=visibilityBoost
        existing._rpalAtmosphereName=name
        existing._rpalAtmosphereAnimated=animated
        ApplyAtmosphereTint(existing)
        if animated then
            existing:SetScript("OnUpdate",UpdateAtmosphere)
        else
            existing:SetScript("OnUpdate",nil)
            local boostedScale=scale*visibilityBoost
            local seasonMode=name=="Open Beta Expanse"
            existing._rpalAtmosphereMain:SetAlpha(math.min(seasonMode and 0.76 or 0.64,0.49*boostedScale))
            existing._rpalAtmosphereBloom:SetAlpha(math.min(seasonMode and 0.34 or 0.27,0.17*boostedScale))
            if existing._rpalAtmosphereContrast then existing._rpalAtmosphereContrast:SetAlpha(math.min(seasonMode and 0.27 or 0.21,0.115*boostedScale)) end
            if existing._rpalAtmosphereSheen then existing._rpalAtmosphereSheen:SetAlpha(math.min(seasonMode and 0.24 or 0.19,0.085*boostedScale)) end
        end
        existing:Show()
        return existing
    end
    if existing then
        existing:SetScript("OnUpdate",nil)
        existing:Hide()
        existing:SetParent(nil)
    end

    local holder=CreateFrame("Frame",nil,frame)
    holder:SetAllPoints(frame)
    holder:SetClipsChildren(true)
    holder:EnableMouse(false)
    holder:EnableMouseWheel(false)
    -- Sit above the parent's backdrop but below normal child controls/cards.
    -- This keeps the atmosphere visible without washing out text or buttons.
    if frame.GetFrameLevel and holder.SetFrameLevel then
        holder:SetFrameLevel((frame:GetFrameLevel() or 0)+1)
    end
    holder._rpalAtmosphereScale=scale
    holder._rpalAtmosphereVisibilityBoost=visibilityBoost
    holder._rpalAtmosphereName=name
    holder._rpalAtmospherePhase=(GetTime and GetTime()) or 0
    holder._rpalAtmosphereAnimated=animated
    holder._rpalAtmosphereThemeTinted=themeTinted
    frame._rpalAtmosphereHolder=holder
    frame._rpalAtmosphereName=name

    local main=holder:CreateTexture(nil,"BACKGROUND",nil,-1)
    main:SetTexture(asset)
    main:SetPoint("TOPLEFT",-18,14)
    main:SetPoint("BOTTOMRIGHT",18,-14)
    main:SetAlpha(math.min((name=="Open Beta Expanse") and 0.76 or 0.64,0.49*scale*visibilityBoost))
    holder._rpalAtmosphereMain=main

    local bloom=holder:CreateTexture(nil,"BACKGROUND",nil,0)
    bloom:SetTexture(asset)
    bloom:SetPoint("TOPLEFT",-34,26)
    bloom:SetPoint("BOTTOMRIGHT",34,-26)
    bloom:SetBlendMode("ADD")
    bloom:SetAlpha(math.min((name=="Open Beta Expanse") and 0.34 or 0.27,0.17*scale*visibilityBoost))
    holder._rpalAtmosphereBloom=bloom

    local contrast=holder:CreateTexture(nil,"ARTWORK",nil,-4)
    contrast:SetTexture(asset)
    contrast:SetPoint("TOPLEFT",-4,3)
    contrast:SetPoint("BOTTOMRIGHT",4,-3)
    contrast:SetBlendMode("ADD")
    contrast:SetAlpha(math.min((name=="Open Beta Expanse") and 0.27 or 0.21,0.115*scale*visibilityBoost))
    holder._rpalAtmosphereContrast=contrast

    local sheen=holder:CreateTexture(nil,"ARTWORK",nil,-5)
    sheen:SetTexture(asset)
    sheen:SetPoint("TOPLEFT",-8,6)
    sheen:SetPoint("BOTTOMRIGHT",8,-6)
    sheen:SetBlendMode("ADD")
    sheen:SetAlpha(math.min((name=="Open Beta Expanse") and 0.24 or 0.19,0.085*scale*visibilityBoost))
    holder._rpalAtmosphereSheen=sheen

    ApplyAtmosphereTint(holder)

    if animated then holder:SetScript("OnUpdate",UpdateAtmosphere) end

    return holder
end


RPAL.RenderAtmosphere = RenderAtmosphere

-- Platform rendering is installed by PlatformFX.lua after this UI module loads.

function RPAL.NormalizeProfileAnimationKeys(selection)
    local keys={}
    local seen={}
    local function AddKey(key)
        key=tostring(key or "")
        local definition=PROFILE_CUSTOMIZATION.animationsByKey[key]
        if key~="" and key~="Stand" and definition and not seen[key] then
            seen[key]=true
            keys[#keys+1]=key
        end
    end
    if type(selection)=="string" then
        AddKey(selection)
    elseif type(selection)=="table" then
        for index,value in ipairs(selection) do AddKey(value) end
        for key,value in pairs(selection) do
            if type(key)=="string" and value then AddKey(key) end
        end
    end
    table.sort(keys)
    return keys
end

function RPAL.AnimationSelectionSignature(selection)
    return table.concat(RPAL.NormalizeProfileAnimationKeys(selection),",")
end

function RPAL.ShuffleProfileAnimationBag(keys,lastKey)
    local bag={}
    for _,key in ipairs(keys) do bag[#bag+1]=key end
    for index=#bag,2,-1 do
        local swap=math.random(index)
        bag[index],bag[swap]=bag[swap],bag[index]
    end
    if #bag>1 and bag[1]==lastKey then bag[1],bag[2]=bag[2],bag[1] end
    return bag
end

function RPAL.ApplyProfileModelAnimation(model,selection)
    if not model then return false end
    local keys=RPAL.NormalizeProfileAnimationKeys(selection)
    model._rpalAnimationGeneration=(model._rpalAnimationGeneration or 0)+1
    local generation=model._rpalAnimationGeneration
    model._rpalAnimationSelection=keys
    model._rpalAnimationSignature=table.concat(keys,",")
    model._rpalAnimationBag={}
    model._rpalAnimationLast=nil
    model._rpalAnimationSerial=0
    model._rpalAnimationNextAt=nil
    model:SetScript("OnUpdate",nil)
    pcall(model.SetScript,model,"OnAnimFinished",nil)
    pcall(function() if model.SetPaused then model:SetPaused(false) end end)

    local function PlayNative(self,sequence)
        local played=false
        if self.SetAnimation then played=pcall(self.SetAnimation,self,sequence) end
        if not played and self.SetSequence then played=pcall(self.SetSequence,self,sequence) end
        return played
    end

    if #keys==0 then
        model._rpalAppliedAnimationKey="Stand"
        model._rpalAnimationKey="Stand"
        return PlayNative(model,0)
    end

    -- Inspect/open behaviour: the first selected animation is played immediately.
    -- After each animation fully finishes, a deliberately human-looking random
    -- 3-7 second idle window is rolled before the next animation begins.
    local PlayNext
    local ScheduleNext
    local firstPlayback=true

    PlayNext=function(self)
        if not self or not self:IsShown() or self._rpalAnimationGeneration~=generation then return end
        local key
        if firstPlayback then
            -- The first selected entry is always the one shown immediately on open.
            key=keys[1]
            firstPlayback=false
            self._rpalAnimationBag={}
            for index=2,#keys do self._rpalAnimationBag[#self._rpalAnimationBag+1]=keys[index] end
            for index=#self._rpalAnimationBag,2,-1 do
                local swap=math.random(index)
                self._rpalAnimationBag[index],self._rpalAnimationBag[swap]=self._rpalAnimationBag[swap],self._rpalAnimationBag[index]
            end
        else
            if not self._rpalAnimationBag or #self._rpalAnimationBag==0 then
                self._rpalAnimationBag=RPAL.ShuffleProfileAnimationBag(keys,self._rpalAnimationLast)
            end
            key=table.remove(self._rpalAnimationBag,1)
        end
        local definition=RPAL.GetProfileAnimationDefinition(key)
        local sequence=tonumber(definition and definition.sequence) or 0
        self._rpalAnimationLast=key
        self._rpalAnimationKey=key
        self._rpalAppliedAnimationKey=key
        self._rpalAnimationSerial=(self._rpalAnimationSerial or 0)+1
        PlayNative(self,sequence)
        -- IMPORTANT: the 3-7 second idle delay starts only after this native
        -- animation reports that it has finished. This keeps the full emote
        -- visible instead of counting the delay from the animation start.
        self._rpalAnimationWaitingForFinish=true
    end

    ScheduleNext=function(self)
        if not self or not self:IsShown() or self._rpalAnimationGeneration~=generation then return end
        local delay=3+math.random()*4
        self._rpalAnimationWaitingForFinish=false

        -- The moment the emote finishes, immediately return the character to
        -- its native idle/stand animation. The 3-7 second cooldown runs while
        -- the character is visibly idling, rather than leaving the model frozen
        -- on the final frame of the previous emote.
        self._rpalAnimationKey="Stand"
        self._rpalAppliedAnimationKey="Stand"
        PlayNative(self,0)

        self._rpalAnimationNextAt=((GetTime and GetTime()) or 0)+delay
        if C_Timer and C_Timer.After then
            C_Timer.After(delay,function()
                if self and self:IsShown() and self._rpalAnimationGeneration==generation then
                    PlayNext(self)
                end
            end)
        end
    end

    -- Native PlayerModel animations are finite. Start the random 3-7 second
    -- idle window from OnAnimFinished, never from the moment the emote begins.
    pcall(model.SetScript,model,"OnAnimFinished",function(self)
        if not self or not self:IsShown() or self._rpalAnimationGeneration~=generation then return end
        if not self._rpalAnimationWaitingForFinish then return end
        self._rpalAnimationWaitingForFinish=false
        ScheduleNext(self)
    end)

    -- First animation is intentionally immediate when the inspect/profile model
    -- becomes active. The timer does not begin until this animation finishes.
    PlayNext(model)
    return true
end


-- Unified portrait cosmetic renderer.  Every non-default frame now uses the
-- purchased Small Container line art with a genuinely empty centre.  Theme
-- identity comes from tint rather than oversized decorative crests or collage art.
local function RenderPortraitCosmetic(parent,anchor,name,tint,size)
    if not parent or not anchor or not name or name=="Default" then return nil end
    size=tonumber(size) or 132
    tint=tint or GOLD

    local asset=COSMETICS.portrait.assets[name]
    if not asset then return nil end
    -- The frame selection controls geometry only; the viewed character's
    -- active theme always supplies its color.
    local c=tint

    local holder=CreateFrame("Frame",nil,parent)
    holder:SetSize(size*1.08,size*1.08)
    holder:SetPoint("CENTER",anchor,"CENTER",0,0)
    if parent.GetFrameLevel and holder.SetFrameLevel then
        holder:SetFrameLevel((parent:GetFrameLevel() or 0)+2)
    end

    -- A restrained halo lives outside the face.  The authored frame itself is
    -- static, avoiding the synthetic "breathing line art" look and an OnUpdate
    -- script for every portrait shown in a large combat.
    local halo=holder:CreateTexture(nil,"BACKGROUND",nil,-1)
    halo:SetTexture(TEX.."Cosmetics\\aura_soft_ring.tga")
    halo:SetBlendMode("ADD")
    halo:SetSize(size*1.16,size*1.16)
    halo:SetPoint("CENTER")
    halo:SetVertexColor(c[1],c[2],c[3],0.16)

    local shadow=holder:CreateTexture(nil,"ARTWORK",nil,1)
    shadow:SetTexture(asset)
    shadow:SetPoint("TOPLEFT",holder,"TOPLEFT",1,-1)
    shadow:SetPoint("BOTTOMRIGHT",holder,"BOTTOMRIGHT",1,-1)
    shadow:SetVertexColor(0.01,0.015,0.025,0.64)

    local art=holder:CreateTexture(nil,"OVERLAY",nil,2)
    art:SetTexture(asset)
    art:SetAllPoints()
    art:SetVertexColor(c[1],c[2],c[3],0.90)
    holder._rpalPortraitAsset=asset
    holder._rpalPortraitArt=art

    return holder
end

local GetRenownLevelColor

local BADGE_VISUAL_STYLES={
    ["Initiate Seal"]={scale=1.00,glow=.15},
    ["Verdant Mark"]={scale=1.00,glow=.16},
    ["Azure Sigil"]={scale=.98,glow=.19},
    ["Amethyst Crest"]={scale=1.00,glow=.17},
    ["Rose Emblem"]={scale=.98,glow=.18},
    ["Sunforged Seal"]={scale=.98,glow=.22},
    ["Crimson Legend"]={scale=.99,glow=.20},
    ["Legacy Chronicle"]={scale=1.00,glow=.22,special=true},
    ["Captain's Radiance"]={scale=1.00,glow=.23,special=true},
    ["Open Beta Crest"]={scale=.98,glow=.24,special=true},
    ["Moonlit Mark"]={scale=1.00,glow=.19},
    ["Dawnkeeper Seal"]={scale=1.00,glow=.20},
    ["Maplefall Seal"]={scale=1.00,glow=.21},
    ["Northstar Seal"]={scale=.97,glow=.22},
    ["Archivist Seal"]={scale=1.00,glow=.18},
    ["Wayglass Seal"]={scale=.99,glow=.18},
    ["Wolf Seal"]={scale=1.00,glow=.20},
}

local function RenderLevelBadge(parent,anchor,name,size,levelOverride,displayAlpha,frameLevelOffset)
    if not parent or not anchor then return nil end
    name=(name and COSMETICS.level.assets[name]) and name or "Initiate Seal"
    size=tonumber(size) or 64
    local asset=COSMETICS.level.assets[name]
    local unlockLevel=COSMETICS.level.levels[name] or tonumber(levelOverride) or 1
    local r,g,b=GetRenownLevelColor(unlockLevel)
    if name=="Legacy Chronicle" then r,g,b=0.82,0.58,1.00
    elseif name=="Captain's Radiance" then r,g,b=1.00,0.82,0.30
    elseif name=="Open Beta Crest" then r,g,b=0.66,0.50,1.00
    elseif name=="Moonlit Mark" then r,g,b=0.56,0.72,1.00
    elseif name=="Dawnkeeper Seal" then r,g,b=1.00,0.78,0.30
    elseif name=="Maplefall Seal" then r,g,b=1.00,0.60,0.18
    elseif name=="Northstar Seal" then r,g,b=0.50,0.72,1.00
    elseif name=="Archivist Seal" then r,g,b=0.76,0.58,0.96
    elseif name=="Wayglass Seal" then r,g,b=0.82,0.72,0.42
    elseif name=="Wolf Seal" then r,g,b=0.92,0.34,0.30 end
    displayAlpha=math.max(0,math.min(1,tonumber(displayAlpha) or 0.96))
    local style=BADGE_VISUAL_STYLES[name] or BADGE_VISUAL_STYLES["Initiate Seal"]

    local holder=anchor._rpalLevelBadgeHolder
    if type(holder)~="table" and type(holder)~="userdata" then holder=CreateFrame("Frame",nil,parent);anchor._rpalLevelBadgeHolder=holder else holder:SetParent(parent) end
    holder:ClearAllPoints();holder:SetSize(size,size);holder:SetPoint("CENTER",anchor,"CENTER",0,0);holder:EnableMouse(false)
    if parent.GetFrameLevel and holder.SetFrameLevel then holder:SetFrameLevel((parent:GetFrameLevel() or 0)+(tonumber(frameLevelOffset) or 8)) end

    local halo=holder._rpalHalo or holder:CreateTexture(nil,"BACKGROUND",nil,-2);holder._rpalHalo=halo
    halo:ClearAllPoints();halo:SetTexture(TEX.."Cosmetics\\aura_soft_ring.tga");halo:SetBlendMode("ADD");halo:SetSize(size*1.20,size*1.20);halo:SetPoint("CENTER");halo:SetVertexColor(r,g,b,.06+(displayAlpha*.10))

    -- Every badge now owns a purpose-built hollow line-art silhouette. No WoW
    -- icon, spell icon, portrait, or legacy glyph is composited into the middle.
    local shadow=holder._rpalShadow or holder:CreateTexture(nil,"ARTWORK",nil,0);holder._rpalShadow=shadow
    shadow:ClearAllPoints();shadow:SetTexture(asset);shadow:SetSize(size*(style.scale or 1),size*(style.scale or 1));shadow:SetPoint("CENTER",1,-1);shadow:SetVertexColor(.01,.01,.015,displayAlpha*.72);shadow:Show()

    local glow=holder._rpalStyle or holder:CreateTexture(nil,"ARTWORK",nil,1);holder._rpalStyle=glow
    glow:ClearAllPoints();glow:SetTexture(asset);glow:SetBlendMode("ADD");glow:SetSize(size*(style.scale or 1)*1.045,size*(style.scale or 1)*1.045);glow:SetPoint("CENTER");glow:SetVertexColor(r,g,b,(style.glow or .18)*displayAlpha);glow:Show()

    local art=holder._rpalArt or holder:CreateTexture(nil,"OVERLAY",nil,2);holder._rpalArt=art
    art:ClearAllPoints();art:SetTexture(asset);art:SetBlendMode("BLEND");art:SetSize(size*(style.scale or 1),size*(style.scale or 1));art:SetPoint("CENTER");art:SetVertexColor(r,g,b,displayAlpha);art:Show()

    -- Retire all old centre-icon layers permanently.
    local symbol=holder._rpalSymbol or holder:CreateTexture(nil,"OVERLAY",nil,3);holder._rpalSymbol=symbol;symbol:Hide()
    local ring=holder._rpalSpecialRing or holder:CreateTexture(nil,"OVERLAY",nil,4);holder._rpalSpecialRing=ring
    ring:ClearAllPoints();ring:SetTexture(TEX.."Cosmetics\\aura_soft_ring.tga");ring:SetBlendMode("ADD");ring:SetSize(size*1.28,size*1.28);ring:SetPoint("CENTER");ring:SetVertexColor(r,g,b,.16*displayAlpha)
    local spark=holder._rpalSpecialSpark or holder:CreateTexture(nil,"OVERLAY",nil,5);holder._rpalSpecialSpark=spark;spark:Hide()

    ring:SetShown(style.special and true or false)
    if style.special then
        holder._rpalBadgePhase=0
        holder:SetScript("OnUpdate",function(self,elapsed)
            self._rpalBadgePhase=(self._rpalBadgePhase or 0)+(elapsed or 0)
            local t=self._rpalBadgePhase
            local pulse=.72+((math.sin(t*2.15)+1)*.12)
            glow:SetAlpha(pulse)
            ring:SetAlpha(.44+((math.sin(t*1.45)+1)*.12))
        end)
    else
        holder:SetScript("OnUpdate",nil);glow:SetAlpha(1);ring:SetAlpha(1)
    end
    holder:Show();return holder
end
RPAL.RenderLevelBadge=RenderLevelBadge

local function RenderHeaderCosmetic(parent,name,tint,w,h,layout)
    if not parent then return nil end
    local existing=parent._rpalHeaderCosmetic
    if type(existing)~="table" and type(existing)~="userdata" then existing=nil end
    if not name or name=="None" then
        if existing then existing:Hide() end
        return nil
    end
    tint=tint or GOLD
    w=tonumber(w) or 430
    h=tonumber(h) or 108
    layout=layout or "banner"

    local asset=COSMETICS.header.assets[name]
    if not asset then
        if existing then existing:Hide() end
        return nil
    end
    local fullWidth=COSMETICS.header.fullWidth and COSMETICS.header.fullWidth[name]
    if layout~="masthead" and not fullWidth then asset=asset:gsub("%.tga$","_banner.tga") end
    -- Header choices select authored geometry, never a baked hue.
    local c=tint

    local holder=existing
    if not holder then
        holder=CreateFrame("Frame",nil,parent)
        parent._rpalHeaderCosmetic=holder
    elseif holder:GetParent()~=parent then
        holder:SetParent(parent)
    end

    local cornerType=type(holder.cornerTR)
    if cornerType~="table" and cornerType~="userdata" then
        holder.cornerTR=holder:CreateTexture(nil,"ARTWORK",nil,0)
        holder.cornerBL=holder:CreateTexture(nil,"ARTWORK",nil,0)
        holder.cornerTRGlow=holder:CreateTexture(nil,"ARTWORK",nil,1)
        holder.cornerBLGlow=holder:CreateTexture(nil,"ARTWORK",nil,1)
        holder.cornerTRGlow:SetBlendMode("ADD");holder.cornerBLGlow:SetBlendMode("ADD")
    end

    holder:ClearAllPoints()
    holder:SetSize(w,h)
    holder:SetClipsChildren(true)
    holder:EnableMouse(false)
    if parent.GetFrameLevel and holder.SetFrameLevel then
        holder:SetFrameLevel((parent:GetFrameLevel() or 0)+1)
    end

    if fullWidth then
        if not holder.fullTop then
            holder.fullTopGlow=holder:CreateTexture(nil,"ARTWORK",nil,0);holder.fullTopGlow:SetBlendMode("ADD")
            holder.fullBottomGlow=holder:CreateTexture(nil,"ARTWORK",nil,0);holder.fullBottomGlow:SetBlendMode("ADD")
            holder.fullTop=holder:CreateTexture(nil,"OVERLAY",nil,1)
            holder.fullBottom=holder:CreateTexture(nil,"OVERLAY",nil,1)
        end
        for _,texture in ipairs({holder.cornerTR,holder.cornerBL,holder.cornerTRGlow,holder.cornerBLGlow,holder._fallMarkL,holder._fallMarkR,holder._fallSpark,holder.fullArt,holder.fullGlow}) do if texture then texture:Hide() end end
        local topH=math.max(18,math.floor(h*0.31));local bottomH=math.max(16,math.floor(h*0.27))
        for _,texture in ipairs({holder.fullTopGlow,holder.fullTop}) do
            texture:ClearAllPoints();texture:SetTexture(asset);texture:SetPoint("TOPLEFT");texture:SetPoint("TOPRIGHT");texture:SetHeight(topH);texture:SetTexCoord(0,1,0,0.31);texture:SetVertexColor(c[1],c[2],c[3],texture==holder.fullTop and (name=="Heirloom Crest" and 0.92 or 0.84) or (name=="Heirloom Crest" and 0.16 or 0.09));texture:Show()
        end
        for _,texture in ipairs({holder.fullBottomGlow,holder.fullBottom}) do
            texture:ClearAllPoints();texture:SetTexture(asset);texture:SetPoint("BOTTOMLEFT");texture:SetPoint("BOTTOMRIGHT");texture:SetHeight(bottomH);texture:SetTexCoord(0,1,0.73,1);texture:SetVertexColor(c[1],c[2],c[3],texture==holder.fullBottom and (name=="Heirloom Crest" and 0.92 or 0.84) or (name=="Heirloom Crest" and 0.16 or 0.09));texture:Show()
        end
        holder:Show();return holder
    else
        for _,texture in ipairs({holder.fullArt,holder.fullGlow,holder.fullTop,holder.fullBottom,holder.fullTopGlow,holder.fullBottomGlow}) do if texture then texture:Hide() end end
    end

    -- Header cosmetics are diagonal signatures only: the authored lower-left
    -- half remains in its canonical orientation, while the lower-right half is
    -- vertically mirrored into the upper-right. Nothing crosses the identity
    -- text, Glance sockets or Level Badge pocket.
    local masthead=layout=="masthead"
    local artAlpha=masthead and 0.92 or 0.84
    local glowAlpha=masthead and 0.10 or 0.075
    local cornerH=math.max(28,math.floor(h*0.46))
    local cornerW=math.min(math.floor(w*0.32),cornerH*2)

    for _,texture in ipairs({holder.cornerTR,holder.cornerBL,holder.cornerTRGlow,holder.cornerBLGlow}) do
        texture:SetTexture(asset)
        texture:ClearAllPoints()
    end
    holder.cornerTR:SetSize(cornerW,cornerH);holder.cornerTR:SetPoint("TOPRIGHT",0,0)
    holder.cornerTRGlow:SetAllPoints(holder.cornerTR)
    holder.cornerBL:SetSize(cornerW,cornerH);holder.cornerBL:SetPoint("BOTTOMLEFT",0,0)
    holder.cornerBLGlow:SetAllPoints(holder.cornerBL)
    holder.cornerTR:SetTexCoord(0.5,1,1,0);holder.cornerTRGlow:SetTexCoord(0.5,1,1,0)
    holder.cornerBL:SetTexCoord(0,0.5,0,1);holder.cornerBLGlow:SetTexCoord(0,0.5,0,1)
    holder.cornerTR:SetVertexColor(c[1],c[2],c[3],artAlpha);holder.cornerBL:SetVertexColor(c[1],c[2],c[3],artAlpha)
    holder.cornerTRGlow:SetVertexColor(c[1],c[2],c[3],glowAlpha);holder.cornerBLGlow:SetVertexColor(c[1],c[2],c[3],glowAlpha)
    holder.cornerTR:Show();holder.cornerBL:Show();holder.cornerTRGlow:Show();holder.cornerBLGlow:Show()

    -- Hide the opposite diagonal from an in-session 1.3.173 preview build.
    for _,legacy in ipairs({holder.cornerTL,holder.cornerBR,holder.cornerTLGlow,holder.cornerBRGlow}) do
        local legacyType=type(legacy)
        if (legacyType=="table" or legacyType=="userdata") and legacy.Hide then legacy:Hide() end
    end

    for _,legacy in ipairs({holder.canopyShadow,holder.canopyArt,holder.canopyGlow}) do
        local legacyType=type(legacy)
        if (legacyType=="table" or legacyType=="userdata") and legacy.Hide then legacy:Hide() end
    end

    -- Hide three-slice regions left by a live 1.3.171 holder.
    local oldSlices=type(holder.slices)=="table" and holder.slices or {}
    for _,slice in ipairs(oldSlices) do
        for _,texture in pairs(slice) do if texture and texture.Hide then texture:Hide() end end
    end

    -- Hide obsolete regions if a live holder predates this renderer.
    for _,legacy in ipairs({holder.shadow,holder.art,holder.bottomShadow,holder.bottomArt,holder.bevel,holder.starlight}) do
        local legacyType=type(legacy)
        if (legacyType=="table" or legacyType=="userdata") and legacy.Hide then legacy:Hide() end
    end
    local legacyPieces=type(holder.pieces)=="table" and holder.pieces or {}
    for _,piece in ipairs(legacyPieces) do
        for _,texture in pairs(piece) do if texture and texture.Hide then texture:Hide() end end
    end

    holder:Show()

    return holder
end

local function RenderProfileAura(parent,anchor,name,tint,size)
    if not parent or not anchor or not name or name=="None" then return nil end
    local def=PROFILE_AURAS.styles[name]
    if not def then return nil end
    size=tonumber(size) or 142
    tint=tint or GOLD
    local c=def.color or tint
    local s=def.secondary or tint
    local tier=tonumber(def.tier) or 1

    local holder=CreateFrame("Frame",nil,parent)
    holder:SetSize(size*1.34,size*1.34)
    holder:SetPoint("CENTER",anchor,"CENTER",0,0)
    holder:SetClipsChildren(false)
    if parent.GetFrameLevel and holder.SetFrameLevel then
        holder:SetFrameLevel((parent:GetFrameLevel() or 0)+1)
    end

    local rings={}
    local ringCount=math.max(1,tonumber(def.rings) or 1)
    for i=1,ringCount do
        local ring=holder:CreateTexture(nil,i==1 and "BACKGROUND" or "ARTWORK",nil,math.min(3,i-1))
        ring:SetTexture(TEX.."Cosmetics\\aura_soft_ring.tga")
        ring:SetBlendMode("ADD")
        local scale=1.02+((i-1)*0.105)+(tier*0.025)
        ring:SetSize(size*scale,size*scale)
        ring:SetPoint("CENTER")
        local rc=(i%2==0) and s or c
        ring:SetVertexColor(rc[1],rc[2],rc[3],0.18+(tier*0.025))
        rings[#rings+1]=ring
    end

    local particles={}
    local particleCount=tonumber(def.particles) or 0
    for i=1,particleCount do
        local particle=holder:CreateTexture(nil,"OVERLAY",nil,3)
        particle:SetTexture(TEX.."Talent\\sparkle.tga")
        particle:SetBlendMode("ADD")
        local base=6+(tier*1.5)+(i%3)
        particle:SetSize(base,base)
        local pc=(i%3==0) and s or c
        particle:SetVertexColor(pc[1],pc[2],pc[3],0.76)
        particles[#particles+1]=particle
    end

    -- High-tier crown rays are thin pieces of light, never opaque panels.
    local rays={}
    if tier>=3 then
        local rayCount=math.min(12,4+tier*2)
        for i=1,rayCount do
            local ray=holder:CreateTexture(nil,"OVERLAY",nil,2)
            ray:SetTexture("Interface\\Buttons\\WHITE8X8")
            ray:SetBlendMode("ADD")
            ray:SetSize(1.5,10+(tier*3))
            ray:SetPoint("CENTER")
            local rc=(i%2==0) and s or c
            ray:SetVertexColor(rc[1],rc[2],rc[3],0.34)
            rays[#rays+1]=ray
        end
    end

    local phase=0
    holder:SetScript("OnUpdate",function(self,elapsed)
        if not self:IsShown() then return end
        phase=phase+(elapsed or 0)
        local speed=tonumber(def.speed) or 1
        local pulse=(math.sin(phase*speed*2)+1)*0.5

        for i,ring in ipairs(rings) do
            local stagger=(math.sin(phase*speed*(0.72+i*0.08)+i*1.1)+1)*0.5
            ring:SetAlpha(0.20+(stagger*(0.12+tier*0.035)))
            if ring.SetRotation and (def.mode=="singularity" or def.mode=="crown" or def.mode=="choir" or def.mode=="sunwell") then
                local direction=(i%2==0) and -1 or 1
                ring:SetRotation(direction*phase*speed*(0.16+i*0.035))
            end
        end

        local radius=size*(0.50+(tier*0.035))
        for i,particle in ipairs(particles) do
            local offset=(i-1)*(math.pi*2/math.max(1,#particles))
            local a=(phase*speed)+offset
            local r=radius
            local x,y
            if def.mode=="veil" then
                x=math.sin(a*0.82)*radius*0.86
                y=math.cos(a*0.54)*radius*0.70
            elseif def.mode=="flicker" then
                r=radius*(0.88+0.10*math.sin(phase*3+i))
                x=math.cos(a)*r;y=math.sin(a)*r
            elseif def.mode=="reverie" then
                r=radius*(0.92+0.12*math.sin(phase*0.8+i*0.6))
                x=math.cos(a)*r;y=math.sin(a*0.72)*r
            elseif def.mode=="eclipse" then
                x=math.cos(a)*radius;y=math.sin(a)*radius*0.64
            elseif def.mode=="crown" then
                local crownA=math.pi*0.18+((i-1)/math.max(1,#particles-1))*math.pi*0.64
                x=math.cos(crownA)*radius*0.92;y=math.sin(crownA)*radius*0.76+size*0.10
            elseif def.mode=="singularity" then
                local spiral=radius*(0.72+0.25*math.sin(phase*0.35+i))
                x=math.cos(a*1.28)*spiral;y=math.sin(a*1.28)*spiral
            elseif def.mode=="choir" then
                local ringBand=(i%2==0) and radius or radius*0.76
                x=math.cos(a*((i%2==0) and 1 or -0.72))*ringBand
                y=math.sin(a*((i%2==0) and 1 or -0.72))*ringBand
            else
                x=math.cos(a)*r;y=math.sin(a)*r
            end
            particle:ClearAllPoints();particle:SetPoint("CENTER",holder,"CENTER",x,y)
            particle:SetAlpha(0.24+((math.sin(phase*(1.4+tier*0.08)+i)+1)*0.26)+(tier*0.025))
        end

        if #rays>0 then
            local rr=size*(0.55+tier*0.02)
            for i,ray in ipairs(rays) do
                local a=(i-1)*(math.pi*2/#rays)+(def.mode=="singularity" and phase*0.14 or 0)
                ray:ClearAllPoints()
                ray:SetPoint("CENTER",holder,"CENTER",math.cos(a)*rr,math.sin(a)*rr)
                ray:SetRotation(a)
                ray:SetAlpha((0.16+(pulse*0.18))*(0.72+(i%3)*0.08))
            end
        end
    end)

    return holder
end

local function RenderUIOrnament(parent,name,tint,inset)
    if not parent or not name then return nil end
    local def=UI_ORNAMENTS.styles[name]
    if not def then return nil end

    tint=tint or GOLD
    inset=tonumber(inset) or 8
    -- Ornament names choose line geometry only. The viewed character's active
    -- theme owns the hue in the ledger, previews, inspect and combat.
    local c=tint
    local width=(parent.GetWidth and parent:GetWidth()) or 900
    local height=(parent.GetHeight and parent:GetHeight()) or 600
    if not width or width<=0 then width=900 end
    if not height or height<=0 then height=600 end
    local compact=width<320 or height<150
    local deskScale=width>=1000 and height>=650

    local holder=parent._rpalUIOrnament
    if type(holder)~="table" and type(holder)~="userdata" then holder=nil end
    if not holder then
        holder=CreateFrame("Frame",nil,parent)
        parent._rpalUIOrnament=holder
        holder.corners={}
        holder.rails={}

        for index=1,4 do
            local corner={}
            corner.shadow=holder:CreateTexture(nil,"ARTWORK",nil,0)
            corner.shadow:SetBlendMode("BLEND")
            corner.art=holder:CreateTexture(nil,"ARTWORK",nil,1)
            corner.art:SetBlendMode("BLEND")
            holder.corners[index]=corner
        end

        for index=1,8 do
            local rail=holder:CreateTexture(nil,"ARTWORK",nil,index<=4 and 0 or -1)
            rail:SetTexture("Interface\\Buttons\\WHITE8X8")
            rail:SetBlendMode("BLEND")
            holder.rails[index]=rail
        end
    elseif holder:GetParent()~=parent then
        holder:SetParent(parent)
    end

    holder:ClearAllPoints()
    holder:SetAllPoints(parent)
    holder:SetClipsChildren(true)
    holder:EnableMouse(false)
    if parent.GetFrameLevel and holder.SetFrameLevel then
        -- BookChrome's side panels sit several levels above the main shell.
        -- Lift the mouse-transparent art past those opaque surfaces; its
        -- pixels remain confined to the edge and close controls stay higher.
        holder:SetFrameLevel((parent:GetFrameLevel() or 0)+(compact and 6 or 10))
    end
    if parent.SetBackdropBorderColor then
        parent:SetBackdropBorderColor(c[1],c[2],c[3],compact and 0.30 or 0.42)
    end

    local cornerAsset=def.corner or TEX.."Cosmetics\\ornament_classic_corner.tga"
    local cornerSize=compact and math.max(27,math.min(34,height*0.39)) or (deskScale and 58 or math.max(46,math.min(58,height*0.095)))
    local cornerAlpha=compact and 0.64 or (deskScale and 0.78 or 0.74)
    -- Every extracted SVG corner is authored in BOTTOMLEFT orientation: its
    -- two arms travel up and right from the joint.  Mirror that canonical art
    -- on the texture axes instead of rotating it around the texture centre.
    -- Runtime rotation moved the joint to the wrong edge and made the right
    -- corners point out of the ledger in live UI and collection previews.
    local cornerDefs={
        {"TOPLEFT",inset-2,-inset+2,0,1,1,0},
        {"TOPRIGHT",-inset+2,-inset+2,1,0,1,0},
        {"BOTTOMRIGHT",-inset+2,inset-2,1,0,0,1},
        {"BOTTOMLEFT",inset-2,inset-2,0,1,0,1},
    }

    for index,piece in ipairs(cornerDefs) do
        local corner=holder.corners[index]
        for _,texture in ipairs({corner.shadow,corner.art}) do
            texture:SetTexture(cornerAsset)
            texture:SetSize(cornerSize,cornerSize)
            texture:ClearAllPoints()
            if texture.SetRotation then texture:SetRotation(0) end
            texture:SetTexCoord(piece[4],piece[5],piece[6],piece[7])
        end
        corner.shadow:SetPoint(piece[1],piece[2]+1,piece[3]-2)
        corner.shadow:SetVertexColor(0.01,0.015,0.025,0.50)
        corner.art:SetPoint(piece[1],piece[2],piece[3])
        corner.art:SetVertexColor(c[1],c[2],c[3],cornerAlpha)
        corner.shadow:Show();corner.art:Show()
    end

    -- Straight rails complete the purchased corner art without stretching its
    -- motifs.  A faint inner rule adds book-binding depth while keeping the
    -- entire reading surface untouched.
    local edge=inset-1
    local railStart=inset+math.floor(cornerSize*0.72)
    local innerStart=inset+math.floor(cornerSize*0.86)
    local outerAlpha=compact and 0.24 or 0.38
    local innerAlpha=compact and 0.08 or 0.13
    for index,rail in ipairs(holder.rails or {}) do
        rail:ClearAllPoints()
        local inner=index>4
        local side=((index-1)%4)+1
        local offset=edge+(inner and 3 or 0)
        local start=inner and innerStart or railStart
        if side==1 then
            rail:SetHeight(inner and 1 or 1.25)
            rail:SetPoint("TOPLEFT",holder,"TOPLEFT",start,-offset)
            rail:SetPoint("TOPRIGHT",holder,"TOPRIGHT",-start,-offset)
        elseif side==2 then
            rail:SetWidth(inner and 1 or 1.25)
            rail:SetPoint("TOPRIGHT",holder,"TOPRIGHT",-offset,-start)
            rail:SetPoint("BOTTOMRIGHT",holder,"BOTTOMRIGHT",-offset,start)
        elseif side==3 then
            rail:SetHeight(inner and 1 or 1.25)
            rail:SetPoint("BOTTOMLEFT",holder,"BOTTOMLEFT",start,offset)
            rail:SetPoint("BOTTOMRIGHT",holder,"BOTTOMRIGHT",-start,offset)
        else
            rail:SetWidth(inner and 1 or 1.25)
            rail:SetPoint("TOPLEFT",holder,"TOPLEFT",offset,-start)
            rail:SetPoint("BOTTOMLEFT",holder,"BOTTOMLEFT",offset,start)
        end
        rail:SetVertexColor(c[1],c[2],c[3],inner and innerAlpha or outerAlpha)
        rail:Show()
    end

    -- Older live holders may still contain the retired axial seals during a
    -- soft UI refresh. Hide them explicitly so the new frame remains clean.
    if type(holder.seals)=="table" then
        for _,seal in ipairs(holder.seals) do
            for _,texture in pairs(seal) do
                if texture and texture.Hide then texture:Hide() end
            end
        end
    end

    -- Keep the live holder theme-aware.  RenderUIOrnament is intentionally
    -- reusable, so a palette switch must recolor the existing textures rather
    -- than wait for a cosmetic re-equip or /reload to rebuild them.
    holder._rpalCornerAlpha=cornerAlpha
    holder._rpalOuterRailAlpha=outerAlpha
    holder._rpalInnerRailAlpha=innerAlpha
    holder._rpalApplyTheme=function(self,newTint)
        newTint=newTint or GOLD
        self._rpalThemeTint={newTint[1],newTint[2],newTint[3],newTint[4] or 1}
        for _,corner in ipairs(self.corners or {}) do
            if corner.art then
                corner.art:SetVertexColor(newTint[1],newTint[2],newTint[3],self._rpalCornerAlpha or 0.74)
            end
        end
        for index,rail in ipairs(self.rails or {}) do
            rail:SetVertexColor(newTint[1],newTint[2],newTint[3],index>4 and (self._rpalInnerRailAlpha or 0.13) or (self._rpalOuterRailAlpha or 0.38))
        end
        local ornamentParent=self:GetParent()
        if ornamentParent and ornamentParent.SetBackdropBorderColor then
            ornamentParent:SetBackdropBorderColor(newTint[1],newTint[2],newTint[3],compact and 0.30 or 0.42)
        end
    end
    holder:_rpalApplyTheme(c)

    holder:Show()

    return holder
end


RPAL.RenderUIOrnament = RenderUIOrnament

local function SetColor(dst,src)
    for i=1,4 do dst[i]=src[i] or dst[i] or 1 end
end

local function LiftDarkColor(color,amount)
    amount=amount or 0.016
    if not color then return end
    color[1]=math.min(1,(color[1] or 0)+amount)
    color[2]=math.min(1,(color[2] or 0)+amount)
    color[3]=math.min(1,(color[3] or 0)+amount)
end

local function ApplyThemePalette(name)
    local theme=THEMES[name] or THEMES["Obsidian Crown"]
    SetColor(GOLD,theme.gold); SetColor(PALE,theme.pale); SetColor(MUTED,theme.muted)
    SetColor(PANEL,theme.panel); SetColor(PANEL2,theme.panel2); SetColor(BORDER,theme.border)
    SetColor(MAIN_BG,theme.main); SetColor(PAGE_BG,theme.page); SetColor(PAGE_BG_ALT,theme.pageAlt)
    SetColor(BUTTON_BG,theme.button); SetColor(BUTTON_HOVER,theme.buttonHover)
    SetColor(ACCENT_BG,theme.accent); SetColor(ACCENT_HOVER,theme.accentHover)

    -- Keep every palette dark, but lift crushed near-black surfaces slightly.
    LiftDarkColor(MAIN_BG,0.018)
    LiftDarkColor(PAGE_BG,0.018)
    LiftDarkColor(PAGE_BG_ALT,0.016)
    LiftDarkColor(PANEL,0.016)
    LiftDarkColor(PANEL2,0.014)
    LiftDarkColor(BUTTON_BG,0.012)
end

local function ApplyAutomaticClassTheme()
    local appearance=RPAL.GetCharacterThemeSelection and RPAL.GetCharacterThemeSelection() or {}
    if appearance.themeManualOverride then
        ApplyThemePalette(appearance.themeName or "Obsidian Crown")
        return false
    end

    ApplyThemePalette("Obsidian Crown")

    local p=RPAL.char and RPAL.char.profile
    local c=p and p.classColor
    if not c or c.r==nil or c.g==nil or c.b==nil then return false end

    local r=math.max(0,math.min(1,tonumber(c.r) or 0.8))
    local g=math.max(0,math.min(1,tonumber(c.g) or 0.65))
    local b=math.max(0,math.min(1,tonumber(c.b) or 0.3))

    SetColor(GOLD,{r,g,b,1})
    SetColor(ACCENT_BG,{r*0.72,g*0.72,b*0.72,1})
    SetColor(ACCENT_HOVER,{
        math.min(1,r*0.92+0.10),
        math.min(1,g*0.92+0.10),
        math.min(1,b*0.92+0.10),
        1
    })
    SetColor(BORDER,{
        math.max(0.16,r*0.60),
        math.max(0.16,g*0.60),
        math.max(0.16,b*0.60),
        1
    })
    return true
end

local function ApplyCurrentTheme()
    local appearance=RPAL.GetCharacterThemeSelection and RPAL.GetCharacterThemeSelection() or {}
    if appearance.themeManualOverride then
        ApplyThemePalette(appearance.themeName or "Obsidian Crown")
    else
        ApplyAutomaticClassTheme()
    end
end


function RPAL.GetCurrentAccentColor()
    return GOLD[1],GOLD[2],GOLD[3],GOLD[4] or 1
end

function RPAL.GetThemePaletteByName(name, classHex)
    local function Copy(c) return {c[1] or 0,c[2] or 0,c[3] or 0,c[4] or 1} end
    local function Lift(c,amount)
        c[1]=math.min(1,(c[1] or 0)+amount);c[2]=math.min(1,(c[2] or 0)+amount);c[3]=math.min(1,(c[3] or 0)+amount)
    end
    local t=THEMES[name] or THEMES["Obsidian Crown"]
    local out={gold=Copy(t.gold),pale=Copy(t.pale),muted=Copy(t.muted),panel=Copy(t.panel),panel2=Copy(t.panel2),border=Copy(t.border),main=Copy(t.main),page=Copy(t.page),pageAlt=Copy(t.pageAlt),button=Copy(t.button),buttonHover=Copy(t.buttonHover),accent=Copy(t.accent),accentHover=Copy(t.accentHover)}
    if name=="Class Specific" and classHex then
        local h=tostring(classHex):gsub("#","")
        local r,g,b=tonumber(h:sub(1,2),16),tonumber(h:sub(3,4),16),tonumber(h:sub(5,6),16)
        if #h==6 and r and g and b then
            r=r/255;g=g/255;b=b/255
            out.gold={r,g,b,1}; out.accent={r*.72,g*.72,b*.72,1}; out.accentHover={math.min(1,r*.92+.10),math.min(1,g*.92+.10),math.min(1,b*.92+.10),1}
            out.border={math.max(.16,r*.60),math.max(.16,g*.60),math.max(.16,b*.60),1}
        end
    end
    Lift(out.main,0.018);Lift(out.page,0.018);Lift(out.pageAlt,0.016)
    Lift(out.panel,0.016);Lift(out.panel2,0.014);Lift(out.button,0.012)
    return out
end

function RPAL.GetCurrentThemePalette()
    local function Copy(c)
        return {c[1] or 0,c[2] or 0,c[3] or 0,c[4] or 1}
    end
    return {
        gold=Copy(GOLD),pale=Copy(PALE),muted=Copy(MUTED),
        panel=Copy(PANEL),panel2=Copy(PANEL2),border=Copy(BORDER),
        main=Copy(MAIN_BG),page=Copy(PAGE_BG),pageAlt=Copy(PAGE_BG_ALT),
        button=Copy(BUTTON_BG),buttonHover=Copy(BUTTON_HOVER),
        accent=Copy(ACCENT_BG),accentHover=Copy(ACCENT_HOVER),
    }
end

-- Public inspect is rendered inside the inspected character's complete
-- palette, then the viewer's palette is restored immediately.  Keeping this
-- scoped avoids the old half-local/half-remote look without recolouring the
-- main ledger behind the inspect modal.
function RPAL.WithThemePalette(palette,callback)
    if type(callback)~="function" then return end
    if type(palette)~="table" then return callback() end
    local previous=RPAL.GetCurrentThemePalette()
    local function Apply(source)
        if type(source)~="table" then return end
        SetColor(GOLD,source.gold or GOLD);SetColor(PALE,source.pale or PALE);SetColor(MUTED,source.muted or MUTED)
        SetColor(PANEL,source.panel or PANEL);SetColor(PANEL2,source.panel2 or PANEL2);SetColor(BORDER,source.border or BORDER)
        SetColor(MAIN_BG,source.main or MAIN_BG);SetColor(PAGE_BG,source.page or PAGE_BG);SetColor(PAGE_BG_ALT,source.pageAlt or PAGE_BG_ALT)
        SetColor(BUTTON_BG,source.button or BUTTON_BG);SetColor(BUTTON_HOVER,source.buttonHover or BUTTON_HOVER)
        SetColor(ACCENT_BG,source.accent or ACCENT_BG);SetColor(ACCENT_HOVER,source.accentHover or ACCENT_HOVER)
    end
    Apply(palette)
    local ok,a,b,c=xpcall(callback,function(err)
        if debugstack then return tostring(err).."\n"..tostring(debugstack(2,20,20)) end
        return tostring(err)
    end)
    Apply(previous)
    if not ok then error(a,0) end
    return a,b,c
end

RPAL.RenderCombatPortraitCosmetic = RenderPortraitCosmetic
RPAL.RenderCombatHeaderCosmetic = RenderHeaderCosmetic
RPAL.RenderCombatProfileAura = RenderProfileAura
RPAL.RenderLevelBadge = RenderLevelBadge

local UI = {}
RPAL.UI = UI
local H={}
local Modal
local rpalModalStack={}
local rpalModalSerial=0
local editMode = false
local currentCategoryEditor, currentBadgeEditor
local activeBadgeEditorShade=nil
local activeMemoirDetailShade=nil
local activeShareView=nil
local ledgerMode=false
local skillSetMode=false
local ForceThemeFrame
local BuildMain
local mainFrameSerial=0

local SOCIAL_PREFIX="RPAL_SYNC"
local SOCIAL_PROTOCOL=10
local SOCIAL_PRESENCE_TTL=100
local socialPresence={}
local socialRequests={}
-- Forward declaration: SelectInspectSnapshot is defined before the social communication
-- helpers later in this file, but it must close over the same canonical name normalizer.
-- Without this declaration Lua resolves SocialNormalizeName as a global at definition time,
-- which is nil and causes the inspect window to fail immediately.
local SocialNormalizeName
local remoteInspectWindow,remoteInspectSender,remoteInspectRequestID

function RPAL.IsODYNInspectOpen()
    if remoteInspectWindow and remoteInspectWindow.IsShown and remoteInspectWindow:IsShown() then return true end
    if UI.remoteHealthWindow and UI.remoteHealthWindow.IsShown and UI.remoteHealthWindow:IsShown() then return true end
    return false
end
local remoteInspectPage="statistics"

function H.InspectTableCount(value,predicate)
    local count=0
    for key,item in pairs(type(value)=="table" and value or {}) do
        if not predicate or predicate(item,key) then count=count+1 end
    end
    return count
end

function H.InspectArrayComplete(value,expected,predicate)
    expected=tonumber(expected)
    if expected==nil then return false end
    for index=1,expected do
        local item=type(value)=="table" and value[index]
        if item==nil or (predicate and not predicate(item,index)) then return false end
    end
    return true
end

-- A transfer is publishable only when the final marker and its advertised
-- records arrived. Until then the renderer keeps the previous cached snapshot.
function H.IsInspectRequestComplete(request)
    if type(request)~="table" then return false,{"request"} end
    if request.private then return true,{} end
    local missing={}
    local function Need(condition,label)if not condition then missing[#missing+1]=label end end
    Need(request.finished==true,"final")
    Need(request.headerBaseReceived==true and type(request.header)=="table","header")
    if request.intent=="health" then
        Need(request.injuryItemExpected~=nil and request.injuryGroupExpected~=nil and request.injuryHistoryExpected~=nil,"health-manifest")
        Need(H.InspectTableCount(request.injuries and request.injuries.items)>=math.max(0,tonumber(request.injuryItemExpected)or 0),"health-items")
        Need(H.InspectTableCount(request.injuries and request.injuries.items,function(item)return item and item._detailsReceived end)>=math.max(0,tonumber(request.injuryItemExpected)or 0),"health-details")
        Need(H.InspectTableCount(request.injuries and request.injuries.groups)>=math.max(0,tonumber(request.injuryGroupExpected)or 0),"health-groups")
        Need(H.InspectArrayComplete(request.injuries and request.injuries.history,tonumber(request.injuryHistoryExpected)or 0),"health-history")
    else
        local header=request.header or {}
        Need(request.infoPacketReceived==true,"information")
        Need(request.cosmeticsPacketReceived==true,"appearance")
        Need(request.statisticsPacketReceived==true,"statistics")
        Need(request.skillSetReceived==true,"skills")
        Need(H.InspectArrayComplete(request.achievements,tonumber(header.achievementCompleted)or 0,function(item)
            return type(item)=="table" and item.name~=nil and item.icon~=nil and item.description~=nil
        end),"achievements")
        Need(request.memoirExpected~=nil and H.InspectTableCount(request.memoirs)>=math.max(0,tonumber(request.memoirExpected)or 0),"memoirs")
        Need(H.InspectArrayComplete(request.pinnedMoments,request.momentExpected,function(item)
            return type(item)=="table" and item.title~=nil
                and H.InspectArrayComplete(item.locations,item.locationExpected or 0)
                and H.InspectArrayComplete(item.peopleDetails,item.peopleExpected or 0)
        end),"moments")
        Need(H.InspectArrayComplete(request.pinnedStories,request.storyExpected,function(story)
            return type(story)=="table" and story.title~=nil
                and H.InspectArrayComplete(story.stepOrder,story.stepExpected or 0,function(stepID)return story.steps and story.steps[stepID]~=nil end)
                and H.InspectArrayComplete(story.links,story.linkExpected or 0)
        end),"stories")
        if (tonumber(request.snapshotProtocol)or 0)>=6 then
            Need(request.additionalFieldExpected~=nil and H.InspectTableCount(request.additionalFields,function(field)return field and field.label and field._valueComplete end)>=math.max(0,tonumber(request.additionalFieldExpected)or 0),"additional-fields")
            Need(request.glanceExpected~=nil and H.InspectTableCount(request.glances,function(glance)return glance and glance._headerReceived and glance._iconReceived and glance._descriptionComplete end)>=math.max(0,tonumber(request.glanceExpected)or 0),"glances")
        end
    end
    return #missing==0,missing
end
RPAL.IsInspectRequestComplete=H.IsInspectRequestComplete

function H.SelectInspectSnapshot(sender,requestID,intent)
    local active
    -- IMPORTANT: inspect requests are keyed with SocialNormalizeName(), which
    -- appends the local realm when WoW gives us a same-realm short name. The
    -- renderer previously used a weaker lowercase-only key here. On same-realm
    -- targets the network transfer could be arriving correctly while the inspect
    -- window looked in a different bucket forever and stayed on
    -- "REQUESTING LIVE SNAPSHOT…". Use the exact same canonical key on every
    -- side of the request/receive/render path.
    local senderKey=SocialNormalizeName(sender)
    local bucket=socialRequests[senderKey]
    if bucket and requestID then active=bucket[requestID] end
    if active and active.private then return active,false,nil,active end
    local complete=active and H.IsInspectRequestComplete(active)
    if complete then return active,false,0,active end
    local cached,cachedAge=RPAL.GetInspectSnapshot and RPAL.GetInspectSnapshot(senderKey,intent,0)
    if cached then return cached,true,cachedAge,active end
    return active,false,nil,active
end

local modalSerial=0
local trpTicker=nil
local lastTRPSignature=nil

local function GetUIScaleSetting()
    if RPAL.db and RPAL.GetSettings then
        return math.max(0.70,math.min(1.35,tonumber(RPAL.GetSettings().uiScale) or 1))
    end
    return 1
end

local function ThemeColorRole(color)
    if color==GOLD then return "GOLD"
    elseif color==PALE then return "PALE"
    elseif color==MUTED then return "MUTED"
    elseif color==PANEL then return "PANEL"
    elseif color==PANEL2 then return "PANEL2"
    elseif color==BORDER then return "BORDER"
    elseif color==MAIN_BG then return "MAIN_BG"
    elseif color==PAGE_BG then return "PAGE_BG"
    elseif color==PAGE_BG_ALT then return "PAGE_BG_ALT"
    elseif color==BUTTON_BG then return "BUTTON_BG"
    elseif color==BUTTON_HOVER then return "BUTTON_HOVER"
    elseif color==ACCENT_BG then return "ACCENT_BG"
    elseif color==ACCENT_HOVER then return "ACCENT_HOVER"
    end
end

local function ThemeColorByRole(role)
    if role=="GOLD" then return GOLD
    elseif role=="PALE" then return PALE
    elseif role=="MUTED" then return MUTED
    elseif role=="PANEL" then return PANEL
    elseif role=="PANEL2" then return PANEL2
    elseif role=="BORDER" then return BORDER
    elseif role=="MAIN_BG" then return MAIN_BG
    elseif role=="PAGE_BG" then return PAGE_BG
    elseif role=="PAGE_BG_ALT" then return PAGE_BG_ALT
    elseif role=="BUTTON_BG" then return BUTTON_BG
    elseif role=="BUTTON_HOVER" then return BUTTON_HOVER
    elseif role=="ACCENT_BG" then return ACCENT_BG
    elseif role=="ACCENT_HOVER" then return ACCENT_HOVER
    end
end

local function Backdrop(frame, bg, border)
    if not frame or type(frame.SetBackdrop)~="function" then return frame end
    -- A textured WoW-style edge immediately feels less prototype-like than a
    -- raw 1px rectangle, while the solid fill keeps every theme predictable.
    frame:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8X8",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        tile = false,
        tileSize = 16,
        edgeSize = 9,
        insets = {left=2,right=2,top=2,bottom=2},
    })
    bg=bg or PANEL
    border=border or BORDER
    frame:SetBackdropColor(unpack(bg))
    frame:SetBackdropBorderColor(unpack(border))
    frame._rpalBgRole=ThemeColorRole(bg)
    frame._rpalBorderRole=ThemeColorRole(border)

    -- Reusable top/bottom micro-highlights.  The texture alpha remains fixed;
    -- the vertex tint follows the current theme through _rpalDecorRole.
    local w,h=frame:GetSize()
    if (tonumber(w) or 0)>=58 and (tonumber(h) or 0)>=24 and not frame._rpalSurfaceSheen then
        local top=frame:CreateTexture(nil,"BORDER",nil,2)
        top:SetTexture("Interface\\Buttons\\WHITE8X8")
        top:SetColorTexture(1,1,1,0.10)
        top:SetPoint("TOPLEFT",5,-4)
        top:SetPoint("TOPRIGHT",-5,-4)
        top:SetHeight(1)
        top._rpalDecorRole="ACCENT"
        frame._rpalSurfaceSheen=top

        local bottom=frame:CreateTexture(nil,"BORDER",nil,1)
        bottom:SetTexture("Interface\\Buttons\\WHITE8X8")
        bottom:SetColorTexture(0,0,0,0.24)
        bottom:SetPoint("BOTTOMLEFT",5,4)
        bottom:SetPoint("BOTTOMRIGHT",-5,4)
        bottom:SetHeight(1)
        frame._rpalSurfaceShade=bottom
    end
end

local function AddBookDecoration(frame)
    if not frame or frame._rpalBookDecorated then return end
    frame._rpalBookDecorated=true

    local leather=frame:CreateTexture(nil,"BACKGROUND",nil,-8)
    leather:SetTexture(ASSET.BOOK)
    leather:SetAllPoints(frame)
    leather:SetAlpha(0.48)
    pcall(function()leather:SetDesaturated(true)end)
    leather._rpalDecorRole="MAIN"
    frame._rpalLeather=leather

    -- Soft vignette around the book edge gives the window depth without
    -- introducing another opaque panel.
    local vignette=frame:CreateTexture(nil,"BACKGROUND",nil,-7)
    vignette:SetTexture("Interface\\Buttons\\WHITE8X8")
    vignette:SetPoint("TOPLEFT",4,-4)
    vignette:SetPoint("BOTTOMRIGHT",-4,4)
    vignette:SetVertexColor(0,0,0,0.13)
    frame._rpalVignette=vignette

    local spine=frame:CreateTexture(nil,"BACKGROUND",nil,-5)
    frame._rpalSpine=spine
    spine:SetTexture(ASSET.SPINE)
    spine:SetPoint("TOPLEFT",5,-7)
    spine:SetPoint("BOTTOMLEFT",5,7)
    spine:SetWidth(20)
    spine:SetAlpha(0.58)
    pcall(function()spine:SetDesaturated(true)end)
    spine._rpalDecorRole="ACCENT"

    -- Bring the existing corner asset back *inside* the frame rather than
    -- protruding outside it.  This reads like a finished bound ledger.
    local cornerData={
        {"TOPLEFT",7,-7,0,1,0,1},
        {"TOPRIGHT",-7,-7,1,0,0,1},
        {"BOTTOMLEFT",7,7,0,1,1,0},
        {"BOTTOMRIGHT",-7,7,1,0,1,0},
    }
    frame._rpalBookCorners=frame._rpalBookCorners or {}
    for _,d in ipairs(cornerData) do
        local c=frame:CreateTexture(nil,"BORDER",nil,1)
        frame._rpalBookCorners[#frame._rpalBookCorners+1]=c
        c:SetTexture(ASSET.CORNER)
        c:SetSize(36,36)
        c:SetPoint(d[1],d[2],d[3])
        c:SetTexCoord(d[4],d[5],d[6],d[7])
        c:SetAlpha(0.52)
        pcall(function()c:SetDesaturated(true)end)
        c._rpalDecorRole="ACCENT"
    end
end

local function AddPageDecoration(frame)
    if not frame or frame._rpalPageDecorated then return end
    frame._rpalPageDecorated=true

    local page=frame:CreateTexture(nil,"BACKGROUND",nil,-7)
    page:SetTexture(ASSET.PAGE)
    page:SetAllPoints(frame)
    page:SetAlpha(0.68)
    pcall(function()page:SetDesaturated(true)end)
    page._rpalDecorRole="PAGE"
    frame._rpalPageTexture=page

    local inner=frame:CreateTexture(nil,"BACKGROUND",nil,-5)
    inner:SetColorTexture(1,0.82,0.48,0.024)
    inner:SetPoint("TOPLEFT",7,-7)
    inner:SetPoint("BOTTOMRIGHT",-7,7)
    frame._rpalPageGlow=inner

    local top=frame:CreateTexture(nil,"BORDER")
    top:SetColorTexture(1,1,1,0.24)
    top:SetHeight(1);top:SetPoint("TOPLEFT",12,-6);top:SetPoint("TOPRIGHT",-12,-6)
    top._rpalDecorRole="ACCENT"
    local bottom=frame:CreateTexture(nil,"BORDER")
    bottom:SetColorTexture(1,1,1,0.15)
    bottom:SetHeight(1);bottom:SetPoint("BOTTOMLEFT",12,6);bottom:SetPoint("BOTTOMRIGHT",-12,6)
    bottom._rpalDecorRole="ACCENT"

    local left=frame:CreateTexture(nil,"BORDER")
    left:SetColorTexture(1,1,1,0.08)
    left:SetWidth(1);left:SetPoint("TOPLEFT",7,-12);left:SetPoint("BOTTOMLEFT",7,12)
    left._rpalDecorRole="ACCENT"
    local right=frame:CreateTexture(nil,"BORDER")
    right:SetColorTexture(1,1,1,0.08)
    right:SetWidth(1);right:SetPoint("TOPRIGHT",-7,-12);right:SetPoint("BOTTOMRIGHT",-7,12)
    right._rpalDecorRole="ACCENT"
end

local function AddCardTexture(frame,alpha)
    if not frame or frame._rpalCardTexture then return end
    local t=frame:CreateTexture(nil,"BACKGROUND",nil,-4)
    t:SetTexture(ASSET.CARD)
    t:SetPoint("TOPLEFT",2,-2)
    t:SetPoint("BOTTOMRIGHT",-2,2)
    t:SetAlpha(alpha or 0.70)
    pcall(function()t:SetDesaturated(true)end)
    t._rpalDecorRole="PAGE"
    frame._rpalCardTexture=t
end

local function AddFrameShadow(frame,pad,alpha)
    if not frame or frame._rpalDropShadow then return end
    pad=tonumber(pad) or 7
    local shadow=frame:CreateTexture(nil,"BACKGROUND",nil,-8)
    shadow:SetTexture("Interface\\Buttons\\WHITE8X8")
    shadow:SetPoint("TOPLEFT",-pad,pad)
    shadow:SetPoint("BOTTOMRIGHT",pad,-pad)
    shadow:SetVertexColor(0,0,0,alpha or 0.45)
    frame._rpalDropShadow=shadow
end

local function AddInnerBorder(frame,inset,alpha)
    if not frame or frame._rpalInnerBorder then return end
    inset=tonumber(inset) or 7
    local holder={}
    local function Line(pointA,relA,xA,yA,pointB,relB,xB,yB,width,height)
        local t=frame:CreateTexture(nil,"ARTWORK",nil,-1)
        t:SetTexture("Interface\\Buttons\\WHITE8X8")
        t:SetColorTexture(1,1,1,alpha or 0.16)
        t:SetPoint(pointA,relA,pointA,xA,yA)
        if pointB then t:SetPoint(pointB,relB,pointB,xB,yB) end
        if width then t:SetWidth(width) end
        if height then t:SetHeight(height) end
        t._rpalDecorRole="ACCENT"
        holder[#holder+1]=t
    end
    local top=frame:CreateTexture(nil,"ARTWORK",nil,-1)
    top:SetTexture("Interface\\Buttons\\WHITE8X8")
    top:SetColorTexture(1,1,1,alpha or 0.16)
    top:SetPoint("TOPLEFT",inset,-inset);top:SetPoint("TOPRIGHT",-inset,-inset);top:SetHeight(1);top._rpalDecorRole="ACCENT"
    local bottom=frame:CreateTexture(nil,"ARTWORK",nil,-1)
    bottom:SetTexture("Interface\\Buttons\\WHITE8X8")
    bottom:SetColorTexture(1,1,1,(alpha or 0.16)*0.7)
    bottom:SetPoint("BOTTOMLEFT",inset,inset);bottom:SetPoint("BOTTOMRIGHT",-inset,inset);bottom:SetHeight(1);bottom._rpalDecorRole="ACCENT"
    local left=frame:CreateTexture(nil,"ARTWORK",nil,-1)
    left:SetTexture("Interface\\Buttons\\WHITE8X8")
    left:SetColorTexture(1,1,1,(alpha or 0.16)*0.65)
    left:SetPoint("TOPLEFT",inset,-inset);left:SetPoint("BOTTOMLEFT",inset,inset);left:SetWidth(1);left._rpalDecorRole="ACCENT"
    local right=frame:CreateTexture(nil,"ARTWORK",nil,-1)
    right:SetTexture("Interface\\Buttons\\WHITE8X8")
    right:SetColorTexture(1,1,1,(alpha or 0.16)*0.65)
    right:SetPoint("TOPRIGHT",-inset,-inset);right:SetPoint("BOTTOMRIGHT",-inset,inset);right:SetWidth(1);right._rpalDecorRole="ACCENT"
    frame._rpalInnerBorder={top,bottom,left,right}
end

local function PolishPanel(frame,kind)
    if not frame or frame._rpalPolished then return end
    frame._rpalPolished=true
    kind=kind or "card"
    if kind=="major" or kind=="modal" then AddFrameShadow(frame,6,0.34) end
    AddCardTexture(frame,kind=="major" and 0.42 or (kind=="modal" and 0.32 or 0.26))
    AddInnerBorder(frame,kind=="major" and 8 or 6,kind=="major" and 0.18 or 0.12)

    local accent=frame:CreateTexture(nil,"ARTWORK",nil,1)
    accent:SetTexture("Interface\\Buttons\\WHITE8X8")
    accent:SetColorTexture(1,1,1,kind=="major" and 0.42 or 0.24)
    accent:SetPoint("TOPLEFT",kind=="major" and 12 or 8,-(kind=="major" and 9 or 7))
    accent:SetWidth(kind=="major" and 42 or 28)
    accent:SetHeight(2)
    accent._rpalDecorRole="ACCENT"
    frame._rpalPolishAccent=accent
end

local function AddSectionRule(parent,titleFS,rightInset)
    if not parent or not titleFS then return end
    local rule=parent:CreateTexture(nil,"ARTWORK")
    rule:SetTexture("Interface\\Buttons\\WHITE8X8")
    rule:SetColorTexture(1,1,1,0.22)
    rule:SetHeight(1)
    rule:SetPoint("LEFT",titleFS,"RIGHT",10,0)
    rule:SetPoint("RIGHT",parent,-(rightInset or 18),0)
    rule._rpalDecorRole="ACCENT"
    return rule
end

GetRenownLevelColor=function(level)
    level=math.floor(tonumber(level) or 1)
    if level<=4 then
        return 0.68,0.68,0.68 -- grey
    elseif level<=14 then
        return 0.34,0.88,0.38 -- green
    elseif level<=29 then
        return 0.32,0.62,1.00 -- blue
    elseif level<=39 then
        return 0.72,0.39,0.96 -- purple
    elseif level<=49 then
        return 1.00,0.45,0.72 -- pink
    elseif level<=59 then
        return 1.00,0.84,0.28 -- yellow
    else
        return 1.00,0.31,0.44 -- pinkish red
    end
end

local DEFAULT_FONT = STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF"

local function Font(parent, text, size, color, font)
    local fs = parent:CreateFontString(nil, "OVERLAY")
    local baseSize=size or 12
    local finalSize=RPAL.GetScaledFontSize and RPAL.GetScaledFontSize(baseSize) or baseSize
    -- Small outlined text can collapse into a dark blob at WoW UI scales.
    -- Use a clean default WoW face for body/button-sized copy and reserve a
    -- light outline for larger headings.
    local fontFlags=(finalSize>=14) and "OUTLINE" or ""
    fs:SetFont(font or DEFAULT_FONT, finalSize, fontFlags)
    if RPAL.RegisterScalableFontObject then RPAL.RegisterScalableFontObject(fs,font or DEFAULT_FONT,baseSize,fontFlags) end
    fs:SetText(text or "")
    if color then
        fs:SetTextColor(unpack(color))
        fs._rpalColorRole=ThemeColorRole(color)
    end
    if finalSize>=10 then
        fs:SetShadowColor(0,0,0,0.82)
        fs:SetShadowOffset(1,-1)
    end
    return fs
end

local function Heading(parent, text, size)
    return Font(parent, text, size or 16, GOLD, DEFAULT_FONT)
end

-- Shared statistic icon. Keep the legacy function name because callers use it,
-- but render only the glyph: no plate, wash, rail, border, cap, notch or halo.
function RPAL.CreateStatMedallion(parent,texture,accent,size)
    if not parent then return nil end
    accent=accent or GOLD
    size=tonumber(size) or 30

    local well=CreateFrame("Frame",nil,parent)
    well:SetSize(size,size)

    local icon=well:CreateTexture(nil,"ARTWORK",nil,0)
    icon:SetTexture(texture or RPAL.ICONS.question)
    icon:SetAllPoints()
    icon:SetTexCoord(0.04,0.96,0.04,0.96)
    -- ADD makes the dark pixels in the source art disappear instead of
    -- becoming a square, while the light glyph takes the equipped theme.
    icon:SetBlendMode("ADD")
    icon:SetVertexColor(accent[1],accent[2],accent[3],0.96)

    return well,icon
end


local function AttachGentleFloat(frame,rise,withShadow)
    if not frame or frame._rpalGentleFloat then return end
    frame._rpalGentleFloat=true

    rise=tonumber(rise) or 2
    local current=0
    local target=0
    local captured=false
    local basePoints={}

    local shadow
    if withShadow and type(frame.CreateTexture)=="function" then
        shadow=frame:CreateTexture(nil,"BACKGROUND",nil,-8)
        shadow:SetColorTexture(0,0,0,0.42)
        shadow:SetPoint("TOPLEFT",5,-6)
        shadow:SetPoint("BOTTOMRIGHT",5,-6)
        shadow:SetAlpha(0)
        frame._rpalHoverShadow=shadow
    end

    local function CapturePoints()
        if captured then return end
        captured=true
        basePoints={}
        local count=frame:GetNumPoints()
        for i=1,count do
            local point,relativeTo,relativePoint,x,y=frame:GetPoint(i)
            basePoints[#basePoints+1]={point=point,relativeTo=relativeTo,relativePoint=relativePoint,x=x or 0,y=y or 0}
        end
    end

    local function ApplyOffset()
        if not captured or #basePoints==0 then return end
        frame:ClearAllPoints()
        for _,p in ipairs(basePoints) do frame:SetPoint(p.point,p.relativeTo,p.relativePoint,p.x,p.y+current) end
    end

    local driver=CreateFrame("Frame",nil,frame)
    driver:Hide()
    driver:SetScript("OnUpdate",function(self,elapsed)
        if not captured then self:Hide();return end
        local difference=target-current
        if math.abs(difference)<0.015 then
            current=target;ApplyOffset()
        else
            current=current+(difference*math.min(1,(elapsed or 0)*13));ApplyOffset()
        end
        if shadow then
            local ratio=rise>0 and math.max(0,math.min(1,current/rise)) or 0
            shadow:SetAlpha(ratio*0.30)
        end
        if current==target then self:Hide() end
    end)

    frame:HookScript("OnEnter",function()
        CapturePoints();target=rise;driver:Show()
    end)
    frame:HookScript("OnLeave",function()
        target=0;driver:Show()
    end)
    frame:HookScript("OnHide",function()driver:Hide()end)
end

local function AttachHoverGrow(frame,amount)
    if not frame or frame._rpalHoverGrow then return end
    frame._rpalHoverGrow=true
    amount=tonumber(amount) or 0.035
    frame:EnableMouse(true)
    local current,target=0,0
    local baseLevel=frame.GetFrameLevel and frame:GetFrameLevel() or 1
    local driver=CreateFrame("Frame",nil,frame)
    driver:Hide()
    driver:SetScript("OnUpdate",function(self,elapsed)
        local diff=target-current
        if math.abs(diff)<0.01 then current=target else current=current+(diff*math.min(1,(elapsed or 0)*14)) end
        if frame.SetScale then frame:SetScale(1+(amount*current)) end
        if current==target then self:Hide() end
    end)
    frame:HookScript("OnEnter",function(self)
        target=1;driver:Show()
        if self.SetFrameLevel then self:SetFrameLevel(baseLevel+18) end
        local accent=self._rpalHoverAccent or GOLD
        if self.SetBackdropBorderColor then self:SetBackdropBorderColor(accent[1],accent[2],accent[3],accent[4] or 0.92) end
    end)
    frame:HookScript("OnLeave",function(self)
        target=0;driver:Show()
        if self.SetFrameLevel then self:SetFrameLevel(baseLevel) end
        local border=self._rpalHoverRestColor or BORDER
        if self.SetBackdropBorderColor then self:SetBackdropBorderColor(border[1],border[2],border[3],border[4] or 1) end
    end)
    frame:HookScript("OnHide",function(self)
        target=0;current=0;driver:Hide();if self.SetScale then self:SetScale(1) end
    end)
end

local function RPALAtan2(y,x)
    if math.atan2 then return math.atan2(y,x) end
    if x>0 then return math.atan(y/x) end
    if x<0 and y>=0 then return math.atan(y/x)+math.pi end
    if x<0 and y<0 then return math.atan(y/x)-math.pi end
    if x==0 and y>0 then return math.pi/2 end
    if x==0 and y<0 then return -math.pi/2 end
    return 0
end

local function Button(parent, text, width, height, gold)
    local b = CreateFrame("Button", nil, parent, "BackdropTemplate")
    b:SetSize(width or 90, height or 32)
    if parent and parent.GetFrameLevel then b:SetFrameLevel(parent:GetFrameLevel()+5) end
    Backdrop(b, gold and ACCENT_BG or BUTTON_BG, gold and GOLD or BORDER)

    local top=b:CreateTexture(nil,"ARTWORK")
    top:SetTexture("Interface\\Buttons\\WHITE8X8")
    top:SetColorTexture(1,1,1,0.10)
    top:SetHeight(1);top:SetPoint("TOPLEFT",7,-5);top:SetPoint("TOPRIGHT",-7,-5)
    top._rpalDecorRole="ACCENT"

    local accent=b:CreateTexture(nil,"ARTWORK")
    accent:SetTexture("Interface\\Buttons\\WHITE8X8")
    accent:SetColorTexture(1,1,1,gold and 0.92 or 0.34)
    accent:SetHeight(2)
    accent:SetPoint("BOTTOMLEFT",7,4)
    accent:SetPoint("BOTTOMRIGHT",-7,4)
    b._rpalAccent=accent
    accent._rpalDecorRole="ACCENT"

    local glow=b:CreateTexture(nil,"BACKGROUND",nil,-1)
    glow:SetTexture("Interface\\Buttons\\WHITE8X8")
    glow:SetPoint("TOPLEFT",3,-3);glow:SetPoint("BOTTOMRIGHT",-3,3)
    glow:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],0)
    b._rpalHoverGlow=glow

    b.label = Font(b, text, 11, PALE, DEFAULT_FONT)
    if b.label and b.label.SetFont then b.label:SetFont(DEFAULT_FONT,RPAL.GetScaledFontSize and RPAL.GetScaledFontSize(11) or 11,"") end
    if RPAL.RegisterScalableFontObject then RPAL.RegisterScalableFontObject(b.label,DEFAULT_FONT,11,"") end
    b.label:SetShadowColor(0,0,0,0.62);b.label:SetShadowOffset(1,-1)
    b.label:SetPoint("CENTER",0,1)

    b:SetScript("OnEnter", function(self)
        local c=gold and ACCENT_HOVER or BUTTON_HOVER
        self:SetBackdropColor(c[1],c[2],c[3],c[4] or 1)
        self:SetBackdropBorderColor(GOLD[1],GOLD[2],GOLD[3],1)
        if self._rpalAccent then self._rpalAccent:SetAlpha(1) end
        if self._rpalHoverGlow then self._rpalHoverGlow:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],0.07) end
    end)
    b:SetScript("OnLeave", function(self)
        local c=gold and ACCENT_BG or BUTTON_BG
        self:SetBackdropColor(c[1],c[2],c[3],c[4] or 1)
        local bc=gold and GOLD or BORDER
        self:SetBackdropBorderColor(bc[1],bc[2],bc[3],bc[4] or 1)
        if self._rpalAccent then self._rpalAccent:SetAlpha(gold and 0.92 or 0.72) end
        if self._rpalHoverGlow then self._rpalHoverGlow:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],0) end
    end)
    b:HookScript("OnMouseDown",function(self,button)
        if button=="LeftButton" and self._rpalHoverGlow then
            local c=self._rpalInteractionAccent or GOLD
            self._rpalHoverGlow:SetVertexColor(c[1],c[2],c[3],0.14)
        end
    end)
    b:HookScript("OnMouseUp",function(self)
        if self._rpalHoverGlow then
            local c=self._rpalInteractionAccent or GOLD
            self._rpalHoverGlow:SetVertexColor(c[1],c[2],c[3],self:IsMouseOver() and 0.07 or 0)
        end
    end)
    if RPAL.AttachButtonMotion then RPAL.AttachButtonMotion(b,0.018) else AttachGentleFloat(b,2,false) end
    if RPAL.AttachFocusMark then RPAL.AttachFocusMark(b,GOLD) end
    return b
end

local function CloseButton(parent)
    local b=CreateFrame("Button",nil,parent)
    b:SetSize(34,34)
    if parent and parent.GetFrameLevel then b:SetFrameLevel(parent:GetFrameLevel()+40) end
    b:SetHitRectInsets(2,2,2,2)

    local normal=b:CreateTexture(nil,"ARTWORK")
    normal:SetTexture(ASSET.CLOSE)
    normal:SetAllPoints()
    normal:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],0.88)
    normal._rpalDecorRole="ACCENT"
    local hover=b:CreateTexture(nil,"HIGHLIGHT")
    hover:SetTexture(ASSET.CLOSE_HOVER)
    hover:SetAllPoints()
    hover:SetBlendMode("ADD")
    hover:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],1)
    hover._rpalDecorRole="ACCENT"
    b._rpalNormalTexture=normal
    b._rpalHoverTexture=hover
    if RPAL.AttachButtonMotion then RPAL.AttachButtonMotion(b,0.016) else AttachGentleFloat(b,2,false) end
    if RPAL.AttachFocusMark then RPAL.AttachFocusMark(b,GOLD) end
    return b
end

function H.AchievementEditButton(parent)
    local b=CreateFrame("Button",nil,parent,"BackdropTemplate")
    b:SetSize(28,22)
    if parent and parent.GetFrameLevel then
        b:SetFrameLevel(parent:GetFrameLevel()+30)
    end
    Backdrop(b,{0.035,0.05,0.065,0.98},{0.34,0.40,0.45,1})

    local icon=b:CreateTexture(nil,"ARTWORK")
    icon:SetTexture(RPAL.ICONS.edit)
    icon:SetSize(16,16)
    icon:SetPoint("CENTER")
    icon:SetVertexColor(PALE[1],PALE[2],PALE[3],0.90)
    icon._rpalDecorRole="ACCENT"

    b:SetScript("OnEnter",function(self)
        self:SetBackdropColor(0.12,0.10,0.05,0.98)
        self:SetBackdropBorderColor(unpack(GOLD))
        icon:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],1)
        if RPAL.ShowSimpleCursorTooltip then
            RPAL.ShowSimpleCursorTooltip("Edit Achievement","Open this achievement in the editor.",GOLD,"ACHIEVEMENTS")
        end
    end)
    b:SetScript("OnLeave",function(self)
        self:SetBackdropColor(0.035,0.05,0.065,0.98)
        self:SetBackdropBorderColor(0.34,0.40,0.45,1)
        icon:SetVertexColor(PALE[1],PALE[2],PALE[3],0.90)
        if RPAL.HideCursorTooltip then RPAL.HideCursorTooltip() end
    end)
    return b
end

local function EditBox(parent, width, height)
    local e = CreateFrame("EditBox", nil, parent, "BackdropTemplate")
    e:SetSize(width or 150, height or 30)
    Backdrop(e, {0.018,0.024,0.028,0.98}, BORDER)
    e:SetFont(DEFAULT_FONT, RPAL.GetScaledFontSize and RPAL.GetScaledFontSize(12) or 12, "OUTLINE")
    if RPAL.RegisterScalableFontObject then RPAL.RegisterScalableFontObject(e,DEFAULT_FONT,12,"OUTLINE") end
    e:SetTextColor(unpack(PALE))
    e:SetAutoFocus(false)
    e:SetTextInsets(9, 9, 0, 0)
    e:HookScript("OnEditFocusGained",function(self)
        self:SetBackdropBorderColor(GOLD[1],GOLD[2],GOLD[3],1)
    end)
    e:HookScript("OnEditFocusLost",function(self)
        self:SetBackdropBorderColor(BORDER[1],BORDER[2],BORDER[3],BORDER[4] or 1)
    end)
    return e
end

local function Check(parent, text)
    local b = CreateFrame("Button", nil, parent, "BackdropTemplate")
    b:SetHeight(30)
    Backdrop(b, {0.035,0.048,0.052,0.94}, BORDER)
    b.box = CreateFrame("Frame", nil, b, "BackdropTemplate")
    b.box:SetSize(17,17); b.box:SetPoint("LEFT",8,0); Backdrop(b.box,{0.015,0.020,0.022,1},BORDER)
    b.tick = b.box:CreateTexture(nil,"OVERLAY")
    b.tick:SetTexture("Interface\\Buttons\\UI-CheckBox-Check")
    b.tick:SetSize(21,21); b.tick:SetPoint("CENTER",0,0)
    b.tick:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],1)
    b.text = Font(b, text, 10, PALE); b.text:SetPoint("LEFT", b.box, "RIGHT", 8,0)
    function b:SetChecked(v)
        self.checked=not not v
        self.tick:SetShown(self.checked)
        if self.checked then
            self.box:SetBackdropBorderColor(GOLD[1],GOLD[2],GOLD[3],1)
        else
            self.box:SetBackdropBorderColor(BORDER[1],BORDER[2],BORDER[3],1)
        end
    end
    function b:GetChecked() return self.checked end
    b:SetScript("OnEnter",function(self)self:SetBackdropBorderColor(GOLD[1],GOLD[2],GOLD[3],0.82)end)
    b:SetScript("OnLeave",function(self)self:SetBackdropBorderColor(BORDER[1],BORDER[2],BORDER[3],1)end)
    b:SetChecked(false)
    return b
end

local function ColorButton(parent, color, callback)
    local b = CreateFrame("Button", nil, parent, "BackdropTemplate")
    b:SetSize(28,28); Backdrop(b,{color.r,color.g,color.b,1},{0.55,0.42,0.20,1})
    b:SetScript("OnClick", function()
        local old = { r=color.r, g=color.g, b=color.b }
        local function changed()
            local r,g,bl = ColorPickerFrame:GetColorRGB()
            color.r,color.g,color.b = r,g,bl
            Backdrop(b,{r,g,bl,1},{0.55,0.42,0.20,1})
            if callback then callback() end
        end
        if ColorPickerFrame.SetupColorPickerAndShow then
            ColorPickerFrame:SetupColorPickerAndShow({
                r=color.r,g=color.g,b=color.b,
                swatchFunc=changed,
                cancelFunc=function()
                    color.r,color.g,color.b = old.r,old.g,old.b
                    Backdrop(b,{old.r,old.g,old.b,1},{0.55,0.42,0.20,1})
                    if callback then callback() end
                end,
            })
        else
            ColorPickerFrame:SetColorRGB(color.r,color.g,color.b)
            ColorPickerFrame.func=changed
            ColorPickerFrame.cancelFunc=function()
                color.r,color.g,color.b=old.r,old.g,old.b
                Backdrop(b,{old.r,old.g,old.b,1},{0.55,0.42,0.20,1})
                if callback then callback() end
            end
            ColorPickerFrame:Show()
        end
    end)
    return b
end

local function ClearChildren(frame)
    if frame._children then
        for _,child in ipairs(frame._children) do
            if child and child.Hide then child:Hide() end
            if child and child.SetParent then pcall(child.SetParent,child,nil) end
        end
    end
    frame._children = {}
end

local function Keep(parent, child)
    parent._children = parent._children or {}
    table.insert(parent._children, child)
    return child
end

local function PopupDropdown(parent, anchor, items, selected, onSelect)
    if RPAL.CloseOpenDropdown then RPAL.CloseOpenDropdown() end
    if UI.dropdown then
        UI.dropdown:Hide()
        UI.dropdown:SetParent(nil)
        UI.dropdown=nil
    end

    -- Keep ODYN's own visual language, but use a deliberately simple input
    -- model: a TOOLTIP-strata popup with real Button rows.  Selection commits
    -- on mouse-down, before any parent modal / outside-dismiss logic has a
    -- chance to steal the corresponding mouse-up.  This is intentionally not
    -- Blizzard's native menu so Settings and DM Kit retain the ODYN skin.
    local d=CreateFrame("Frame",nil,UIParent,"BackdropTemplate")
    UI.dropdown=d
    d._odynDropdownOwner=anchor
    d._odynOwnsDismiss=true
    d:SetFrameStrata("TOOLTIP")
    d:SetFrameLevel(100)
    d:SetToplevel(true)
    d:EnableMouse(true)
    if d.SetPropagateMouseClicks then pcall(d.SetPropagateMouseClicks,d,false) end
    if d.SetPropagateMouseMotion then pcall(d.SetPropagateMouseMotion,d,false) end
    if RPAL.SetOpenDropdown then RPAL.SetOpenDropdown(d) end

    local width=math.max(180,anchor:GetWidth())
    local visibleRows=math.max(1,math.min(#items,12))
    d:SetWidth(width)
    d:SetHeight((visibleRows*26)+8)
    Backdrop(d,PANEL2,GOLD)

    local anchorBottom=anchor.GetBottom and anchor:GetBottom()
    if anchorBottom and anchorBottom<(d:GetHeight()+12) then
        d:SetPoint("BOTTOMLEFT",anchor,"TOPLEFT",0,3)
    else
        d:SetPoint("TOPLEFT",anchor,"BOTTOMLEFT",0,-3)
    end
    if d.Raise then pcall(d.Raise,d) end

    local list,content
    if #items<=12 then
        content=d
    else
        list=CreateFrame("ScrollFrame",nil,d)
        list:SetPoint("TOPLEFT",4,-4)
        list:SetSize(width-8,visibleRows*26)
        list:SetFrameLevel(d:GetFrameLevel()+2)
        list:EnableMouse(true)
        if list.SetClipsChildren then list:SetClipsChildren(true) end
        if list.EnableMouseWheel then list:EnableMouseWheel(true) end
        content=CreateFrame("Frame",nil,list)
        content:SetSize(width-8,math.max(1,#items*26))
        content:SetFrameLevel(d:GetFrameLevel()+3)
        list:SetScrollChild(content)
    end

    local function ScrollItems(delta)
        if not list then return end
        local maximum=math.max(0,(#items-visibleRows)*26)
        local current=(list.GetVerticalScroll and list:GetVerticalScroll()) or 0
        local nextValue=math.max(0,math.min(maximum,current-((tonumber(delta) or 0)*52)))
        if list.SetVerticalScroll then list:SetVerticalScroll(nextValue) end
    end
    if list then
        d:EnableMouseWheel(true)
        d:SetScript("OnMouseWheel",function(_,delta)ScrollItems(delta)end)
        list:SetScript("OnMouseWheel",function(_,delta)ScrollItems(delta)end)
    end

    d._rpalItemButtons={}
    for index,item in ipairs(items) do
        local selectedItem=item
        local b=CreateFrame("Button",nil,content,"BackdropTemplate")
        b:SetSize(width-8,24)
        if content==d then b:SetPoint("TOPLEFT",4,-4-((index-1)*26))
        else b:SetPoint("TOPLEFT",0,-((index-1)*26)) end
        b:SetFrameLevel(d:GetFrameLevel()+20+index)
        b:EnableMouse(true)
        b:SetHitRectInsets(0,0,0,0)
        if b.RegisterForClicks then b:RegisterForClicks("LeftButtonDown") end
        if b.SetPropagateMouseClicks then pcall(b.SetPropagateMouseClicks,b,false) end
        Backdrop(b,BUTTON_BG,BORDER)

        b.label=Font(b,tostring(item or ""),11,PALE,DEFAULT_FONT)
        if b.label and b.label.SetFont then b.label:SetFont(DEFAULT_FONT,RPAL.GetScaledFontSize and RPAL.GetScaledFontSize(11) or 11,"") end
        b.label:SetPoint("CENTER",0,1)
        if item==selected then b.label:SetTextColor(unpack(GOLD)) end

        b:SetScript("OnEnter",function(self)
            self:SetBackdropColor(BUTTON_HOVER[1],BUTTON_HOVER[2],BUTTON_HOVER[3],BUTTON_HOVER[4] or 1)
            self:SetBackdropBorderColor(GOLD[1],GOLD[2],GOLD[3],1)
        end)
        b:SetScript("OnLeave",function(self)
            self:SetBackdropColor(BUTTON_BG[1],BUTTON_BG[2],BUTTON_BG[3],BUTTON_BG[4] or 1)
            self:SetBackdropBorderColor(BORDER[1],BORDER[2],BORDER[3],BORDER[4] or 1)
        end)
        local function Commit(self,button)
            if button and button~="LeftButton" then return end
            if d._selectionCommitted then return end
            d._selectionCommitted=true
            d:Hide()
            if UI.dropdown==d then UI.dropdown=nil end
            if type(onSelect)=="function" then onSelect(selectedItem) end
        end
        -- OnMouseDown is the important path. OnClick remains as a compatibility
        -- fallback for clients/templates that synthesize click differently.
        b:SetScript("OnMouseDown",Commit)
        b:SetScript("OnClick",Commit)
        if b.EnableMouseWheel and list then
            b:EnableMouseWheel(true)
            b:SetScript("OnMouseWheel",function(_,delta)ScrollItems(delta)end)
        end
        d._rpalItemButtons[index]=b
    end

    -- Own outside-click dismissal locally.  The global dropdown dismiss driver
    -- skips this popup, so it can never hide between a row's mouse-down and
    -- mouse-up.  We wait briefly after opening so the click that opened the
    -- menu cannot immediately close it again.
    local anchorLeft=anchor.GetLeft and anchor:GetLeft()
    local anchorTop=anchor.GetTop and anchor:GetTop()
    local anchorScale=anchor.GetEffectiveScale and anchor:GetEffectiveScale() or 1
    local openedAt=(type(GetTime)=="function" and GetTime()) or 0
    local wasDown=false
    if type(IsMouseButtonDown)=="function" then
        wasDown=IsMouseButtonDown("LeftButton") or IsMouseButtonDown("RightButton") or IsMouseButtonDown("MiddleButton")
    end
    d:SetScript("OnUpdate",function(self,elapsed)
        self._anchorWatch=(self._anchorWatch or 0)+(elapsed or 0)
        if self._anchorWatch>=0.05 then
            self._anchorWatch=0
            if anchor.IsShown and not anchor:IsShown() then self:Hide();return end
            if anchor.IsVisible and not anchor:IsVisible() then self:Hide();return end
            local scale=anchor.GetEffectiveScale and anchor:GetEffectiveScale() or anchorScale
            local left=anchor.GetLeft and anchor:GetLeft()
            local top=anchor.GetTop and anchor:GetTop()
            if scale and anchorScale and math.abs(scale-anchorScale)>0.0005 then
                anchorScale=scale;anchorLeft=left or anchorLeft;anchorTop=top or anchorTop
            elseif (anchorLeft and left and math.abs(left-anchorLeft)>2) or (anchorTop and top and math.abs(top-anchorTop)>2) then
                self:Hide();return
            end
        end

        if type(IsMouseButtonDown)=="function" then
            local down=IsMouseButtonDown("LeftButton") or IsMouseButtonDown("RightButton") or IsMouseButtonDown("MiddleButton")
            local now=(type(GetTime)=="function" and GetTime()) or openedAt
            if (now-openedAt)>=0.15 and down and not wasDown then
                local overSelf=(self.IsMouseOver and self:IsMouseOver()) or false
                local overOwner=(anchor.IsMouseOver and anchor:IsMouseOver()) or false
                if not overSelf and not overOwner then self:Hide();return end
            end
            wasDown=down
        end
    end)

    d:SetScript("OnHide",function()
        if UI.dropdown==d then UI.dropdown=nil end
    end)
    d._rpalScrollFrame=list
    d._rpalScrollContent=content
    d._rpalVisibleRows=visibleRows
    return d
end
RPAL.OpenPopupDropdown=PopupDropdown

local function MakeLabel(parent, text, x, y)
    local l = Font(parent, text:upper(), 9, GOLD)
    l:SetPoint("TOPLEFT", x, y)
    return l
end

function H.BindEdit(edit, getter, setter)
    edit:SetText(getter() or "")
    edit:SetScript("OnEnterPressed", function(self) self:ClearFocus() end)
    edit:SetScript("OnEditFocusLost", function(self) setter(self:GetText() or ""); RPAL.Refresh() end)
end

local function SetPortrait(tex)
    SetPortraitTexture(tex, "player")
end

local function MaskCircle(tex)
    -- WoW's portrait border provides the visual framing.
    -- No external/manual image mask is needed.
end


-- ============================================================
-- Player-to-player addon communication
-- ============================================================

function H.SocialShortName(name)
    name=tostring(name or "")
    return name:match("^([^%-]+)") or name
end

local function SocialFullUnitName(unit)
    local name,realm=UnitFullName(unit)
    if not name then return nil end
    if realm and realm~="" then return name.."-"..realm end
    return name
end

SocialNormalizeName = function(name)
    name=tostring(name or ""):gsub("^%s+",""):gsub("%s+$",""):gsub("%s+","")
    if name~="" and not name:find("-",1,true) then
        local _,realm
        if UnitFullName then _,realm=UnitFullName("player") end
        if realm and realm~="" then name=name.."-"..realm:gsub("%s+","") end
    end
    return name:lower()
end

-- Resolve a live unit token for inspected portraits. Remote snapshots cannot
-- transmit Blizzard portrait textures, but the target/group unit already has
-- one locally and self-loopback previews can always use "player".
function H.ResolveInspectUnit(name,request)
    if request and request.localPreview then return "player" end
    local wanted=SocialNormalizeName(name)
    if wanted=="" then return nil end
    local function Match(unit)
        if not UnitExists or not UnitExists(unit) or (UnitIsPlayer and not UnitIsPlayer(unit)) then return false end
        local full=SocialFullUnitName(unit) or (UnitName and UnitName(unit))
        return full and SocialNormalizeName(full)==wanted
    end
    for _,unit in ipairs({"player","target","focus","mouseover"}) do if Match(unit) then return unit end end
    for i=1,4 do local unit="party"..i;if Match(unit) then return unit end end
    for i=1,40 do local unit="raid"..i;if Match(unit) then return unit end end
    return nil
end

function H.ParseODYNRelease(version,build)
    local a,b,c=tostring(version or ""):match("(%d+)[%.](%d+)[%.](%d+)")
    a,b,c=tonumber(a)or 0,tonumber(b)or 0,tonumber(c)or 0
    local hf=tonumber(tostring(build or ""):match("[Hh][Oo][Tt][Ff][Ii][Xx]%s*(%d+)")) or 0
    return a,b,c,hf
end

function H.IsODYNReleaseNewer(version,build,baseVersion,baseBuild)
    local a,b,c,h=H.ParseODYNRelease(version,build)
    local x,y,z,k=H.ParseODYNRelease(baseVersion,baseBuild)
    if a~=x then return a>x end;if b~=y then return b>y end;if c~=z then return c>z end
    return h>k
end

function RPAL.NoteAvailableUpdate(version,build,source)
    version=tostring(version or ""):gsub("^%s+",""):gsub("%s+$","")
    build=tostring(build or ""):gsub("^%s+",""):gsub("%s+$","")
    if version=="" then return false end
    local localVersion=tostring(RPAL.VERSION or "1.7.1")
    local localBuild=tostring(RPAL.BUILD or "")
    if not H.IsODYNReleaseNewer(version,build,localVersion,localBuild) then return false end
    local current=RPAL._availableUpdateInfo
    if current and not H.IsODYNReleaseNewer(version,build,current.version,current.build) then return false end
    RPAL._availableUpdateInfo={version=version,build=build,source=source or "peer",seenAt=(GetTime and GetTime())or 0}
    if RPAL.RefreshBookChrome and C_Timer and C_Timer.After then C_Timer.After(0,function()if RPAL.RefreshBookChrome then RPAL.RefreshBookChrome(true) end end) end
    return true
end

function RPAL.GetAvailableUpdateInfo() return RPAL._availableUpdateInfo end

-- Huginn can optionally add these fields to its bridge in a future companion
-- build. Keeping the reader here means the in-game notice starts working as
-- soon as companion metadata is present, without another ODYN UI rewrite.
function RPAL.RefreshDetectedUpdateFromHuginn()
    local feed=_G and _G.HUGINN_COMMUNITY_FEED
    local hint=_G and _G.HUGINN_ODYN_UPDATE
    local version=(type(hint)=="table" and hint.version) or (type(feed)=="table" and (feed.latestOdynVersion or feed.odynLatestVersion))
    local build=(type(hint)=="table" and hint.build) or (type(feed)=="table" and (feed.latestOdynBuild or feed.odynLatestBuild))
    if version then RPAL.NoteAvailableUpdate(version,build,"huginn") end
end

function H.SocialAddonVersionFields()
    local version=tostring(RPAL.VERSION or "1.7.1"):gsub(";","")
    local build=tostring(RPAL.BUILD or ""):gsub(";","")
    return version,build
end

local function SocialHexEncode(value,maxBytes)
    local s=tostring(value or "")
    if maxBytes and #s>maxBytes then
        local cut=math.max(0,math.floor(tonumber(maxBytes) or 0))
        local start=cut
        while start>1 do
            local byte=string.byte(s,start)
            if byte and byte>=0x80 and byte<=0xBF then start=start-1 else break end
        end
        local lead=string.byte(s,start)
        local length=(lead and lead>=0xF0 and lead<=0xF7 and 4)
            or (lead and lead>=0xE0 and lead<=0xEF and 3)
            or (lead and lead>=0xC0 and lead<=0xDF and 2)
            or 1
        if start+length-1>cut then cut=start-1 end
        s=s:sub(1,math.max(0,cut))
    end
    return (s:gsub(".",function(ch)return string.format("%02X",string.byte(ch))end))
end

local function SocialHexDecode(value)
    value=tostring(value or "")
    if value=="" then return "" end
    if (#value%2)~=0 or value:find("[^0-9A-Fa-f]") then return "" end
    local out={}
    for i=1,#value,2 do
        local byte=tonumber(value:sub(i,i+1),16)
        if not byte then return "" end
        out[#out+1]=string.char(byte)
    end
    return table.concat(out):gsub("[%z\1-\31]","")
end

function H.SocialSplit(text,sep)
    local result={}
    local start=1
    while true do
        local pos=string.find(text,sep,start,true)
        if not pos then
            result[#result+1]=string.sub(text,start)
            break
        end
        result[#result+1]=string.sub(text,start,pos-1)
        start=pos+#sep
    end
    return result
end

local function SocialCanSend()
    return C_ChatInfo and C_ChatInfo.SendAddonMessage and C_ChatInfo.RegisterAddonMessagePrefix
end

local function SocialSend(message,chatType,target)
    if not SocialCanSend() then return false end
    if #message>255 then return false end
    if RPAL.SendAddonMessageSafe then return RPAL.SendAddonMessageSafe(SOCIAL_PREFIX,message,chatType,target,true) end
    local ok,result=pcall(C_ChatInfo.SendAddonMessage,SOCIAL_PREFIX,message,chatType,target)
    return ok and (result==nil or result==true or tonumber(result)==0)
end

local socialStatusRequests={}
function RPAL.RequestRemoteTRPStatus(targetName,force)
    targetName=tostring(targetName or ""):gsub("^%s+",""):gsub("%s+$","")
    if targetName=="" or SocialNormalizeName(targetName)==SocialNormalizeName(UnitName("player")) then return false end
    local now=(GetTime and GetTime()) or 0
    local key=SocialNormalizeName(targetName)
    if not force and now-(socialStatusRequests[key] or -1000)<1.5 then return false,"cooldown" end
    socialStatusRequests[key]=now

    -- LibMSP is the authoritative bridge for TRP-only players. Explicitly ask
    -- for FC instead of waiting for a tooltip/cache refresh; this is especially
    -- important immediately after /reload while nearby players are already IC.
    local lib=_G.msp
    if type(lib)=="table" then
        if type(lib.QueueRequest)=="function" then
            pcall(lib.QueueRequest,lib,targetName,"FC")
            pcall(lib.QueueRequest,lib,targetName,"NA")
        elseif type(lib.Request)=="function" then
            pcall(lib.Request,lib,targetName,{"FC","NA"})
        end
    end

    -- If the other player also runs ODYN, a direct whisper gives us their
    -- resolved status without relying on the (unsupported) SAY addon channel.
    local ownStatus=RPAL.GetTRPCharacterStatus and select(1,RPAL.GetTRPCharacterStatus()) or "UNKNOWN"
    do local av,ab=H.SocialAddonVersionFields();SocialSend("P;"..SOCIAL_PROTOCOL..";"..tostring(ownStatus)..";"..av..";"..ab,"WHISPER",targetName) end
    return true
end

function RPAL.ShareStoryLocationsWith(targetName,reciprocal)
    targetName=tostring(targetName or ""):gsub("^%s+",""):gsub("%s+$","")
    if targetName=="" or not RPAL.GetShareableStoryLocations then return false end
    if SocialNormalizeName(targetName)==SocialNormalizeName(UnitName("player")) then return false end
    local now=(GetTime and GetTime()) or 0
    socialRequests._locationShareRate=socialRequests._locationShareRate or {}
    local key=SocialNormalizeName(targetName)
    local cooldown=reciprocal and 120 or 600
    if now-(socialRequests._locationShareRate[key] or -10000)<cooldown then return false end
    local locations=RPAL.GetShareableStoryLocations(8)
    if #locations==0 then return false end
    socialRequests._locationShareRate[key]=now
    local sentAny=false
    for _,location in ipairs(locations) do
        if SocialSend(table.concat({"LP",tostring(SOCIAL_PROTOCOL),SocialHexEncode(location,80)},";"),"WHISPER",targetName) then sentAny=true end
    end
    return sentAny
end

local function SocialMarkPresent(sender,version,status,addonVersion,addonBuild)
    if not sender or sender=="" then return end
    local key=SocialNormalizeName(sender)
    local previous=socialPresence[key]
    socialPresence[key]={
        name=sender,
        version=version or (previous and previous.version) or "?",
        status=status or (previous and previous.status),
        addonVersion=addonVersion or (previous and previous.addonVersion),
        addonBuild=addonBuild or (previous and previous.addonBuild),
        seenAt=GetTime and GetTime() or 0,
    }
    if addonVersion and RPAL.NoteAvailableUpdate then RPAL.NoteAvailableUpdate(addonVersion,addonBuild,"peer") end
    if RPAL.UpdateTargetBadge then RPAL.UpdateTargetBadge() end
    -- A presence reply can arrive just after the hover menu opens. Refresh it
    -- immediately so the first row flips from Open TRP to Inspect without the
    -- player needing to close/reopen the menu.
    if RPAL.RefreshHoverQuickActionMenu then pcall(RPAL.RefreshHoverQuickActionMenu) end
end

function H.SocialIsPresent(name)
    local entry=socialPresence[SocialNormalizeName(name)]
    if not entry then return false end
    local now=GetTime and GetTime() or 0
    if (now-(entry.seenAt or 0))>SOCIAL_PRESENCE_TTL then
        socialPresence[SocialNormalizeName(name)]=nil
        return false
    end
    return true
end

-- Hover Quick Actions use the same short-lived peer presence table as the
-- target-frame ODYN badge. Expose only the tiny queries/actions needed by the
-- hover module so it never reaches into UI.lua locals directly.
function RPAL.IsODYNUser(name)
    return H.SocialIsPresent(name)
end

function RPAL.ProbeODYNUser(name)
    name=tostring(name or ""):gsub("^%s+",""):gsub("%s+$","")
    if name=="" or not SocialCanSend() then return false end
    local privacy=RPAL.GetProfilePrivacy and RPAL.GetProfilePrivacy() or {}
    if privacy.hidePublicProfile then return false end

    -- Hovering a crowded group must never turn into addon-message spam. One
    -- lightweight presence probe per player every few seconds is plenty; an
    -- existing presence cache entry needs no probe at all.
    if H.SocialIsPresent(name) then return true end
    socialRequests._hoverPresenceProbe=socialRequests._hoverPresenceProbe or {}
    local key=SocialNormalizeName(name)
    local now=(GetTime and GetTime()) or 0
    if now-(socialRequests._hoverPresenceProbe[key] or -10000)<6 then return false,"rate_limited" end
    socialRequests._hoverPresenceProbe[key]=now

    local status=RPAL.GetTRPCharacterStatus and select(1,RPAL.GetTRPCharacterStatus()) or "UNKNOWN"
    local av,ab=H.SocialAddonVersionFields()
    return SocialSend("P;"..SOCIAL_PROTOCOL..";"..tostring(status)..";"..av..";"..ab,"WHISPER",name)
end

local function FindTRP3SlashHandler()
    local slashList=_G.SlashCmdList
    if type(slashList)~="table" then return nil end

    -- Total RP 3 registers /trp3 and /totalrp3 on SlashCmdList.TOTALRP3.
    -- Use the canonical key first: iterating SlashCmdList is not guaranteed to
    -- expose every entry reliably on current WoW clients.
    if type(slashList.TOTALRP3)=="function" then return slashList.TOTALRP3 end

    -- Compatibility fallback for older/localised TRP builds. Do not stop at the
    -- first missing alias index; slash alias globals can be sparse.
    for commandKey,handler in pairs(slashList) do
        if type(handler)=="function" then
            for index=1,20 do
                local alias=_G["SLASH_"..tostring(commandKey)..tostring(index)]
                if type(alias)=="string" then
                    alias=alias:lower()
                    if alias=="/trp3" or alias=="/totalrp3" then return handler end
                end
            end
        end
    end
    return nil
end

function RPAL.OpenTRPProfile(name)
    name=tostring(name or ""):gsub("^%s+",""):gsub("%s+$","")
    if RPAL.NotifyHoverLogTRPUIOpened then pcall(RPAL.NotifyHoverLogTRPUIOpened) end
    if name=="" then return false,"invalid_target" end

    local handler=FindTRP3SlashHandler()

    -- TRP normally loads at login, but retry once through WoW's addon loader in
    -- case Quick Actions is used while it is still load-on-demand/not initialised.
    if not handler then
        if C_AddOns and type(C_AddOns.LoadAddOn)=="function" then
            pcall(C_AddOns.LoadAddOn,"totalRP3")
        elseif type(_G.LoadAddOn)=="function" then
            pcall(_G.LoadAddOn,"totalRP3")
        end
        handler=FindTRP3SlashHandler()
    end

    if handler then
        local ok,result=pcall(handler,"open "..name)
        if ok then return result~=false end
        return false,tostring(result)
    end

    print("|cffff7777ODYN:|r Total RP 3 profile opener could not be found. Make sure Total RP 3 is enabled and loaded.")
    return false,"trp_unavailable"
end

local PingTarget

local function BroadcastPresence()
    if not SocialCanSend() then return end
    local privacy=RPAL.GetProfilePrivacy and RPAL.GetProfilePrivacy() or {}
    if privacy.hidePublicProfile then return end
    local status=RPAL.GetTRPCharacterStatus and select(1,RPAL.GetTRPCharacterStatus()) or "UNKNOWN"

    -- C_ChatInfo.SendAddonMessage does not provide a nearby SAY transport.
    -- Broadcast only on valid group addon channels; nearby strangers are
    -- resolved through LibMSP and direct whispers when they interact/target.
    local chatType
    if IsInGroup and LE_PARTY_CATEGORY_INSTANCE and IsInGroup(LE_PARTY_CATEGORY_INSTANCE) then
        chatType="INSTANCE_CHAT"
    elseif IsInRaid and IsInRaid() then
        chatType="RAID"
    elseif IsInGroup and IsInGroup() then
        chatType="PARTY"
    end
    if chatType then local av,ab=H.SocialAddonVersionFields();SocialSend("H;"..SOCIAL_PROTOCOL..";"..tostring(status)..";"..av..";"..ab,chatType) end

    if UnitExists("target") and UnitIsPlayer("target") and not UnitIsUnit("target","player") then
        PingTarget()
    end
end

PingTarget=function()
    local privacy=RPAL.GetProfilePrivacy and RPAL.GetProfilePrivacy() or {}
    if privacy.hidePublicProfile then return end
    if not UnitExists("target") or not UnitIsPlayer("target") or UnitIsUnit("target","player") then
        return
    end
    local targetName=SocialFullUnitName("target")
    if not targetName then return end
    local status=RPAL.GetTRPCharacterStatus and select(1,RPAL.GetTRPCharacterStatus()) or "UNKNOWN"
    do local av,ab=H.SocialAddonVersionFields();SocialSend("P;"..SOCIAL_PROTOCOL..";"..tostring(status)..";"..av..";"..ab,"WHISPER",targetName) end
end

function H.CurrentPublicThemeDescriptor()
    local appearance=RPAL.GetCharacterThemeSelection and RPAL.GetCharacterThemeSelection() or {}
    local name=appearance.themeManualOverride and (appearance.themeName or "Obsidian Crown") or "Class Specific"
    local hex=string.format("%02X%02X%02X",
        math.floor((GOLD[1] or 1)*255+0.5),
        math.floor((GOLD[2] or 1)*255+0.5),
        math.floor((GOLD[3] or 1)*255+0.5))
    return name,hex
end

function H.SendInspectSnapshot(target,requestID,intent)
    intent=(intent=="health")and"health"or"profile"
    local privacySettings=RPAL.GetProfilePrivacy and RPAL.GetProfilePrivacy() or {}
    if privacySettings.hidePublicProfile then return false,"profile_hidden" end
    socialRequests._snapshotByPeer=socialRequests._snapshotByPeer or {}
    local transferKey=SocialNormalizeName(target)..":"..intent
    local activeTransfer=socialRequests._snapshotByPeer[transferKey]
    if activeTransfer then return true,"already_sending" end
    if (socialRequests._activeSnapshots or 0)>=3 then return false,"busy" end
    local snapshot=RPAL.GetPublicSocialSnapshot()
    if not snapshot then return false,"snapshot_unavailable" end
    socialRequests._activeSnapshots=(socialRequests._activeSnapshots or 0)+1
    socialRequests._snapshotByPeer[transferKey]=requestID
    -- A defensive lease prevents one unexpected build/ticker error from
    -- permanently occupying the per-peer transfer slot until /reload.
    local snapshotLeaseKey="inspect-send:"..transferKey
    if RPAL.ScheduleUnique then
        RPAL.ScheduleUnique(snapshotLeaseKey,75,function()
            if socialRequests._snapshotByPeer and socialRequests._snapshotByPeer[transferKey]==requestID then
                socialRequests._snapshotByPeer[transferKey]=nil
                socialRequests._activeSnapshots=math.max(0,(socialRequests._activeSnapshots or 1)-1)
            end
        end)
    end

    -- Inspect snapshots can contain dozens of achievement records. Sending the
    -- entire burst in one frame can hit WoW's addon-message throttle and make
    -- later public-detail/Memoir packets appear to vanish. Pace this one inspect
    -- response very lightly while preserving packet order.
    local sendQueue={}
    local commonPacket={D=true,N=true,G=true,C=true,E=true}
    local healthPacket={IJ=true,IG=true,II=true,IN=true,IX=true,IL=true,MN=true,HT=true,HX=true,HY=true}
    local function SnapshotSend(message,chatType,whisperTarget)
        local packetKind=tostring(message or ""):match("^([^;]+)") or ""
        if intent=="health" then
            if not commonPacket[packetKind] and not healthPacket[packetKind] then return true end
        elseif healthPacket[packetKind] then
            return true
        end
        if #sendQueue>=1600 then return false end
        sendQueue[#sendQueue+1]={message,chatType or "WHISPER",whisperTarget or target}
        return true
    end

    local completed={}
    if intent=="profile" then
        for index=1,math.min(200,#(snapshot.achievements or {})) do completed[index]=snapshot.achievements[index] end
    end
    local completedCount=#completed

    -- Skill Set is part of the same public inspect snapshot. Values are sent
    -- by category in compact numeric packets; the receiver already knows the
    -- shared skill catalogue, so no editable/private metadata is transmitted.
    local skillValues=(intent=="profile" and RPAL.GetSkillSetValues and RPAL.GetSkillSetValues()) or {}
    local injurySnapshot=(intent=="health" and RPAL.GetPublicInjurySnapshot and RPAL.GetPublicInjurySnapshot()) or {items={},groups={}}
    local injuryItemIDs,injuryGroupIDs={},{}
    if intent=="health" then
        for id in pairs(injurySnapshot.items or {}) do injuryItemIDs[#injuryItemIDs+1]=tostring(id) end
        for id in pairs(injurySnapshot.groups or {}) do injuryGroupIDs[#injuryGroupIDs+1]=tostring(id) end
    end
    table.sort(injuryItemIDs);table.sort(injuryGroupIDs)
    local injuryItemCount=math.min(200,#injuryItemIDs)
    local injuryGroupCount=math.min(100,#injuryGroupIDs)

    -- Protocol 7 keeps D compact enough to ALWAYS fit the 255-byte payload.
    -- Two tiny extension packets then restore the longer identity strings for
    -- current clients. The final number advertises optional Glance/TRP-field
    -- records; older clients ignore that trailing field.
    local header=table.concat({
        "D",requestID,
        SocialHexEncode(snapshot.displayName,18),
        SocialHexEncode(snapshot.title,18),
        SocialHexEncode(snapshot.race,12),
        SocialHexEncode(snapshot.className,12),
        SocialHexEncode(snapshot.guild,18),
        tostring(snapshot.score or 0),
        tostring(snapshot.totalEvents or 0),
        tostring(snapshot.charactersMet or 0),
        tostring(completedCount),
        tostring(snapshot.achievementTotal or 0),
        tostring(SOCIAL_PROTOCOL),
    },";")
    SnapshotSend(header,"WHISPER",target)
    SnapshotSend(table.concat({
        "N",requestID,SocialHexEncode(snapshot.displayName,42),SocialHexEncode(snapshot.title,42)
    },";"),"WHISPER",target)
    SnapshotSend(table.concat({
        "G",requestID,SocialHexEncode(snapshot.race,28),SocialHexEncode(snapshot.className,28),SocialHexEncode(snapshot.guild,50)
    },";"),"WHISPER",target)

    local themeName,themeHex=H.CurrentPublicThemeDescriptor()
    SnapshotSend(table.concat({
        "C",requestID,SocialHexEncode(themeName,24),themeHex
    },";"),"WHISPER",target)

    if intent=="profile" then
    local cosmetics=snapshot.cosmetics or {}
    -- Protocol 4 splits appearance into two small packets. The previous single
    -- K packet could exceed WoW's 255-byte addon-message payload once long Aura,
    -- Ornament and Effect names were equipped, silently dropping appearance.
    SnapshotSend(table.concat({
        "K",requestID,
        SocialHexEncode(cosmetics.portraitFrame or "Default",18),
        SocialHexEncode(cosmetics.levelBadge or "Initiate Seal",24),
        SocialHexEncode(cosmetics.headerDecoration or "None",18),
        SocialHexEncode(cosmetics.background or "None",24)
    },";"),"WHISPER",target)
    -- Multi-animation selection is sent before the compatibility appearance
    -- record so current clients never briefly start the legacy single emote.
    local selectedAnimationIndices={}
    local selectedAnimations=cosmetics.characterAnimations or {}
    for animationIndex,definition in ipairs(RPAL.BuildProfileAnimationCatalog()) do
        if definition.key~="Stand" and selectedAnimations[definition.key] then
            selectedAnimationIndices[#selectedAnimationIndices+1]=tostring(animationIndex)
        end
    end
    SnapshotSend(table.concat({"L",requestID,table.concat(selectedAnimationIndices,",")},";"),"WHISPER",target)
    SnapshotSend(table.concat({
        "X",requestID,
        SocialHexEncode(cosmetics.profileAura or "None",24),
        SocialHexEncode(cosmetics.uiOrnament or "Classic",24),
        SocialHexEncode(cosmetics.effectPack or "None",24),
        SocialHexEncode(cosmetics.platformStyle or "Default",24),
        SocialHexEncode(cosmetics.characterAnimation or "Stand",18)
    },";"),"WHISPER",target)

    -- Public character-details panel. Split over two compact records so addon
    -- messages stay safely below WoW's payload limit.
    local info=snapshot.information or {}
    SnapshotSend(table.concat({
        "I",requestID,
        SocialHexEncode(info.age or "",18),
        SocialHexEncode(info.height or "",20),
        SocialHexEncode(info.bodyShape or "",24),
        SocialHexEncode(info.pronouns or "",18),
        SocialHexEncode(info.relationship or "",24)
    },";"),"WHISPER",target)
    SnapshotSend(table.concat({
        "J",requestID,
        SocialHexEncode(info.birthplace or "",24),
        SocialHexEncode(info.residence or "",24),
        SocialHexEncode(info.commonlyFound or "",40)
    },";"),"WHISPER",target)

    local publicFlags=snapshot.statuses or {}
    SnapshotSend(table.concat({
        "PF",requestID,
        publicFlags.hungry and "1" or "0",
        publicFlags.dehydrated and "1" or "0",
        publicFlags.injured and "1" or "0",
        publicFlags.healing and "1" or "0"
    },";"),"WHISPER",target)

    -- Protocol 3 redundancy: one public field per tiny packet.
    local publicFields={
        age=info.age,height=info.height,bodyShape=info.bodyShape,pronouns=info.pronouns,
        relationship=info.relationship,birthplace=info.birthplace,residence=info.residence,
        commonlyFound=info.commonlyFound,weight=info.weight,hairColor=info.hairColor,
        eyeColor=info.eyeColor,
    }
    for key,value in pairs(publicFields) do
        SnapshotSend(table.concat({"V",requestID,key,SocialHexEncode(value or "",54)},";"),"WHISPER",target)
    end

    -- Every TRP Additional Information entry is transferred in order. Labels
    -- and values use separate records so long custom values never push an addon
    -- message over WoW's 255-byte limit.
    local additionalFields={}
    local glanceFieldKeys={}
    for slot=1,5 do
        local glance=snapshot.glances and snapshot.glances[slot]
        if type(glance)=="table" then
            local label=tostring(glance.title or ""):gsub("|c%x%x%x%x%x%x%x%x",""):gsub("|r","")
            local value=tostring(glance.description or ""):gsub("|c%x%x%x%x%x%x%x%x",""):gsub("|r","")
            label=label:lower():gsub("[%s%p%c]","")
            value=value:gsub("%s+"," "):gsub("^%s+",""):gsub("%s+$",""):lower()
            if label~="" and value~="" then glanceFieldKeys[label.."\031"..value]=true end
        end
    end
    for _,field in ipairs(info.additionalFields or {}) do
        if #additionalFields>=64 then break end
        local label=tostring(field.label or "")
        local value=tostring(field.value or "")
        local cleanLabel=label:gsub("|c%x%x%x%x%x%x%x%x",""):gsub("|r",""):lower():gsub("[%s%p%c]","")
        local cleanValue=value:gsub("|c%x%x%x%x%x%x%x%x",""):gsub("|r",""):gsub("%s+"," "):gsub("^%s+",""):gsub("%s+$",""):lower()
        if label~="" and value~="" and not glanceFieldKeys[cleanLabel.."\031"..cleanValue] then
            additionalFields[#additionalFields+1]={label=label,value=value}
        end
    end
    SnapshotSend(table.concat({"FC",requestID,tostring(#additionalFields)},";"),"WHISPER",target)
    for index,field in ipairs(additionalFields) do
        local chunkBytes=90
        local totalParts=math.min(12,math.max(1,math.ceil(#field.value/chunkBytes)))
        SnapshotSend(table.concat({
            "FI",requestID,tostring(index),SocialHexEncode(field.label,60),tostring(totalParts)
        },";"),"WHISPER",target)
        for part=1,totalParts do
            local chunk=field.value:sub(((part-1)*chunkBytes)+1,part*chunkBytes)
            SnapshotSend(table.concat({
                "FV",requestID,tostring(index),tostring(part),tostring(totalParts),SocialHexEncode(chunk,chunkBytes)
            },";"),"WHISPER",target)
        end
    end

    -- At First Glance entries keep their original slot, icon and full tooltip
    -- text. Empty slots need no network record; the inspect UI still renders
    -- all five sockets locally.
    local publicGlances={}
    for slot=1,5 do
        local glance=snapshot.glances and snapshot.glances[slot]
        if type(glance)=="table" and ((glance.title or "")~="" or (glance.description or "")~="" or (glance.icon or "")~="") then
            publicGlances[#publicGlances+1]={slot=slot,title=tostring(glance.title or ""),description=tostring(glance.description or ""),icon=tostring(glance.icon or "")}
        end
    end
    SnapshotSend(table.concat({"GC",requestID,tostring(#publicGlances)},";"),"WHISPER",target)
    for _,glance in ipairs(publicGlances) do
        local chunkBytes=90
        local totalParts=(glance.description=="") and 0 or math.min(24,math.max(1,math.ceil(#glance.description/chunkBytes)))
        SnapshotSend(table.concat({
            "GH",requestID,tostring(glance.slot),SocialHexEncode(glance.title,60),tostring(totalParts)
        },";"),"WHISPER",target)
        SnapshotSend(table.concat({
            "GX",requestID,tostring(glance.slot),SocialHexEncode(glance.icon,96)
        },";"),"WHISPER",target)
        for part=1,totalParts do
            local chunk=glance.description:sub(((part-1)*chunkBytes)+1,part*chunkBytes)
            SnapshotSend(table.concat({
                "GD",requestID,tostring(glance.slot),tostring(part),tostring(totalParts),SocialHexEncode(chunk,chunkBytes)
            },";"),"WHISPER",target)
        end
    end

    -- Statistics in one compact record.
    local countParts={"T",requestID}
    for _,key in ipairs(RPAL.STAT_ORDER or {}) do
        countParts[#countParts+1]=key.."="..tostring((snapshot.counts or {})[key] or 0)
    end
    SnapshotSend(table.concat(countParts,";"),"WHISPER",target)

    -- Memoirs are compact, character-specific public milestones from the far
    -- edges of the Renown Atlas. Unknown packet kinds are ignored by old clients.
    -- Re-apply rewards first. This repairs characters that had allocated Memoir
    -- nodes from an older build but whose public memoir map was not populated.
    if RPAL.ApplyTalentRewards then RPAL.ApplyTalentRewards() end
    local memoirs=RPAL.GetUnlockedMemoirs and RPAL.GetUnlockedMemoirs() or {}
    local memoirCount=math.min(64,#memoirs)
    SnapshotSend(table.concat({"Z",requestID,tostring(memoirCount)},";"),"WHISPER",target)
    for memoirIndex=1,memoirCount do
        local item=memoirs[memoirIndex]
        local memoirPacket=table.concat({
            "M",requestID,
            SocialHexEncode(item.id or "",40),
            tostring(item.unlockedAt or 0)
        },";")
        SnapshotSend(memoirPacket,"WHISPER",target)
    end

    -- Pinned Sessions are compact public memories. Titles/locations are
    -- sent separately from the note chunks so every addon message remains
    -- comfortably below WoW's payload limit.
    local pinnedMoments={}
    for index=1,math.min(24,#(snapshot.pinnedMoments or {})) do pinnedMoments[index]=snapshot.pinnedMoments[index] end
    SnapshotSend(table.concat({"U",requestID,tostring(#pinnedMoments)},";"),"WHISPER",target)
    for index,item in ipairs(pinnedMoments) do
        SnapshotSend(table.concat({
            "R",requestID,tostring(index),
            SocialHexEncode(item.title or "A Saved Session",38),
            SocialHexEncode(item.zone or "",24),
            tostring(item.at or 0),
            tostring(item.duration or 0),
            tostring(item.interactions or 0),
            tostring(item.renownGained or 0)
        },";"),"WHISPER",target)
        local locations=item.locations or {}
        SnapshotSend(table.concat({"RC",requestID,tostring(index),tostring(math.min(#locations,24))},";"),"WHISPER",target)
        for li=1,math.min(#locations,24) do
            local loc=locations[li] or {}
            SnapshotSend(table.concat({
                "RL",requestID,tostring(index),tostring(li),
                SocialHexEncode(loc.customName or "",30),
                SocialHexEncode(loc.label or loc.subZone or loc.zone or "Unknown Place",30),
                tostring(loc.firstAt or 0),tostring(loc.lastAt or 0)
            },";"),"WHISPER",target)
        end
        local details=item.peopleDetails or {}
        SnapshotSend(table.concat({"RP",requestID,tostring(index),tostring(math.min(#details,24))},";"),"WHISPER",target)
        for pi=1,math.min(#details,24) do
            local person=details[pi] or {}
            SnapshotSend(table.concat({
                "RR",requestID,tostring(index),tostring(pi),
                SocialHexEncode(person.name or "",30),
                SocialHexEncode(person.trpName or "",30),
                person.met and "1" or "0",person.presence and "1" or "0",
                tostring(person.interactions or 0),person.activeRP and "1" or "0"
            },";"),"WHISPER",target)
        end
        local body=tostring(item.text or "")
        local chunkBytes=80
        local totalParts=math.min(128,math.max(1,math.ceil(#body/chunkBytes)))
        for part=1,totalParts do
            local chunk=body:sub(((part-1)*chunkBytes)+1,part*chunkBytes)
            SnapshotSend(table.concat({"S",requestID,tostring(index),tostring(part),tostring(totalParts),SocialHexEncode(chunk,chunkBytes)},";"),"WHISPER",target)
        end
    end

    -- Pinned Stories use protocol 9 graph packets. Older RPAL clients safely
    -- ignore these unknown kinds; current clients reconstruct a read-only
    -- board whose Watch presentation retains branching choices. Public copies
    -- are deliberately bounded so a very large board cannot crowd the rest of
    -- the profile out of WoW's paced inspect queue; the receiver labels any
    -- bounded copy as partial while the owner's local file remains untouched.
    do
        local MAX_PUBLIC_STORY_STEPS=20
        local MAX_PUBLIC_STORY_LINKS=36
        local MAX_PUBLIC_STORY_NOTE_PARTS=2
        local MAX_PUBLIC_STORY_BONDS=3
        local STORY_NOTE_CHUNK_BYTES=80
        local pinnedStories={}
        for index=1,math.min(3,#(snapshot.pinnedStories or {})) do pinnedStories[index]=snapshot.pinnedStories[index] end
        SnapshotSend(table.concat({"SC",requestID,tostring(#pinnedStories)},";"),"WHISPER",target)
        for arcIndex,story in ipairs(pinnedStories) do
            if RPAL.NormalizeStoryArc then story=RPAL.NormalizeStoryArc(story) end
            local stepIDs={}
            local stepIndexByID={}
            for stepIndex=1,math.min(MAX_PUBLIC_STORY_STEPS,#(story.stepOrder or {})) do
                local stepID=story.stepOrder[stepIndex]
                if story.steps and story.steps[stepID] then
                    stepIDs[#stepIDs+1]=stepID
                    stepIndexByID[stepID]=#stepIDs
                end
            end
            local rootIndex=stepIndexByID[story.rootStepID] or 1
            local storyNote=tostring(story.note or "")
            local storyNoteParts=#storyNote>0 and math.min(MAX_PUBLIC_STORY_NOTE_PARTS,math.ceil(#storyNote/STORY_NOTE_CHUNK_BYTES)) or 0
            local storyWasTruncated=#(story.stepOrder or {})>#stepIDs
                or #(story.links or {})>MAX_PUBLIC_STORY_LINKS
                or #storyNote>(MAX_PUBLIC_STORY_NOTE_PARTS*STORY_NOTE_CHUNK_BYTES)
            for _,stepID in ipairs(stepIDs) do
                local step=story.steps[stepID] or {}
                local bondNames=RPAL.GetStoryArcBondNames and RPAL.GetStoryArcBondNames(step) or {}
                if #tostring(step.note or "")>(MAX_PUBLIC_STORY_NOTE_PARTS*STORY_NOTE_CHUNK_BYTES) or #bondNames>MAX_PUBLIC_STORY_BONDS then
                    storyWasTruncated=true
                end
            end
            SnapshotSend(table.concat({
                "SH",requestID,tostring(arcIndex),
                SocialHexEncode(story.title or "A Story Arc",38),
                story.completed and "1" or "0",
                tostring(story.createdAt or 0),tostring(story.updatedAt or 0),
                tostring(rootIndex),tostring(storyNoteParts),tostring(story.musicID or ""),
                SocialHexEncode(story.musicName or "",38)
            },";"),"WHISPER",target)
            for part=1,storyNoteParts do
                local chunk=storyNote:sub(((part-1)*STORY_NOTE_CHUNK_BYTES)+1,part*STORY_NOTE_CHUNK_BYTES)
                SnapshotSend(table.concat({"SN",requestID,tostring(arcIndex),tostring(part),tostring(storyNoteParts),SocialHexEncode(chunk,STORY_NOTE_CHUNK_BYTES)},";"),"WHISPER",target)
            end

            SnapshotSend(table.concat({"ST",requestID,tostring(arcIndex),tostring(#stepIDs),tostring(#(story.stepOrder or {})),storyWasTruncated and "1" or "0"},";"),"WHISPER",target)
            for stepIndex,stepID in ipairs(stepIDs) do
                local step=story.steps[stepID] or {}
                local stepNote=tostring(step.note or "")
                local noteParts=#stepNote>0 and math.min(MAX_PUBLIC_STORY_NOTE_PARTS,math.ceil(#stepNote/STORY_NOTE_CHUNK_BYTES)) or 0
                local bondNames=RPAL.GetStoryArcBondNames and RPAL.GetStoryArcBondNames(step) or {}
                local bondCount=math.min(MAX_PUBLIC_STORY_BONDS,#bondNames)
                SnapshotSend(table.concat({
                    "SE",requestID,tostring(arcIndex),tostring(stepIndex),
                    SocialHexEncode(step.title or "Story Event",36),
                    SocialHexEncode(step.location or "",30),
                    tostring(math.floor(tonumber(step.x) or 0)),tostring(math.floor(tonumber(step.y) or 0)),
                    step.completed and "1" or "0",tostring(noteParts),tostring(bondCount)
                },";"),"WHISPER",target)
                for part=1,noteParts do
                    local chunk=stepNote:sub(((part-1)*STORY_NOTE_CHUNK_BYTES)+1,part*STORY_NOTE_CHUNK_BYTES)
                    SnapshotSend(table.concat({"SD",requestID,tostring(arcIndex),tostring(stepIndex),tostring(part),tostring(noteParts),SocialHexEncode(chunk,STORY_NOTE_CHUNK_BYTES)},";"),"WHISPER",target)
                end
                for bondIndex=1,bondCount do
                    SnapshotSend(table.concat({
                        "SP",requestID,tostring(arcIndex),tostring(stepIndex),tostring(bondIndex),
                        SocialHexEncode(bondNames[bondIndex] or "",60)
                    },";"),"WHISPER",target)
                end
            end

            local publicLinks={}
            for _,link in ipairs(story.links or {}) do
                if #publicLinks>=MAX_PUBLIC_STORY_LINKS then break end
                local fromIndex,toIndex=stepIndexByID[link.from],stepIndexByID[link.to]
                if fromIndex and toIndex then publicLinks[#publicLinks+1]={link=link,from=fromIndex,to=toIndex} end
            end
            SnapshotSend(table.concat({"SL",requestID,tostring(arcIndex),tostring(#publicLinks),tostring(#(story.links or {}))},";"),"WHISPER",target)
            for linkIndex,item in ipairs(publicLinks) do
                SnapshotSend(table.concat({
                    "SX",requestID,tostring(arcIndex),tostring(linkIndex),tostring(item.from),tostring(item.to),
                    SocialHexEncode(item.link.label or "",30),tostring(item.link.order or linkIndex)
                },";"),"WHISPER",target)
            end
        end
    end

    -- Protocol 4 achievement transport. Hex encoding doubles every byte, so
    -- the old all-in-one A packet could silently exceed 255 bytes. Keep A as a
    -- compact backwards-readable record, then send icon + description in safe
    -- extension packets.
    for index,item in ipairs(completed) do
        local packet=table.concat({
            "A",requestID,tostring(index),tostring(completedCount),
            item.favourite and "1" or "0",
            SocialHexEncode(item.category or "",24),
            SocialHexEncode(item.name or "",50),
            "","",
        },";")
        SnapshotSend(packet,"WHISPER",target)
        SnapshotSend(table.concat({
            "B",requestID,tostring(index),SocialHexEncode(item.icon or "",96)
        },";"),"WHISPER",target)

        local description=tostring(item.description or "")
        local chunkBytes=90
        local totalParts=math.min(64,math.max(1,math.ceil(#description/chunkBytes)))
        for part=1,totalParts do
            local chunk=description:sub(((part-1)*chunkBytes)+1,part*chunkBytes)
            SnapshotSend(table.concat({
                "W",requestID,tostring(index),tostring(part),tostring(totalParts),SocialHexEncode(chunk,chunkBytes)
            },";"),"WHISPER",target)
        end
    end

    for _,category in ipairs(RPAL.SKILL_SET_CATEGORIES or {}) do
        local values={}
        for _,skill in ipairs(category.skills or {}) do
            values[#values+1]=tostring(math.max(0,math.min(5,math.floor(tonumber(skillValues[skill]) or 0))))
        end
        SnapshotSend(table.concat({"SS",requestID,category.id or "",table.concat(values,",")},";"),"WHISPER",target)
    end
    end

    -- Health records are public inspect data.  Positions are normalized to the supplied
    -- body silhouette and timers are transmitted as remaining seconds, so the
    -- inspector sees a live snapshot without receiving private edit metadata.
    if intent=="health" then
    local healthHistory=injurySnapshot.history or {}
    local healthHistoryCount=math.min(60,#healthHistory)
    local anatomy=injurySnapshot.anatomy or {}
    local traitNames={}
    for _,trait in ipairs({"tail","wings","horns","hooves"}) do if anatomy.traits and anatomy.traits[trait] then traitNames[#traitNames+1]=trait end end
    SnapshotSend(table.concat({"IJ",requestID,tostring(injuryItemCount),tostring(injuryGroupCount),tostring(healthHistoryCount),SocialHexEncode(anatomy.preset or "Humanoid",28),table.concat(traitNames,",")},";"),"WHISPER",target)
    for groupIndex=1,injuryGroupCount do
        local gid=injuryGroupIDs[groupIndex];local g=injurySnapshot.groups[gid]
        SnapshotSend(table.concat({"IG",requestID,tostring(gid),SocialHexEncode(g.name or "Injury Group",42),tostring(g.remaining and math.max(0,math.floor(tonumber(g.remaining) or 0)) or "")},";"),"WHISPER",target)
    end
    for itemIndex=1,injuryItemCount do
        local id=injuryItemIDs[itemIndex];local item=injurySnapshot.items[id]
        SnapshotSend(table.concat({"II",requestID,tostring(id),SocialHexEncode(item.type or "Cut",24),SocialHexEncode(item.healing or "",24),tostring(item.category or "injury"),string.format("%.4f",tonumber(item.x) or .5),string.format("%.4f",tonumber(item.y) or .5),tostring(item.groupId or ""),tostring(item.remaining and math.max(0,math.floor(tonumber(item.remaining) or 0)) or ""),tostring(item.side or "front"),SocialHexEncode(item.customName or "",40),string.format("%.2f",tonumber(item.scale) or 1),tostring(math.floor(tonumber(item.rotation) or 0)%360)},";"),"WHISPER",target)
        local note=tostring(item.note or "")
        if note~="" then
            local noteChunkBytes=76;local noteParts=math.min(8,math.max(1,math.ceil(#note/noteChunkBytes)))
            for part=1,noteParts do
                local chunk=note:sub(((part-1)*noteChunkBytes)+1,part*noteChunkBytes)
                SnapshotSend(table.concat({"IN",requestID,tostring(id),tostring(part),tostring(noteParts),SocialHexEncode(chunk,noteChunkBytes)},";"),"WHISPER",target)
            end
        end
        local symptoms=table.concat(item.symptoms or {},",")
        SnapshotSend(table.concat({"IX",requestID,tostring(id),tostring(item.stage or ""),tostring(item.severity or ""),SocialHexEncode(item.region or "",24),tostring(item.privacy or "public"),tostring(item.originId or ""),SocialHexEncode(symptoms,36)},";"),"WHISPER",target)
        if tostring(item.originLabel or "")~="" then
            SnapshotSend(table.concat({"IL",requestID,tostring(id),SocialHexEncode(item.originLabel or "",52)},";"),"WHISPER",target)
        end
        for noteIndex=1,math.min(4,#(item.medicalNotes or {})) do
            local mn=item.medicalNotes[noteIndex]
            SnapshotSend(table.concat({"MN",requestID,tostring(id),tostring(noteIndex),tostring(math.floor(tonumber(mn.at) or 0)),SocialHexEncode(mn.author or "",26),SocialHexEncode(mn.text or "",62)},";"),"WHISPER",target)
        end
    end
    for historyIndex=1,healthHistoryCount do
        local e=healthHistory[historyIndex]
        SnapshotSend(table.concat({"HT",requestID,tostring(historyIndex),tostring(math.floor(tonumber(e.at) or 0)),SocialHexEncode(e.kind or "update",14),SocialHexEncode(e.label or "Health Record",52)},";"),"WHISPER",target)
        if tostring(e.from or "")~="" or tostring(e.to or "")~="" or tostring(e.actor or "")~="" then
            SnapshotSend(table.concat({"HX",requestID,tostring(historyIndex),SocialHexEncode(e.from or "",28),SocialHexEncode(e.to or "",28),SocialHexEncode(e.actor or "",26)},";"),"WHISPER",target)
        end
        if tostring(e.note or "")~="" or tostring(e.sceneTitle or "")~="" then
            SnapshotSend(table.concat({"HY",requestID,tostring(historyIndex),SocialHexEncode(e.note or "",54),SocialHexEncode(e.sceneTitle or "",34)},";"),"WHISPER",target)
        end
    end
    end

    if not SnapshotSend("E;"..requestID,"WHISPER",target) then
        sendQueue[#sendQueue]={"E;"..requestID,"WHISPER",target}
    end

    -- One ticker owns the paced queue. This avoids creating hundreds of
    -- independent timers for a single inspect response.
    local sendIndex,snapshotTicker,snapshotReleased=0,nil,false
    local function ReleaseSnapshotSlot()
        if snapshotReleased then return end
        snapshotReleased=true
        if RPAL.CancelScheduled then RPAL.CancelScheduled(snapshotLeaseKey) end
        local ownsSlot=socialRequests._snapshotByPeer and socialRequests._snapshotByPeer[transferKey]==requestID
        if ownsSlot then
            socialRequests._activeSnapshots=math.max(0,(socialRequests._activeSnapshots or 1)-1)
            socialRequests._snapshotByPeer[transferKey]=nil
        end
    end
    local function SendNextSnapshotPacket()
        sendIndex=sendIndex+1
        local packet=sendQueue[sendIndex]
        if not packet then
            if snapshotTicker then snapshotTicker:Cancel();snapshotTicker=nil end
            ReleaseSnapshotSlot()
            return
        end
        SocialSend(packet[1],packet[2],packet[3])
    end
    SendNextSnapshotPacket()
    if sendIndex<#sendQueue and C_Timer and C_Timer.NewTicker then
        snapshotTicker=C_Timer.NewTicker(0.035,SendNextSnapshotPacket)
    else
        while sendIndex<#sendQueue do SendNextSnapshotPacket() end
        ReleaseSnapshotSlot()
    end
    return true
end

function H.EnsureInspectRequest(sender,requestID)
    local key=SocialNormalizeName(sender)
    requestID=tostring(requestID or "")
    if key=="" or #requestID<1 or #requestID>32 or not requestID:match("^[%w_%-]+$") then return nil end
    socialRequests[key]=socialRequests[key] or {}
    local now=GetTime and GetTime() or 0
    local count,oldestID,oldestAt=0,nil,nil
    for id,entry in pairs(socialRequests[key]) do
        if type(entry)~="table" or now-(tonumber(entry.receivedAt) or 0)>180 then
            socialRequests[key][id]=nil
        else
            count=count+1
            if not oldestAt or (entry.receivedAt or 0)<oldestAt then oldestID=id;oldestAt=entry.receivedAt or 0 end
        end
    end
    local request=socialRequests[key][requestID]
    if not request then
        if count>=3 and oldestID then socialRequests[key][oldestID]=nil end
        request={
            sender=sender,
            requestID=requestID,
            header=nil,
            headerBaseReceived=false,
            counts={},
            statisticsPacketReceived=false,
            achievements={},
            achievementDescriptionParts={},
            memoirs={},
            pinnedMoments={},
            pinnedStories={},
            momentTextParts={},
            storyTextParts={},
            storyStepTextParts={},
            information={},
            infoPacketReceived=false,
            additionalFields={},
            additionalFieldParts={},
            additionalFieldExpected=nil,
            glances={},
            glanceDescriptionParts={},
            glanceExpected=nil,
            skillSet={},
            skillSetReceived=false,
            injuries={items={},groups={}},
            injuryItemExpected=nil,
            injuryGroupExpected=nil,
            receivedAt=now,
            receivedBytes=0,
            finished=false,
            complete=false,
        }
        socialRequests[key][requestID]=request
    end
    return request
end

function RPAL.RequestSocialInspect(name,mode)
    if not name or name=="" then return false,"invalid_target" end
    mode=(mode=="health")and"health"or"profile"
    local now=GetTime and GetTime() or 0
    local key=SocialNormalizeName(name)
    if key=="" then return false,"invalid_target" end
    local rateKey=key..":"..mode
    local function Open(requestID)
        if mode=="health" and RPAL.OpenRemoteHealthInspect then
            RPAL.OpenRemoteHealthInspect(name,requestID)
        elseif RPAL.OpenRemoteInspect then
            RPAL.OpenRemoteInspect(name,requestID)
        end
    end

    socialRequests._activeOutbound=socialRequests._activeOutbound or {}
    local active=socialRequests._activeOutbound[rateKey]
    local activeBucket=active and socialRequests[key]
    local activeRequest=activeBucket and activeBucket[active.requestID]
    if type(activeRequest)=="table" and not activeRequest.complete and not activeRequest.private and now-(activeRequest.requestedAt or 0)<60 then
        Open(active.requestID)
        return true,"already_pending"
    end

    -- A very recent completed snapshot opens instantly without another network
    -- burst. Check the active refresh first so a window opened during that
    -- refresh remains bound to its request and repaints as new packets arrive.
    -- Older snapshots also open instantly, then refresh in the background.
    local cached,cachedAge,cachedFresh=RPAL.GetInspectSnapshot and RPAL.GetInspectSnapshot(name,mode,mode=="health" and 6 or 12)
    if cached and cachedFresh then
        Open(nil)
        return true,"cache_fresh"
    end

    socialRequests._outboundRate=socialRequests._outboundRate or {}
    if now-(socialRequests._outboundRate[rateKey] or -100)>1 then
        socialRequests._outboundRate[rateKey]=now
    else
        Open(active and active.requestID or nil)
        return true,"coalesced"
    end
    socialRequests._serial=(socialRequests._serial or 0)+1
    local requestID=tostring(socialRequests._serial).."x"..tostring(math.floor(now*10))
    local request=H.EnsureInspectRequest(name,requestID)
    if not request then return false,"request_unavailable" end
    request.receivedAt=now;request.requestedAt=now;request.lastSentAt=now;request.outbound=true;request.receivedBytes=0;request.intent=mode;request.retryCount=0;request.timedOut=false
    socialRequests._activeOutbound[rateKey]={requestID=requestID,startedAt=now}
    local av,ab=H.SocialAddonVersionFields();local sent,sendReason=SocialSend("Q;"..requestID..";"..(mode=="health"and"H"or"P")..";"..av..";"..ab,"WHISPER",name)
    if not sent then request.sendFailed=sendReason or true end
    Open(requestID)

    local monitorKey="inspect:"..rateKey
    local function RefreshWindow()
        if mode=="health" and RPAL.RefreshRemoteHealthInspect then RPAL.RefreshRemoteHealthInspect(name,requestID)
        elseif RPAL.RefreshRemoteInspect then RPAL.RefreshRemoteInspect(name,requestID) end
    end
    local function Monitor()
        local currentBucket=socialRequests[key]
        local current=currentBucket and currentBucket[requestID]
        local pointer=socialRequests._activeOutbound and socialRequests._activeOutbound[rateKey]
        if not current or not pointer or pointer.requestID~=requestID then return end
        local checkNow=GetTime and GetTime() or 0
        local complete=H.IsInspectRequestComplete(current)
        if current.private or complete then
            current.complete=complete and true or false
            socialRequests._activeOutbound[rateKey]=nil
            RefreshWindow()
            return
        end
        if checkNow-(current.requestedAt or checkNow)>=60 then
            current.timedOut=true
            socialRequests._activeOutbound[rateKey]=nil
            RefreshWindow()
            return
        end
        local quietFor=checkNow-(current.receivedAt or current.requestedAt or checkNow)
        if quietFor>=6 and (current.retryCount or 0)<2 and checkNow-(current.lastSentAt or 0)>=6 then
            current.retryCount=(current.retryCount or 0)+1
            current.lastSentAt=checkNow
            do local av,ab=H.SocialAddonVersionFields();SocialSend("Q;"..requestID..";"..(mode=="health"and"H"or"P")..";"..av..";"..ab,"WHISPER",name) end
        end
        if RPAL.ScheduleUnique then RPAL.ScheduleUnique(monitorKey,3,Monitor)
        elseif C_Timer and C_Timer.After then C_Timer.After(3,Monitor) end
    end
    if RPAL.ScheduleUnique then RPAL.ScheduleUnique(monitorKey,3,Monitor)
    elseif C_Timer and C_Timer.After then C_Timer.After(3,Monitor) end
    return true,sendReason or "requested"
end

function RPAL.HandleSocialAddonMessage(prefix,text,channel,sender)
    if RPAL.Combat and prefix==RPAL.Combat.PREFIX then
        if RPAL.Combat and RPAL.Combat.HandleMessage then RPAL.Combat.HandleMessage(prefix,text,channel,sender) end
        return
    end
    if prefix~=SOCIAL_PREFIX or type(text)~="string" or #text>255 or type(sender)~="string" then return end
    if SocialNormalizeName(sender)==SocialNormalizeName(UnitName("player")) then return end

    local fields=H.SocialSplit(text,";")
    local kind=fields[1]
    if kind=="H" then
        if channel~="PARTY" and channel~="RAID" and channel~="INSTANCE_CHAT" then return end
    elseif channel~="WHISPER" then
        return
    end

    if kind=="H" then
        SocialMarkPresent(sender,fields[2],fields[3],fields[4],fields[5])
        return
    elseif kind=="P" then
        local privacy=RPAL.GetProfilePrivacy and RPAL.GetProfilePrivacy() or {}
        if privacy.hidePublicProfile then return end
        SocialMarkPresent(sender,fields[2],fields[3],fields[4],fields[5])
        local ownStatus=RPAL.GetTRPCharacterStatus and select(1,RPAL.GetTRPCharacterStatus()) or "UNKNOWN"
        do local av,ab=H.SocialAddonVersionFields();SocialSend("O;"..SOCIAL_PROTOCOL..";"..tostring(ownStatus)..";"..av..";"..ab,"WHISPER",sender) end
        return
    elseif kind=="O" then
        SocialMarkPresent(sender,fields[2],fields[3],fields[4],fields[5])
        return
    elseif kind=="Q" then
        local requestID=tostring(fields[2] or "")
        if #requestID<1 or #requestID>32 or not requestID:match("^[%w_%-]+$") then return end
        local privacy=RPAL.GetProfilePrivacy and RPAL.GetProfilePrivacy() or {}
        if privacy.hidePublicProfile then
            SocialSend("PV;"..requestID..";PRIVATE","WHISPER",sender)
            return
        end
        local senderKey=SocialNormalizeName(sender)
        SocialMarkPresent(sender,SOCIAL_PROTOCOL,nil,fields[4],fields[5])
        local intent=(fields[3]=="H")and"health"or"profile"
        local rateKey=senderKey..":"..intent
        local now=GetTime and GetTime() or 0
        socialRequests._inboundRate=socialRequests._inboundRate or {}
        if now-(socialRequests._inboundRate[rateKey] or -100)<6 then return end
        socialRequests._inboundRate[rateKey]=now
        SocialMarkPresent(sender,SOCIAL_PROTOCOL)
        H.SendInspectSnapshot(sender,requestID,intent)
        return
    elseif kind=="LP" then
        local version=tonumber(fields[2])
        if not version or version<9 or version>99 then return end
        local name=SocialHexDecode(fields[3])
        if #name<2 or #name>80 then return end
        local senderKey=SocialNormalizeName(sender)
        local now=(GetTime and GetTime()) or 0
        socialRequests._locationReceive=socialRequests._locationReceive or {}
        local rate=socialRequests._locationReceive[senderKey]
        if not rate or now-(rate.startedAt or 0)>600 then rate={startedAt=now,count=0,replied=false};socialRequests._locationReceive[senderKey]=rate end
        if (rate.count or 0)>=12 then return end
        rate.count=(rate.count or 0)+1
        SocialMarkPresent(sender,version)
        if RPAL.LearnStoryLocation then RPAL.LearnStoryLocation(name,"peer",sender) end
        if not rate.replied then rate.replied=true;RPAL.ShareStoryLocationsWith(sender,true) end
        return
    end

    local requestID=tostring(fields[2] or "")
    if #requestID<1 or #requestID>32 or not requestID:match("^[%w_%-]+$") then return end
    local senderKey=SocialNormalizeName(sender)
    local bucket=socialRequests[senderKey]
    local request=bucket and bucket[requestID]
    local now=GetTime and GetTime() or 0
    if type(request)~="table" or not request.outbound or now-(request.requestedAt or 0)>120 then return end
    request.receivedBytes=(request.receivedBytes or 0)+#text
    if request.receivedBytes>262144 then bucket[requestID]=nil;return end
    if kind~="PV" then SocialMarkPresent(sender,SOCIAL_PROTOCOL) end
    request.receivedAt=now

    local function PacketInteger(value,minimum,maximum)
        local number=tonumber(value)
        if not number or number~=number or number%1~=0 or number<minimum or number>maximum then return nil end
        return number
    end

    if kind=="PV" then
        request.private=true
        request.finished=true
        request.complete=true
        request.privateReason=tostring(fields[3] or "PRIVATE")
        if RPAL.InvalidateInspectSnapshot then
            RPAL.InvalidateInspectSnapshot(sender,"profile")
            RPAL.InvalidateInspectSnapshot(sender,"health")
        end
        if socialRequests._activeOutbound then socialRequests._activeOutbound[senderKey..":"..tostring(request.intent or "profile")]=nil end
        socialPresence[senderKey]=nil
        if RPAL.UpdateTargetBadge then RPAL.UpdateTargetBadge() end
        if RPAL.RefreshRemoteInspect then RPAL.RefreshRemoteInspect(sender,requestID) end
        if RPAL.RefreshRemoteHealthInspect then RPAL.RefreshRemoteHealthInspect(sender,requestID) end
        return
    elseif kind=="D" then
        if request.finished and not request.complete then request.finished=false end
        request.headerBaseReceived=true
        request.snapshotProtocol=PacketInteger(fields[13],1,99) or request.snapshotProtocol
        request.header={
            displayName=SocialHexDecode(fields[3]),
            title=SocialHexDecode(fields[4]),
            race=SocialHexDecode(fields[5]),
            className=SocialHexDecode(fields[6]),
            guild=SocialHexDecode(fields[7]),
            score=PacketInteger(fields[8],0,1000000000) or 0,
            totalEvents=PacketInteger(fields[9],0,1000000000) or 0,
            charactersMet=PacketInteger(fields[10],0,1000000) or 0,
            achievementCompleted=PacketInteger(fields[11],0,200) or 0,
            achievementTotal=PacketInteger(fields[12],0,1000000) or 0,
        }
    elseif kind=="N" then
        request.header=request.header or {}
        request.header.displayName=SocialHexDecode(fields[3])
        request.header.title=SocialHexDecode(fields[4])
    elseif kind=="G" then
        request.header=request.header or {}
        request.header.race=SocialHexDecode(fields[3])
        request.header.className=SocialHexDecode(fields[4])
        request.header.guild=SocialHexDecode(fields[5])
    elseif kind=="C" then
        request.themeName=SocialHexDecode(fields[3])
        request.themeHex=tostring(fields[4] or ""):match("^%x%x%x%x%x%x$") and fields[4] or ""
    elseif kind=="K" then
        request.cosmetics=request.cosmetics or {}
        request.cosmetics.portraitFrame=SocialHexDecode(fields[3])
        request.cosmetics.levelBadge=SocialHexDecode(fields[4])
        request.cosmetics.headerDecoration=SocialHexDecode(fields[5])
        request.cosmetics.background=SocialHexDecode(fields[6]) or "None"
        -- Protocol <=3 included these in K. Preserve them when present.
        if fields[7] then request.cosmetics.profileAura=SocialHexDecode(fields[7]) or "None" end
        if fields[8] then request.cosmetics.uiOrnament=SocialHexDecode(fields[8]) or "Classic" end
        if fields[9] then request.cosmetics.effectPack=SocialHexDecode(fields[9]) or "None" end
    elseif kind=="X" then
        request.cosmetics=request.cosmetics or {}
        request.cosmeticsPacketReceived=true
        request.cosmetics.profileAura=SocialHexDecode(fields[3]) or "None"
        request.cosmetics.uiOrnament=SocialHexDecode(fields[4]) or "Classic"
        request.cosmetics.effectPack=SocialHexDecode(fields[5]) or "None"
        request.cosmetics.platformStyle=SocialHexDecode(fields[6]) or "Default"
        request.cosmetics.characterAnimation=SocialHexDecode(fields[7]) or "Stand"
    elseif kind=="L" then
        request.cosmetics=request.cosmetics or {}
        request.cosmetics.characterAnimations={}
        local catalog=RPAL.BuildProfileAnimationCatalog()
        for rawIndex in tostring(fields[3] or ""):gmatch("[^,]+") do
            local definition=catalog[PacketInteger(rawIndex,1,#catalog) or 0]
            if definition and definition.key~="Stand" then
                request.cosmetics.characterAnimations[definition.key]=true
            end
        end
    elseif kind=="I" then
        request.information=request.information or {}
        request.infoPacketReceived=true
        request.information.age=SocialHexDecode(fields[3])
        request.information.height=SocialHexDecode(fields[4])
        request.information.bodyShape=SocialHexDecode(fields[5])
        request.information.pronouns=SocialHexDecode(fields[6])
        request.information.relationship=SocialHexDecode(fields[7])
    elseif kind=="J" then
        request.information=request.information or {}
        request.infoPacketReceived=true
        request.information.birthplace=SocialHexDecode(fields[3])
        request.information.residence=SocialHexDecode(fields[4])
        request.information.commonlyFound=SocialHexDecode(fields[5])
    elseif kind=="PF" then
        request.publicFlags={
            hungry=fields[3]=="1",
            dehydrated=fields[4]=="1",
            injured=fields[5]=="1",
            healing=fields[6]=="1",
        }
    elseif kind=="V" then
        request.information=request.information or {}
        request.infoPacketReceived=true
        local fieldKey=tostring(fields[3] or "")
        local allowed={
            age=true,height=true,bodyShape=true,weight=true,hairColor=true,eyeColor=true,
            pronouns=true,relationship=true,birthplace=true,residence=true,commonlyFound=true,
        }
        if allowed[fieldKey] then request.information[fieldKey]=SocialHexDecode(fields[4]) end
    elseif kind=="FC" then
        request.additionalFieldExpected=PacketInteger(fields[3],0,64)
        request.additionalFields={}
        request.additionalFieldParts={}
        request.information=request.information or {}
        request.information.additionalFields=request.additionalFields
    elseif kind=="FI" then
        local index=PacketInteger(fields[3],1,64)
        local total=PacketInteger(fields[5],1,12)
        if index and total then
            request.additionalFields=request.additionalFields or {}
            request.additionalFieldParts=request.additionalFieldParts or {}
            local item=request.additionalFields[index] or {}
            item.label=SocialHexDecode(fields[4]) or "Additional Field"
            item._valueExpected=total
            request.additionalFields[index]=item
            request.information=request.information or {}
            request.information.additionalFields=request.additionalFields
            request.additionalFieldParts[index]=request.additionalFieldParts[index] or {total=total,chunks={}}
        end
    elseif kind=="FV" then
        local index=PacketInteger(fields[3],1,64)
        local part=PacketInteger(fields[4],1,12)
        local total=PacketInteger(fields[5],1,12)
        if index and part and total and part<=total then
            request.additionalFields=request.additionalFields or {}
            request.additionalFieldParts=request.additionalFieldParts or {}
            local parts=request.additionalFieldParts[index] or {total=total,chunks={}}
            if parts.total~=total then return end
            request.additionalFieldParts[index]=parts
            parts.chunks[part]=SocialHexDecode(fields[6]) or ""
            local ready=true
            local built={}
            for i=1,total do
                if parts.chunks[i]==nil then ready=false break end
                built[#built+1]=parts.chunks[i]
            end
            if ready then
                local item=request.additionalFields[index] or {label="Additional Field"}
                item.value=table.concat(built)
                item._valueComplete=true
                request.additionalFields[index]=item
                request.information=request.information or {}
                request.information.additionalFields=request.additionalFields
            end
        end
    elseif kind=="GC" then
        request.glanceExpected=PacketInteger(fields[3],0,5)
        request.glances={}
        request.glanceDescriptionParts={}
    elseif kind=="GH" then
        local slot=PacketInteger(fields[3],1,5)
        local total=PacketInteger(fields[5],0,24)
        if slot and total then
            request.glances=request.glances or {}
            request.glanceDescriptionParts=request.glanceDescriptionParts or {}
            local glance=request.glances[slot] or {}
            glance.title=SocialHexDecode(fields[4]) or ""
            glance._headerReceived=true
            glance._descriptionExpected=total
            if total==0 then glance.description="";glance._descriptionComplete=true end
            request.glances[slot]=glance
            if total>0 then request.glanceDescriptionParts[slot]=request.glanceDescriptionParts[slot] or {total=total,chunks={}} end
        end
    elseif kind=="GX" then
        local slot=PacketInteger(fields[3],1,5)
        if slot then
            request.glances=request.glances or {}
            local glance=request.glances[slot] or {}
            glance.icon=SocialHexDecode(fields[4]) or ""
            glance._iconReceived=true
            request.glances[slot]=glance
        end
    elseif kind=="GD" then
        local slot=PacketInteger(fields[3],1,5)
        local part=PacketInteger(fields[4],1,24)
        local total=PacketInteger(fields[5],1,24)
        if slot and part and total and part<=total then
            request.glances=request.glances or {}
            request.glanceDescriptionParts=request.glanceDescriptionParts or {}
            local parts=request.glanceDescriptionParts[slot] or {total=total,chunks={}}
            if parts.total~=total then return end
            request.glanceDescriptionParts[slot]=parts
            parts.chunks[part]=SocialHexDecode(fields[6]) or ""
            local ready=true
            local built={}
            for i=1,total do
                if parts.chunks[i]==nil then ready=false break end
                built[#built+1]=parts.chunks[i]
            end
            if ready then
                local glance=request.glances[slot] or {}
                glance.description=table.concat(built)
                glance._descriptionComplete=true
                request.glances[slot]=glance
            end
        end
    elseif kind=="Y" then
        request.memoirs=request.memoirs or {}
        local memoirID=SocialHexDecode(fields[3])
        if memoirID and memoirID~="" and #memoirID<=80 and (request.memoirs[memoirID] or (request.memoirReceived or 0)<64) then
            if not request.memoirs[memoirID] then request.memoirReceived=(request.memoirReceived or 0)+1 end
            request.memoirs[memoirID]={id=memoirID,unlockedAt=PacketInteger(fields[4],0,4000000000) or 0}
        end
    elseif kind=="Z" then
        request.memoirExpected=PacketInteger(fields[3],0,64)
    elseif kind=="U" then
        request.momentExpected=PacketInteger(fields[3],0,24)
        request.pinnedMoments=request.pinnedMoments or {}
    elseif kind=="R" then
        local index=PacketInteger(fields[3],1,24)
        if index then
            request.pinnedMoments=request.pinnedMoments or {}
            local item=request.pinnedMoments[index] or {}
            item.title=SocialHexDecode(fields[4])
            item.zone=SocialHexDecode(fields[5])
            item.at=PacketInteger(fields[6],0,4000000000) or 0
            item.duration=PacketInteger(fields[7],0,315360000) or 0
            item.interactions=PacketInteger(fields[8],0,1000000) or 0
            item.renownGained=PacketInteger(fields[9],0,1000000000) or 0
            request.pinnedMoments[index]=item
        end
    elseif kind=="RC" then
        local index=PacketInteger(fields[3],1,24);local expected=PacketInteger(fields[4],0,24)
        if index and expected then
            request.pinnedMoments=request.pinnedMoments or {};request.pinnedMoments[index]=request.pinnedMoments[index] or {}
            request.pinnedMoments[index].locations=request.pinnedMoments[index].locations or {}
            request.pinnedMoments[index].locationExpected=expected
        end
    elseif kind=="RL" then
        local index=PacketInteger(fields[3],1,24);local li=PacketInteger(fields[4],1,24)
        if index and li then
            request.pinnedMoments=request.pinnedMoments or {};request.pinnedMoments[index]=request.pinnedMoments[index] or {}
            local item=request.pinnedMoments[index];item.locations=item.locations or {}
            item.locations[li]={
                customName=SocialHexDecode(fields[5]) or "",
                label=SocialHexDecode(fields[6]) or "Unknown Place",
                firstAt=PacketInteger(fields[7],0,4000000000) or 0,lastAt=PacketInteger(fields[8],0,4000000000) or 0,
            }
        end
    elseif kind=="RP" then
        local index=PacketInteger(fields[3],1,24);local expected=PacketInteger(fields[4],0,24)
        if index and expected then
            request.pinnedMoments=request.pinnedMoments or {};request.pinnedMoments[index]=request.pinnedMoments[index] or {}
            request.pinnedMoments[index].peopleDetails=request.pinnedMoments[index].peopleDetails or {}
            request.pinnedMoments[index].peopleExpected=expected
        end
    elseif kind=="RR" then
        local index=PacketInteger(fields[3],1,24);local pi=PacketInteger(fields[4],1,24)
        if index and pi then
            request.pinnedMoments=request.pinnedMoments or {};request.pinnedMoments[index]=request.pinnedMoments[index] or {}
            local item=request.pinnedMoments[index];item.peopleDetails=item.peopleDetails or {}
            item.peopleDetails[pi]={
                name=SocialHexDecode(fields[5]) or "Unknown",
                trpName=SocialHexDecode(fields[6]) or "",
                met=fields[7]=="1",presence=fields[8]=="1",interactions=PacketInteger(fields[9],0,1000000) or 0,
                activeRP=fields[10]=="1",
            }
        end
    elseif kind=="S" then
        local index=PacketInteger(fields[3],1,24);local part=PacketInteger(fields[4],1,128);local total=PacketInteger(fields[5],1,128)
        if index and part and total and part<=total then
            request.momentTextParts=request.momentTextParts or {}
            local parts=request.momentTextParts[index] or {total=total,chunks={}}
            if parts.total~=total then return end
            request.momentTextParts[index]=parts;parts.chunks[part]=SocialHexDecode(fields[6]) or ""
            local ready=true;local built={}
            for i=1,total do if parts.chunks[i]==nil then ready=false break end;built[#built+1]=parts.chunks[i] end
            if ready then
                request.pinnedMoments=request.pinnedMoments or {};request.pinnedMoments[index]=request.pinnedMoments[index] or {}
                request.pinnedMoments[index].text=table.concat(built)
            end
        end
    elseif kind=="SC" then
        request.storyExpected=PacketInteger(fields[3],0,3)
        request.pinnedStories=request.pinnedStories or {}
    elseif kind=="SH" then
        local ai=PacketInteger(fields[3],1,3)
        if ai then
            request.pinnedStories=request.pinnedStories or {}
            local story=request.pinnedStories[ai] or {}
            story.id="remote-story-"..tostring(ai)
            story.schema=2
            story.title=SocialHexDecode(fields[4]) or "A Story Arc"
            story.completed=fields[5]=="1"
            story.createdAt=PacketInteger(fields[6],0,4000000000) or 0
            story.updatedAt=PacketInteger(fields[7],0,4000000000) or story.createdAt
            story.rootStepIndex=PacketInteger(fields[8],1,48) or 1
            story.noteExpected=PacketInteger(fields[9],0,12) or 0
            story.musicID=PacketInteger(fields[10],1,4294967295)
            story.musicName=SocialHexDecode(fields[11]) or ""
            story.steps=story.steps or {};story.stepOrder=story.stepOrder or {};story.links=story.links or {}
            story.view=story.view or {x=0,y=0,zoom=1};story.pinned=true;story.publicReadOnly=true
            request.pinnedStories[ai]=story
        end
    elseif kind=="SN" then
        local ai=PacketInteger(fields[3],1,3);local part=PacketInteger(fields[4],1,12);local total=PacketInteger(fields[5],1,12)
        if ai and part and total and part<=total then
            request.storyTextParts=request.storyTextParts or {}
            local parts=request.storyTextParts[ai] or {total=total,chunks={}}
            if parts.total~=total then return end
            request.storyTextParts[ai]=parts;parts.chunks[part]=SocialHexDecode(fields[6]) or ""
            local ready=true;local built={}
            for i=1,total do if parts.chunks[i]==nil then ready=false break end;built[#built+1]=parts.chunks[i] end
            if ready then
                request.pinnedStories=request.pinnedStories or {};request.pinnedStories[ai]=request.pinnedStories[ai] or {}
                request.pinnedStories[ai].note=table.concat(built)
            end
        end
    elseif kind=="ST" then
        local ai=PacketInteger(fields[3],1,3);local expected=PacketInteger(fields[4],0,48)
        if ai and expected then
            request.pinnedStories=request.pinnedStories or {};request.pinnedStories[ai]=request.pinnedStories[ai] or {steps={},stepOrder={},links={}}
            local story=request.pinnedStories[ai];story.steps=story.steps or {};story.stepOrder=story.stepOrder or {}
            story.stepExpected=expected;story.stepOriginalCount=PacketInteger(fields[5],0,80) or expected
            story.truncated=story.stepOriginalCount>expected or fields[6]=="1"
        end
    elseif kind=="SE" then
        local ai=PacketInteger(fields[3],1,3);local si=PacketInteger(fields[4],1,48)
        if ai and si then
            request.pinnedStories=request.pinnedStories or {};request.pinnedStories[ai]=request.pinnedStories[ai] or {steps={},stepOrder={},links={}}
            local story=request.pinnedStories[ai];story.steps=story.steps or {};story.stepOrder=story.stepOrder or {}
            local stepID="remote-story-"..tostring(ai).."-step-"..tostring(si)
            local step={
                id=stepID,title=SocialHexDecode(fields[5]) or "Story Event",location=SocialHexDecode(fields[6]) or "",
                x=PacketInteger(fields[7],0,1800) or 0,y=PacketInteger(fields[8],0,1100) or 0,
                completed=fields[9]=="1",noteExpected=PacketInteger(fields[10],0,12) or 0,
                bondExpected=PacketInteger(fields[11],0,6) or 0,bondKeys={},bondNames={},
            }
            story.steps[stepID]=step;story.stepOrder[si]=stepID
            if (story.rootStepIndex or 1)==si then story.rootStepID=stepID end
        end
    elseif kind=="SD" then
        local ai=PacketInteger(fields[3],1,3);local si=PacketInteger(fields[4],1,48)
        local part=PacketInteger(fields[5],1,12);local total=PacketInteger(fields[6],1,12)
        if ai and si and part and total and part<=total then
            request.storyStepTextParts=request.storyStepTextParts or {}
            request.storyStepTextParts[ai]=request.storyStepTextParts[ai] or {}
            local parts=request.storyStepTextParts[ai][si] or {total=total,chunks={}}
            if parts.total~=total then return end
            request.storyStepTextParts[ai][si]=parts;parts.chunks[part]=SocialHexDecode(fields[7]) or ""
            local ready=true;local built={}
            for i=1,total do if parts.chunks[i]==nil then ready=false break end;built[#built+1]=parts.chunks[i] end
            if ready then
                local story=request.pinnedStories and request.pinnedStories[ai]
                local stepID=story and story.stepOrder and story.stepOrder[si]
                if stepID and story.steps[stepID] then story.steps[stepID].note=table.concat(built) end
            end
        end
    elseif kind=="SP" then
        local ai=PacketInteger(fields[3],1,3);local si=PacketInteger(fields[4],1,48);local bi=PacketInteger(fields[5],1,6)
        local story=ai and request.pinnedStories and request.pinnedStories[ai]
        local stepID=story and si and story.stepOrder and story.stepOrder[si]
        if stepID and bi and story.steps[stepID] then
            story.steps[stepID].bondNames=story.steps[stepID].bondNames or {}
            story.steps[stepID].bondNames[bi]=SocialHexDecode(fields[6]) or ""
        end
    elseif kind=="SL" then
        local ai=PacketInteger(fields[3],1,3);local expected=PacketInteger(fields[4],0,96)
        if ai and expected then
            request.pinnedStories=request.pinnedStories or {};request.pinnedStories[ai]=request.pinnedStories[ai] or {steps={},stepOrder={},links={}}
            local story=request.pinnedStories[ai];story.links=story.links or {};story.linkExpected=expected
            story.linkOriginalCount=PacketInteger(fields[5],0,160) or expected
            story.truncated=story.truncated or story.linkOriginalCount>expected
        end
    elseif kind=="SX" then
        local ai=PacketInteger(fields[3],1,3);local li=PacketInteger(fields[4],1,96)
        local fromIndex=PacketInteger(fields[5],1,48);local toIndex=PacketInteger(fields[6],1,48)
        local story=ai and request.pinnedStories and request.pinnedStories[ai]
        if story and li and fromIndex and toIndex then
            local fromID=story.stepOrder and story.stepOrder[fromIndex]
            local toID=story.stepOrder and story.stepOrder[toIndex]
            if fromID and toID then
                story.links=story.links or {}
                story.links[li]={
                    id="remote-story-"..tostring(ai).."-link-"..tostring(li),
                    from=fromID,to=toID,label=SocialHexDecode(fields[7]) or "",
                    order=PacketInteger(fields[8],1,160) or li,createdAt=story.createdAt or 0,
                }
            end
        end
    elseif kind=="T" then
        request.counts={}
        request.statisticsPacketReceived=true
        for i=3,#fields do
            local key,value=fields[i]:match("^([^=]+)=(%-?%d+)$")
            local count=key and PacketInteger(value,-1000000000,1000000000)
            if key and #key<=24 and count then request.counts[key]=count end
        end
    elseif kind=="M" then
        request.memoirs=request.memoirs or {}
        local memoirID=SocialHexDecode(fields[3])
        if memoirID and memoirID~="" and #memoirID<=80 and (request.memoirs[memoirID] or (request.memoirReceived or 0)<64) then
            if not request.memoirs[memoirID] then request.memoirReceived=(request.memoirReceived or 0)+1 end
            request.memoirs[memoirID]={
                id=memoirID,
                unlockedAt=PacketInteger(fields[4],0,4000000000) or 0,
            }
        end
    elseif kind=="A" then
        local index=PacketInteger(fields[3],1,200)
        local total=PacketInteger(fields[4],0,200)
        if index and total then
            local existing=request.achievements[index] or {}
            existing.favourite=fields[5]=="1"
            existing.category=SocialHexDecode(fields[6])
            existing.name=SocialHexDecode(fields[7])
            if fields[8] and fields[8]~="" then existing.icon=SocialHexDecode(fields[8]) or existing.icon or "" end
            if fields[9] and fields[9]~="" then existing.description=SocialHexDecode(fields[9]) or existing.description or "" end
            request.achievements[index]=existing
        end
    elseif kind=="B" then
        local index=PacketInteger(fields[3],1,200)
        if index then
            request.achievements[index]=request.achievements[index] or {}
            request.achievements[index].icon=SocialHexDecode(fields[4]) or ""
        end
    elseif kind=="W" then
        local index=PacketInteger(fields[3],1,200)
        local part=PacketInteger(fields[4],1,64)
        local total=PacketInteger(fields[5],1,64)
        if index and part and total and part<=total then
            request.achievementDescriptionParts=request.achievementDescriptionParts or {}
            local parts=request.achievementDescriptionParts[index] or {total=total,chunks={}}
            if parts.total~=total then return end
            request.achievementDescriptionParts[index]=parts
            parts.chunks[part]=SocialHexDecode(fields[6]) or ""
            local ready=true
            local built={}
            for i=1,total do
                if parts.chunks[i]==nil then ready=false break end
                built[#built+1]=parts.chunks[i]
            end
            if ready then
                request.achievements[index]=request.achievements[index] or {}
                request.achievements[index].description=table.concat(built)
            end
        end
    elseif kind=="SS" then
        local categoryID=tostring(fields[3] or "")
        local categoryDefinition
        for _,definition in ipairs(RPAL.SKILL_SET_CATEGORIES or {}) do if definition.id==categoryID then categoryDefinition=definition break end end
        if categoryDefinition then
            request.skillSet=request.skillSet or {}
            local values={}
            for raw in tostring(fields[4] or ""):gmatch("[^,]+") do
                if #values>=#(categoryDefinition.skills or {}) then break end
                values[#values+1]=PacketInteger(raw,0,5) or 0
            end
            request.skillSet[categoryID]=values
            local received=0
            for _,category in ipairs(RPAL.SKILL_SET_CATEGORIES or {}) do
                if request.skillSet[category.id] then received=received+1 end
            end
            request.skillSetReceived=(received>=#(RPAL.SKILL_SET_CATEGORIES or {}))
        end
    elseif kind=="IJ" then
        request.injuries=request.injuries or {items={},groups={},history={}}
        request.injuryItemExpected=PacketInteger(fields[3],0,200)
        request.injuryGroupExpected=PacketInteger(fields[4],0,100)
        request.injuryHistoryExpected=PacketInteger(fields[5],0,60) or 0
        request.injuries.history=request.injuries.history or {}
        local traits={}
        for trait in tostring(fields[7] or ""):gmatch("[^,]+") do if trait=="tail" or trait=="wings" or trait=="horns" or trait=="hooves" then traits[trait]=true end end
        request.injuries.anatomy={preset=SocialHexDecode(fields[6]) or "Humanoid",traits=traits}
    elseif kind=="IG" then
        request.injuries=request.injuries or {items={},groups={}}
        local gid=tostring(fields[3] or "")
        if gid~="" and #gid<=64 and (request.injuries.groups[gid] or (request.injuryGroupsReceived or 0)<100) then
            if not request.injuries.groups[gid] then request.injuryGroupsReceived=(request.injuryGroupsReceived or 0)+1 end
            local itemIds=request.injuries.groups[gid] and request.injuries.groups[gid].itemIds or {}
            request.injuries.groups[gid]={id=gid,name=SocialHexDecode(fields[4]) or "Injury Group",remaining=PacketInteger(fields[5],0,315360000) or 0,itemIds=itemIds}
        end
    elseif kind=="II" then
        request.injuries=request.injuries or {items={},groups={}}
        local id=tostring(fields[3] or "")
        if id~="" and #id<=64 and (request.injuries.items[id] or (request.injuryItemsReceived or 0)<200) then
            local gid=tostring(fields[9] or "")
            if gid=="" or #gid>64 then gid=nil end
            local x=tonumber(fields[7]);local y=tonumber(fields[8])
            if not x or x~=x or x==math.huge or x==-math.huge then x=.5 end
            if not y or y~=y or y==math.huge or y==-math.huge then y=.5 end
            local category=(fields[6]=="healing" and "healing") or (fields[6]=="alteration" and "alteration") or "injury"
            local item={id=id,type=SocialHexDecode(fields[4]) or "Cut",healing=SocialHexDecode(fields[5]) or "",category=category,x=math.max(.02,math.min(.98,x)),y=math.max(.02,math.min(.98,y)),groupId=gid,remaining=PacketInteger(fields[10],0,315360000) or 0,side=(fields[11]=="back") and "back" or "front",customName=SocialHexDecode(fields[12]) or "",scale=math.max(.65,math.min(1.55,tonumber(fields[13]) or 1)),rotation=(tonumber(fields[14]) or 0)%360}
            if not request.injuries.items[id] then request.injuryItemsReceived=(request.injuryItemsReceived or 0)+1 end
            request.injuries.items[id]=item
            local pendingDetails=request.injuryDetailPending and request.injuryDetailPending[id]
            if pendingDetails then
                for key,value in pairs(pendingDetails)do item[key]=value end
                request.injuryDetailPending[id]=nil
            end
            local noteState=request.injuryNoteParts and request.injuryNoteParts[id]
            if noteState and noteState.note then item.note=noteState.note end
            if gid then
                if not request.injuries.groups[gid] and (request.injuryGroupsReceived or 0)>=100 then gid=nil;item.groupId=nil end
                if gid and not request.injuries.groups[gid] then request.injuryGroupsReceived=(request.injuryGroupsReceived or 0)+1 end
                if gid then request.injuries.groups[gid]=request.injuries.groups[gid] or {id=gid,name="Injury Group",itemIds={}} end
                if not gid then return end
                local ids=request.injuries.groups[gid].itemIds; local seen=false
                for _,existing in ipairs(ids) do if tostring(existing)==id then seen=true break end end
                if not seen then ids[#ids+1]=id end
            end
        end
    elseif kind=="IN" then
        request.injuries=request.injuries or {items={},groups={}}
        local id=tostring(fields[3] or "");local part=PacketInteger(fields[4],1,8);local total=PacketInteger(fields[5],1,8)
        if id~="" and #id<=64 and part and total and part<=total then
            request.injuryNoteParts=request.injuryNoteParts or {};local state=request.injuryNoteParts[id]
            if not state or state.total~=total then state={total=total,chunks={}};request.injuryNoteParts[id]=state end
            state.chunks[part]=SocialHexDecode(fields[6]) or ""
            local ready=true;local built={}
            for i=1,total do if state.chunks[i]==nil then ready=false;break end;built[#built+1]=state.chunks[i] end
            if ready then
                local note=table.concat(built):sub(1,600);local item=request.injuries.items[id]
                if item then item.note=note else state.note=note end
            end
        end
    elseif kind=="IX" then
        request.injuries=request.injuries or {items={},groups={},history={}}
        local id=tostring(fields[3] or "")
        local item=request.injuries.items and request.injuries.items[id]
        if id~="" and #id<=64 then
            local details={region=SocialHexDecode(fields[6]) or "",privacy=tostring(fields[7] or "public"),originId=(fields[8] and fields[8]~="") and tostring(fields[8]) or nil,symptoms={},_detailsReceived=true}
            local stage=tostring(fields[4] or "");if stage=="fresh" or stage=="treated" or stage=="recovering" or stage=="healed" then details.stage=stage end
            local severity=tostring(fields[5] or "");if severity=="minor" or severity=="moderate" or severity=="severe" or severity=="critical" then details.severity=severity end
            for symptom in tostring(SocialHexDecode(fields[9]) or ""):gmatch("[^,]+") do if #details.symptoms<10 then details.symptoms[#details.symptoms+1]=symptom end end
            if item then
                for key,value in pairs(details)do item[key]=value end
            else
                request.injuryDetailPending=request.injuryDetailPending or {}
                request.injuryDetailPending[id]=details
            end
        end
    elseif kind=="IL" then
        request.injuries=request.injuries or {items={},groups={},history={}}
        local id=tostring(fields[3] or "");local item=request.injuries.items and request.injuries.items[id]
        if item then item.originLabel=SocialHexDecode(fields[4]) or "" end
    elseif kind=="MN" then
        request.injuries=request.injuries or {items={},groups={},history={}}
        local id=tostring(fields[3] or "");local item=request.injuries.items and request.injuries.items[id]
        local index=PacketInteger(fields[4],1,4)
        if item and index then item.medicalNotes=item.medicalNotes or {};item.medicalNotes[index]={at=PacketInteger(fields[5],0,4000000000) or 0,author=SocialHexDecode(fields[6]) or "",text=SocialHexDecode(fields[7]) or "",public=true} end
    elseif kind=="HT" then
        request.injuries=request.injuries or {items={},groups={},history={}}
        request.injuries.history=request.injuries.history or {}
        local index=PacketInteger(fields[3],1,60)
        if index then request.injuries.history[index]={at=PacketInteger(fields[4],0,4000000000) or 0,kind=SocialHexDecode(fields[5]) or "update",label=SocialHexDecode(fields[6]) or "Health Record",from="",to="",actor="",note="",sceneTitle=""} end
    elseif kind=="HX" then
        request.injuries=request.injuries or {items={},groups={},history={}};request.injuries.history=request.injuries.history or {}
        local index=PacketInteger(fields[3],1,60);local e=index and request.injuries.history[index]
        if e then e.from=SocialHexDecode(fields[4]) or"";e.to=SocialHexDecode(fields[5]) or"";e.actor=SocialHexDecode(fields[6]) or"" end
    elseif kind=="HY" then
        request.injuries=request.injuries or {items={},groups={},history={}};request.injuries.history=request.injuries.history or {}
        local index=PacketInteger(fields[3],1,60);local e=index and request.injuries.history[index]
        if e then e.note=SocialHexDecode(fields[4]) or"";e.sceneTitle=SocialHexDecode(fields[5]) or"" end
    elseif kind=="E" then
        -- Notes are optional extension packets; attach any that arrived before II.
        if request.injuryNoteParts and request.injuries and request.injuries.items then
            for id,state in pairs(request.injuryNoteParts) do if state.note and request.injuries.items[id] then request.injuries.items[id].note=state.note end end
        end
        request.finished=true
        local complete,missing=H.IsInspectRequestComplete(request)
        request.complete=complete and true or false
        request.incomplete=not complete
        request.missingSections=missing
        if complete then
            request.completedAt=now
            if RPAL.PutInspectSnapshot then RPAL.PutInspectSnapshot(sender,request.intent,request) end
            if socialRequests._activeOutbound then socialRequests._activeOutbound[senderKey..":"..tostring(request.intent or "profile")]=nil end
        end
    end

    -- X carries Aura/Ornament/Effect/Platform. Waiting for E meant an effect
    -- could remain absent until a long Achievement/Memoir transfer finished.
    if (kind=="D" or kind=="X" or kind=="E") and RPAL.RefreshRemoteInspect then
        RPAL.RefreshRemoteInspect(sender,requestID)
    end
    if (kind=="D" or kind=="IJ" or kind=="II" or kind=="IN" or kind=="IX" or kind=="IL" or kind=="MN" or kind=="HT" or kind=="HX" or kind=="HY" or kind=="E") and RPAL.RefreshRemoteHealthInspect then
        RPAL.RefreshRemoteHealthInspect(sender,requestID)
    end
end


local function StartSocialPresence()
    if not SocialCanSend() then return end
    pcall(C_ChatInfo.RegisterAddonMessagePrefix,SOCIAL_PREFIX)

    C_Timer.After(2.5,BroadcastPresence)

    if RPAL._socialTicker then RPAL._socialTicker:Cancel() end
    RPAL._socialTicker=C_Timer.NewTicker(45,function()
        BroadcastPresence()
        if UnitExists("target") and UnitIsPlayer("target") and not UnitIsUnit("target","player") then
            PingTarget()
        end
        local now=GetTime and GetTime() or 0
        for key,bucket in pairs(socialRequests) do
            if type(key)=="string" and key:sub(1,1)~="_" and type(bucket)=="table" then
                for requestID,request in pairs(bucket) do
                    if type(request)~="table" or now-(request.receivedAt or 0)>180 then bucket[requestID]=nil end
                end
                if not next(bucket) then socialRequests[key]=nil end
            end
        end
        for _,rates in pairs({socialRequests._inboundRate,socialRequests._outboundRate}) do
            for key,at in pairs(rates or {}) do if now-(tonumber(at) or 0)>180 then rates[key]=nil end end
        end
        for key,pointer in pairs(socialRequests._activeOutbound or {}) do
            if type(pointer)~="table" or now-(tonumber(pointer.startedAt) or 0)>75 then
                socialRequests._activeOutbound[key]=nil
                if RPAL.CancelScheduled then RPAL.CancelScheduled("inspect:"..key) end
            end
        end
    end)
end




local function MakeWidgetRound(button)
    if not button or button._rpalRoundMask then return end
    local mask=button:CreateMaskTexture()
    mask:SetTexture("Interface\\CharacterFrame\\TempPortraitAlphaMask","CLAMPTOBLACKADDITIVE","CLAMPTOBLACKADDITIVE")
    mask:SetAllPoints(button)
    button._rpalRoundMask=mask

    if button.icon and button.icon.AddMaskTexture then button.icon:AddMaskTexture(mask) end
    if button.glow and button.glow.AddMaskTexture then button.glow:AddMaskTexture(mask) end
    if button.roundBG and button.roundBG.AddMaskTexture then button.roundBG:AddMaskTexture(mask) end
end


RPAL.ODYN_EYE_ORDER={"Default","Runebound Lens","Voidglass Eye","Sunwell's Eye","Moonwell Eye","Emberwatch","Silver Thread","Maplefall Eye","Solar Watch"}
RPAL.ODYN_EYE_COSTS={
    Default=0,["Runebound Lens"]=95,["Sunwell's Eye"]=105,["Moonwell Eye"]=110,
    Emberwatch=110,["Voidglass Eye"]=120,["Maplefall Eye"]=105,["Silver Thread"]=115,["Solar Watch"]=125,
}

-- Every ODYN Eye is authored as two transparent, round masks. The active theme
-- supplies their colours; the cosmetic supplies a genuinely different radial
-- silhouette. Nothing in this renderer uses an action-button/square border.
RPAL.ODYN_EYE_TEXTURE_ROOT=TEX.."Cosmetics\\Eyes\\"
RPAL.ODYN_EYE_STYLES={
    Default={label="The Watcher",asset="watcher",fx="blink"},
    ["Runebound Lens"]={asset="runebound_lens",fx="runeSpin"},
    ["Sunwell's Eye"]={asset="sunwells_eye",fx="sunburst"},
    ["Moonwell Eye"]={asset="moonwell_eye",fx="moonSway"},
    Emberwatch={asset="emberwatch",fx="emberFlicker"},
    ["Voidglass Eye"]={asset="voidglass_eye",fx="voidOrbit"},
    ["Maplefall Eye"]={asset="maplefall_eye",fx="petalTurn"},
    ["Silver Thread"]={asset="silver_thread",fx="threadWeave"},
    ["Solar Watch"]={asset="solar_watch",fx="solarBeat"},
}

function RPAL.GetODYNEyeThemeColors()
    local palette=RPAL.GetCurrentThemePalette and RPAL.GetCurrentThemePalette() or nil
    local primary=(palette and palette.gold) or GOLD or {0.96,0.76,0.30,1}
    local pale=(palette and palette.pale) or {0.94,0.91,0.84,1}
    local panel=(palette and (palette.panel2 or palette.panel)) or {0.025,0.028,0.034,1}
    local secondary={
        math.min(1,(primary[1] or 1)*0.62+(pale[1] or 1)*0.38),
        math.min(1,(primary[2] or 1)*0.62+(pale[2] or 1)*0.38),
        math.min(1,(primary[3] or 1)*0.62+(pale[3] or 1)*0.38),1,
    }
    return primary,secondary,panel
end

function RPAL.EnsureODYNEyeArt(button)
    if not button or button._odynEyeArt then return button and button._odynEyeArt end
    local art={}
    button._odynEyeArt=art

    art.disc=button:CreateTexture(nil,"BACKGROUND",nil,0)
    art.disc:SetTexture(RPAL.ODYN_EYE_TEXTURE_ROOT.."eye_orb_disc.tga")
    art.disc:SetPoint("CENTER")

    art.halo=button:CreateTexture(nil,"BACKGROUND",nil,-1)
    art.halo:SetTexture(TEX.."Celestial\\starlight_glow.tga")
    art.halo:SetBlendMode("ADD")
    art.halo:SetPoint("CENTER")

    art.primary=button:CreateTexture(nil,"ARTWORK",nil,1)
    art.primary:SetPoint("CENTER")
    art.primary:SetBlendMode("ADD")

    art.accent=button:CreateTexture(nil,"ARTWORK",nil,2)
    art.accent:SetPoint("CENTER")
    art.accent:SetBlendMode("ADD")

    for _,texture in ipairs({art.disc,art.halo,art.primary,art.accent}) do
        if texture.SetSnapToPixelGrid then texture:SetSnapToPixelGrid(false) end
        if texture.SetTexelSnappingBias then texture:SetTexelSnappingBias(0) end
    end

    if button.HookScript and not button._odynEyeSizeHook then
        button._odynEyeSizeHook=true
        button:HookScript("OnSizeChanged",function(self)
            if self._odynEyeStyle and RPAL.LayoutODYNEyeArt then RPAL.LayoutODYNEyeArt(self) end
        end)
    end
    return art
end

function RPAL.LayoutODYNEyeArt(button)
    if not button then return end
    local art=RPAL.EnsureODYNEyeArt(button);if not art then return end
    local style=RPAL.ODYN_EYE_STYLES[button._odynEyeStyle or "Default"] or RPAL.ODYN_EYE_STYLES.Default
    local primary,secondary,panel=RPAL.GetODYNEyeThemeColors()
    local width=tonumber(button:GetWidth()) or 26
    local height=tonumber(button:GetHeight()) or width
    local size=math.max(18,math.min(width,height))
    -- Scale Eye artwork, never the positioned/clickable widget frame.  Frame
    -- scaling multiplies anchor offsets in WoW and makes an orb appear to grow
    -- toward/away from its anchor instead of expanding around its own centre.
    -- Hover and Renown thumps therefore compose as visual-only multipliers.
    local hoverScale=math.max(0.75,tonumber(button._odynEyeHoverScale) or 1)
    local renownScale=math.max(0.75,tonumber(button._odynRenownPulseScale) or 1)
    local visualScale=hoverScale*renownScale
    local asset=tostring(style.asset or "watcher")

    -- Click animations temporarily transform these layers. Re-rendering a
    -- widget (theme change, resize, or preview reuse) must always restore a
    -- clean centred baseline before applying the current Eye artwork.
    for _,texture in ipairs({art.disc,art.halo,art.primary,art.accent,button.ring}) do
        if texture then
            texture:SetAlpha(1)
            if texture.SetRotation then texture:SetRotation(0) end
            texture:ClearAllPoints();texture:SetPoint("CENTER",button,"CENTER",0,0)
        end
    end

    if button.roundBG then button.roundBG:SetAlpha(0) end
    if button.icon then button.icon:Hide() end
    if button._odynEyeAccent then button._odynEyeAccent:Hide() end
    if button._odynEyeInnerRing then button._odynEyeInnerRing:Hide() end

    art.disc:SetSize(size*1.03*visualScale,size*1.03*visualScale)
    art.disc:SetVertexColor((panel[1] or .02)*.78,(panel[2] or .02)*.78,(panel[3] or .03)*.78,.98)
    art.disc:Show()

    art.halo:SetSize(size*1.28*visualScale,size*1.28*visualScale)
    art.halo:SetVertexColor(primary[1],primary[2],primary[3],.15)
    art.halo:Show()

    art.primary:SetTexture(RPAL.ODYN_EYE_TEXTURE_ROOT.."eye_"..asset.."_primary.tga")
    art.primary:SetSize(size*1.08*visualScale,size*1.08*visualScale)
    art.primary:SetVertexColor(primary[1],primary[2],primary[3],.98)
    art.primary:Show()

    art.accent:SetTexture(RPAL.ODYN_EYE_TEXTURE_ROOT.."eye_"..asset.."_accent.tga")
    art.accent:SetSize(size*1.08*visualScale,size*1.08*visualScale)
    art.accent:SetVertexColor(secondary[1],secondary[2],secondary[3],.88)
    art.accent:Show()

    if button.ring then
        local ringScale=button._odynEyePreview and 1.22 or 1.44
        button.ring:SetTexture(RPAL.ODYN_EYE_TEXTURE_ROOT.."eye_orb_ring.tga")
        button.ring:SetBlendMode("ADD")
        button.ring:SetSize(size*ringScale*visualScale,size*ringScale*visualScale)
        button.ring:SetVertexColor(primary[1],primary[2],primary[3],.76)
    end
    if button.glow then
        button.glow:SetTexture(TEX.."Celestial\\starlight_glow.tga");button.glow:SetBlendMode("ADD")
        button.glow:SetVertexColor(primary[1],primary[2],primary[3],.22)
    end
end

-- Eye widgets use artwork-only hover scaling.  Unlike the generic card hover
-- helper this never calls SetScale() on the anchored frame, so the orb grows
-- equally in every direction and its click/drag position never moves.
local function AttachCenteredEyeHoverGrow(frame,amount)
    if not frame or frame._odynCenteredEyeHoverGrow then return end
    frame._odynCenteredEyeHoverGrow=true
    amount=tonumber(amount) or 0.10
    frame:EnableMouse(true)
    local current,target=0,0
    local driver=CreateFrame("Frame",nil,frame)
    driver:Hide()

    local function Apply()
        frame._odynEyeHoverScale=1+(amount*current)
        -- While a signature Eye animation is running, EyeFX applies this scale
        -- itself every frame.  Relayout only the static state so we never reset
        -- a blink/orbit/spin halfway through.
        if not (RPAL.IsODYNEyeFXActive and RPAL.IsODYNEyeFXActive(frame)) then
            if RPAL.LayoutODYNEyeArt then RPAL.LayoutODYNEyeArt(frame) end
        end
    end

    driver:SetScript("OnUpdate",function(self,elapsed)
        local diff=target-current
        if math.abs(diff)<0.006 then
            current=target
        else
            current=current+(diff*math.min(1,(elapsed or 0)*14))
        end
        Apply()
        if current==target then self:Hide() end
    end)
    frame:HookScript("OnEnter",function() target=1;driver:Show() end)
    frame:HookScript("OnLeave",function() target=0;driver:Show() end)
    frame:HookScript("OnHide",function()
        target=0;current=0;driver:Hide();frame._odynEyeHoverScale=1
        if RPAL.LayoutODYNEyeArt then RPAL.LayoutODYNEyeArt(frame) end
    end)
end

local playerFrameWidget
local lendEarIndicator
local playerFrameWidgetDragging=false
local playerFrameWidgetMoved=false
local playerFrameWidgetStartX,playerFrameWidgetStartY

local function GetPlayerFrameAnchor()
    return _G.PlayerFrame or _G.PlayerFrameContainer
end

local function PositionPlayerFrameWidget()
    if not playerFrameWidget then return end
    local parent=GetPlayerFrameAnchor()
    if not parent then playerFrameWidget:Hide();return end

    local settings=RPAL.GetSettings()
    if not settings.playerFrameWidgetEnabled then playerFrameWidget:Hide();return end
    playerFrameWidget:Show()

    local angle=math.rad(tonumber(settings.playerFrameWidgetAngle) or 215)
    local radius=tonumber(settings.playerFrameWidgetRadius) or 62
    playerFrameWidget:ClearAllPoints()
    playerFrameWidget:SetPoint("CENTER",parent,"CENTER",math.cos(angle)*radius,math.sin(angle)*radius)
    if RPAL.UpdateLendEarIndicator then RPAL.UpdateLendEarIndicator() end
end

local function EnsureLendEarIndicator()
    if lendEarIndicator or not playerFrameWidget then return lendEarIndicator end
    local b=CreateFrame("Button","ODYN_LendEarIndicator",playerFrameWidget)
    lendEarIndicator=b
    b:SetSize(18,18)
    b:SetPoint("TOP",playerFrameWidget,"BOTTOM",0,-4)
    b:SetFrameStrata("LOW")
    b:SetFrameLevel((playerFrameWidget:GetFrameLevel() or 8)+4)
    b:SetClampedToScreen(true)
    b:RegisterForClicks("LeftButtonUp")
    b:EnableMouse(true)

    local bg=b:CreateTexture(nil,"BACKGROUND")
    bg:SetTexture("Interface\\Buttons\\WHITE8X8")
    bg:SetAllPoints()
    bg:SetVertexColor(.025,.028,.034,.96)
    b.roundBG=bg

    local icon=b:CreateTexture(nil,"ARTWORK")
    icon:SetTexture("Interface\\Icons\\INV_Misc_Eye_01")
    icon:SetPoint("TOPLEFT",3,-3);icon:SetPoint("BOTTOMRIGHT",-3,3)
    icon:SetDesaturated(true)
    icon:SetVertexColor(.88,.78,.52,.92)
    b.icon=icon

    local ring=b:CreateTexture(nil,"OVERLAY",nil,1)
    ring:SetTexture("Interface\\Buttons\\UI-ActionButton-Border")
    ring:SetBlendMode("ADD")
    ring:SetSize(27,27);ring:SetPoint("CENTER")
    ring:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],.82)
    b.ring=ring

    local slash=b:CreateTexture(nil,"OVERLAY",nil,3)
    slash:SetTexture("Interface\\Buttons\\WHITE8X8")
    slash:SetSize(2,15);slash:SetPoint("CENTER",0,0)
    slash:SetRotation(math.rad(-43))
    slash:SetVertexColor(.98,.70,.34,1)
    b.slash=slash

    local countText=b:CreateFontString(nil,"OVERLAY")
    countText:SetFont("Fonts\\FRIZQT__.TTF",8,"OUTLINE")
    countText:SetPoint("BOTTOMRIGHT",b,"BOTTOMRIGHT",3,-2)
    countText:SetTextColor(1,.86,.48,1)
    b.countText=countText

    local glow=b:CreateTexture(nil,"BACKGROUND",nil,-1)
    glow:SetTexture("Interface\\Buttons\\WHITE8X8")
    glow:SetPoint("TOPLEFT",-2,2);glow:SetPoint("BOTTOMRIGHT",2,-2)
    glow:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],.18);glow:SetAlpha(0)
    b.glow=glow
    MakeWidgetRound(b)

    b:SetScript("OnEnter",function(self)
        self.glow:SetAlpha(1)
        local count=RPAL.GetLendEarCount and tonumber(RPAL.GetLendEarCount()) or 0
        local names=RPAL.GetLendEarNames and RPAL.GetLendEarNames() or {}
        local list=(#names>0 and table.concat(names," • ") or "No focused players")
        if RPAL.ShowCursorTooltip then RPAL.ShowCursorTooltip({
            eyebrow="HOVER LOG",title="Focus Messages Active",
            body=(count==1 and "1 player is being prioritised in RP chat." or tostring(count).." players are being prioritised in RP chat."),
            sectionTitle="FOCUSED PLAYERS",section=list.."\n\nClick to clear all focused players and restore normal chat emphasis.",
            accent=GOLD,width=350,
        }) end
    end)
    b:SetScript("OnLeave",function(self)
        self.glow:SetAlpha(0)
        if RPAL.HideCursorTooltip then RPAL.HideCursorTooltip() end
    end)
    b:SetScript("OnClick",function()
        if RPAL.ClearLendEar then RPAL.ClearLendEar() end
        if RPAL.HideCursorTooltip then RPAL.HideCursorTooltip() end
    end)
    b:Hide()
    return b
end

function RPAL.UpdateLendEarIndicator()
    if not playerFrameWidget then return end
    local b=EnsureLendEarIndicator()
    if not b then return end
    local count=RPAL.GetLendEarCount and tonumber(RPAL.GetLendEarCount()) or 0
    local settings=RPAL.GetSettings and RPAL.GetSettings() or {}
    if b.countText then b.countText:SetText(count>1 and tostring(count) or "") end
    if count>0 and settings.playerFrameWidgetEnabled~=false and playerFrameWidget:IsShown() then b:Show() else b:Hide() end
end

function H.SavePlayerFrameWidgetPosition()
    local parent=GetPlayerFrameAnchor()
    if not playerFrameWidget or not parent then return end
    local px,py=parent:GetCenter()
    local wx,wy=playerFrameWidget:GetCenter()
    if not px or not py or not wx or not wy then return end

    local dx,dy=wx-px,wy-py
    local radius=math.max(42,math.min(105,math.sqrt(dx*dx+dy*dy)))
    local angle=math.deg(RPALAtan2(dy,dx))
    local settings=RPAL.GetSettings()
    settings.playerFrameWidgetRadius=radius
    settings.playerFrameWidgetAngle=angle
    PositionPlayerFrameWidget()
end

function H.UpdatePlayerFrameWidgetDrag()
    if not playerFrameWidgetDragging or not playerFrameWidget then return end
    local parent=GetPlayerFrameAnchor()
    if not parent then return end

    local scale=UIParent:GetEffectiveScale()
    local mx,my=GetCursorPosition()
    mx,my=mx/scale,my/scale
    local px,py=parent:GetCenter()
    if not px or not py then return end

    local dx,dy=mx-px,my-py
    local distance=math.sqrt(dx*dx+dy*dy)
    if distance<0.001 then distance=1 end
    local radius=math.max(42,math.min(105,distance))
    dx=dx/distance*radius
    dy=dy/distance*radius

    playerFrameWidget:ClearAllPoints()
    playerFrameWidget:SetPoint("CENTER",parent,"CENTER",dx,dy)
end

RPAL._playerFrameWidgetMenu=nil

RPAL.PLAYER_WIDGET_MENU_DEFS={
    {key="main",label="Open ODYN",action=function() if RPAL.ShowMainUI then RPAL.ShowMainUI() elseif UI.main then UI.main:Show() end end},
    {key="overview",label="Open Overview",action=function() if RPAL.OpenDeskNavigation then RPAL.OpenDeskNavigation("overview") end end},
    {key="atlas",label="Open Atlas",action=function() if RPAL.OpenTalentAtlas then RPAL.OpenTalentAtlas() end end},
    {key="chronicle",label="Open Sessions",action=function() if RPAL.OpenDeskNavigation then RPAL.OpenDeskNavigation("chronicle") elseif RPAL.OpenChronicle then RPAL.OpenChronicle("Scenes") end end},
    {key="achievements",label="Open Achievements",action=function() if RPAL.OpenDeskNavigation then RPAL.OpenDeskNavigation("achievements") end end},
    {key="calendar",label="Open Calendar",action=function() if RPAL.OpenDeskNavigation then RPAL.OpenDeskNavigation("calendar") elseif RPAL.OpenCalendar then RPAL.OpenCalendar() end end},
    {key="injury",label="Open Health",action=function() if RPAL.OpenDeskNavigation then RPAL.OpenDeskNavigation("injury") elseif RPAL.OpenInjurySystem then RPAL.OpenInjurySystem() end end},
    {key="currencies",label="Open Currencies",action=function() if RPAL.OpenCurrencyUI then RPAL.OpenCurrencyUI() end end},
    {key="skillset",label="Open Skill Set",action=function() if RPAL.OpenDeskNavigation then RPAL.OpenDeskNavigation("skillset") elseif RPAL.OpenSkillSet then RPAL.OpenSkillSet() end end},
    {key="minigames",label="Open Mini Games",action=function() if RPAL.OpenDeskNavigation then RPAL.OpenDeskNavigation("minigames") end end},
    {key="customize",label="Open Customize",action=function() if RPAL.OpenDeskNavigation then RPAL.OpenDeskNavigation("customize") elseif RPAL.OpenCustomization then RPAL.OpenCustomization() end end},
    {key="tools",label="Open Tools",action=function() if RPAL.OpenDeskNavigation then RPAL.OpenDeskNavigation("tools") elseif RPAL.OpenTools then RPAL.OpenTools() end end},
    {key="dmkit",label="Open DM Kit",action=function() if RPAL.OpenDeskNavigation then RPAL.OpenDeskNavigation("dmkit") elseif RPAL.OpenDMKit then RPAL.OpenDMKit() end end},
}

function RPAL.HidePlayerFrameWidgetMenu()
    if not RPAL._playerFrameWidgetMenu then return end
    local menu=RPAL._playerFrameWidgetMenu
    RPAL._playerFrameWidgetMenu=nil
    if RPAL.CloseOpenDropdown then RPAL.CloseOpenDropdown() end
    if menu.Hide then menu:Hide() end
end

function RPAL.TogglePlayerFrameWidgetMenu(button)
    if RPAL._playerFrameWidgetMenu then RPAL.HidePlayerFrameWidgetMenu();return end
    if not button then return end

    local settings=RPAL.GetSettings and RPAL.GetSettings() or {}
    local visible=type(settings.playerWidgetMenuVisible)=="table" and settings.playerWidgetMenuVisible or {}
    local entries={}
    for _,def in ipairs(RPAL.PLAYER_WIDGET_MENU_DEFS) do
        if visible[def.key]==true then
            if def.key~="dmkit" or not (RPAL.DMKit and RPAL.DMKit.IsEnabled) or RPAL.DMKit.IsEnabled() then
                entries[#entries+1]=def
            end
        end
    end

    -- Player-frame shortcuts deliberately use the exact same visual language as
    -- the target Inspect flyout: compact framed choices, readable two-line copy
    -- and no embedded ScrollFrame/backdrop layer that can sit above the labels.
    local descriptions={
        main="Main dashboard and character overview",
        overview="Character progression and quick statistics",
        atlas="Talents, unlocks and progression paths",
        chronicle="Sessions, campaigns, stories and bonds",
        achievements="Character and account achievements",
        calendar="Events, community listings and planning",
        injury="Injuries, statuses and medical tools",
        currencies="Small movable Coin Pouch and Bank widget",
        skillset="Skills, proficiencies and character abilities",
        minigames="Hearthstone, Chess and Checkers",
        customize="Themes, profile presentation and cosmetics",
        tools="Exchange, screenshot and sharing utilities",
        dmkit="Campaign hosting and encounter controls",
    }

    local count=#entries
    local maxRows=6
    local columns=(count>maxRows) and 2 or 1
    local rows=math.max(1,math.min(maxRows,math.ceil(math.max(1,count)/columns)))
    local colW=212
    local gap=8
    local side=12
    local width=(columns==1) and 236 or (side*2+colW*2+gap)
    local headerH=10
    local rowH=38
    local bottom=10
    local height=headerH+(count==0 and 42 or rows*rowH)+bottom

    local m=CreateFrame("Frame",nil,UIParent,"BackdropTemplate")
    RPAL._playerFrameWidgetMenu=m
    m:SetSize(width,height)
    m:SetClampedToScreen(true)
    -- The shell is visual only.  A late Raise() on a mouse-enabled shell can
    -- put its full rectangular hit region above every child Button, leaving
    -- rows visible while all hover/click input lands on the menu background.
    -- Keep the shell out of hit testing and use a lower input catcher instead.
    m:EnableMouse(false)
    if RPAL.ApplyUILayer then
        RPAL.ApplyUILayer(m,"POPOVER",70)
    else
        m:SetFrameStrata("TOOLTIP");m:SetFrameLevel(12070);m:SetToplevel(true)
    end
    Backdrop(m,PANEL2,GOLD)

    local bx=button.GetCenter and select(1,button:GetCenter())
    local ux=UIParent.GetCenter and select(1,UIParent:GetCenter())
    if bx and ux and bx>ux then
        m:SetPoint("TOPRIGHT",button,"BOTTOMRIGHT",0,-8)
    else
        m:SetPoint("TOPLEFT",button,"BOTTOMLEFT",0,-8)
    end

    if RPAL.SetOpenDropdown then RPAL.SetOpenDropdown(m) end
    m:HookScript("OnHide",function(self)
        if RPAL._playerFrameWidgetMenu==self then RPAL._playerFrameWidgetMenu=nil end
    end)

    -- This frame blocks world clicks through the empty/header portion of the
    -- flyout.  It also acts as a coordinate-based input fallback if a client
    -- or another UI skin flattens the child frame levels unexpectedly.
    local inputCatcher=CreateFrame("Button",nil,m)
    inputCatcher:SetAllPoints(m)
    inputCatcher:EnableMouse(true)
    inputCatcher:RegisterForClicks("LeftButtonUp")
    if m.GetFrameLevel and inputCatcher.SetFrameLevel then
        pcall(inputCatcher.SetFrameLevel,inputCatcher,(m:GetFrameLevel() or 1)+1)
    end

    if count==0 then
        local empty=CreateFrame("Frame",nil,m,"BackdropTemplate")
        empty:SetPoint("TOPLEFT",12,-10)
        empty:SetPoint("TOPRIGHT",-12,-10)
        empty:SetHeight(34)
        empty:SetFrameLevel((m:GetFrameLevel() or 12070)+5)
        Backdrop(empty,PANEL,BORDER)
        local t=empty:CreateFontString(nil,"OVERLAY")
        t:SetFont(STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF",8.2,"")
        t:SetText("No shortcuts enabled. Configure them in Settings > Interface.")
        t:SetTextColor(.70,.63,.53,1)
        t:SetPoint("LEFT",9,0)
    else
        local choices={}
        local hoveredChoice

        local function CursorInside(frame)
            if not frame or not frame.IsShown or not frame:IsShown() then return false end
            local left,right,top,bottom=frame:GetLeft(),frame:GetRight(),frame:GetTop(),frame:GetBottom()
            if not left or not right or not top or not bottom then return false end
            local scale=(frame.GetEffectiveScale and frame:GetEffectiveScale()) or (UIParent:GetEffectiveScale() or 1)
            if not scale or scale<=0 then scale=1 end
            local x,y=GetCursorPosition()
            if not x or not y then return false end
            x,y=x/scale,y/scale
            return x>=left and x<=right and y>=bottom and y<=top
        end

        local function SetChoiceHovered(choice,hovered)
            if not choice or choice.hovered==hovered then return end
            choice.hovered=hovered and true or false
            local b,t,d=choice.button,choice.title,choice.description
            if hovered then
                b:SetBackdropColor(BUTTON_HOVER[1],BUTTON_HOVER[2],BUTTON_HOVER[3],BUTTON_HOVER[4] or 1)
                b:SetBackdropBorderColor(1,.72,.30,1)
                t:SetTextColor(1,.91,.66,1)
                d:SetTextColor(.82,.74,.62,1)
            else
                b:SetBackdropColor(PANEL[1],PANEL[2],PANEL[3],PANEL[4] or 1)
                b:SetBackdropBorderColor(unpack(BORDER))
                t:SetTextColor(1,.84,.50,1)
                d:SetTextColor(.70,.63,.53,1)
            end
        end

        local function ChoiceUnderCursor()
            -- Walk backwards so the visually highest choice wins if UI scale
            -- rounding ever makes two expanded hit rectangles touch.
            for index=#choices,1,-1 do
                local choice=choices[index]
                if CursorInside(choice.button) then return choice end
            end
        end

        local function ActivateChoice(choice)
            if not choice or m._odynActionDispatched or not m:IsShown() then return end
            m._odynActionDispatched=true
            RPAL.HidePlayerFrameWidgetMenu()
            if choice.entry and choice.entry.action then choice.entry.action() end
        end

        local function Choice(entry,index)
            local column=(columns==1) and 0 or math.floor((index-1)/maxRows)
            local row=(columns==1) and (index-1) or ((index-1)%maxRows)
            local x=side+column*(colW+gap)
            local y=-10-(row*rowH)
            local w=(columns==1) and (width-side*2) or colW

            local b=CreateFrame("Button",nil,m,"BackdropTemplate")
            b:SetSize(w,34)
            b:SetPoint("TOPLEFT",x,y)
            if b.SetFrameLevel and m.GetFrameLevel then
                pcall(b.SetFrameLevel,b,(m:GetFrameLevel() or 1)+10+index)
            end
            b:EnableMouse(true)
            b:RegisterForClicks("LeftButtonUp")
            b:SetHitRectInsets(-2,-2,-1,-1)
            Backdrop(b,PANEL,BORDER)

            local t=b:CreateFontString(nil,"OVERLAY")
            t:SetFont(STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF",9.5,"OUTLINE")
            t:SetText(string.upper(entry.label or "OPEN"))
            t:SetPoint("LEFT",9,5)
            t:SetPoint("RIGHT",-9,5)
            t:SetJustifyH("LEFT")
            t:SetTextColor(1,.84,.50,1)

            local d=b:CreateFontString(nil,"OVERLAY")
            d:SetFont(STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF",7.2,"")
            d:SetText(descriptions[entry.key] or "Open this ODYN section")
            d:SetPoint("LEFT",9,-8)
            d:SetPoint("RIGHT",-9,-8)
            d:SetJustifyH("LEFT")
            d:SetTextColor(.70,.63,.53,1)

            local choice={button=b,title=t,description=d,entry=entry}
            choices[#choices+1]=choice

            b:SetScript("OnEnter",function()SetChoiceHovered(choice,true)end)
            b:SetScript("OnLeave",function()SetChoiceHovered(choice,false)end)
            b:SetScript("OnMouseDown",function(_,mouseButton)
                if mouseButton=="LeftButton" then choice.pressed=true end
            end)
            -- Dispatch directly on mouse-up. OnClick remains as a fallback for
            -- clients that synthesize clicks without the normal down/up pair.
            b:SetScript("OnMouseUp",function(_,mouseButton)
                if mouseButton~="LeftButton" then return end
                local wasPressed=choice.pressed
                choice.pressed=false
                if wasPressed and CursorInside(b) then ActivateChoice(choice) end
            end)
            b:SetScript("OnClick",function(_,mouseButton)
                if mouseButton and mouseButton~="LeftButton" then return end
                ActivateChoice(choice)
            end)
        end

        for index,entry in ipairs(entries) do Choice(entry,index) end

        -- Polling is intentional and only exists while this tiny transient
        -- menu is visible. It restores hover feedback even if the catcher has
        -- to proxy input because another addon altered frame levels.
        inputCatcher:SetScript("OnUpdate",function()
            local choice=ChoiceUnderCursor()
            if choice~=hoveredChoice then
                SetChoiceHovered(hoveredChoice,false)
                hoveredChoice=choice
                SetChoiceHovered(hoveredChoice,true)
            end
        end)
        inputCatcher:SetScript("OnMouseDown",function(_,mouseButton)
            if mouseButton=="LeftButton" then inputCatcher._pressedChoice=ChoiceUnderCursor() end
        end)
        inputCatcher:SetScript("OnMouseUp",function(_,mouseButton)
            if mouseButton~="LeftButton" then return end
            local pressed=inputCatcher._pressedChoice
            inputCatcher._pressedChoice=nil
            local released=ChoiceUnderCursor()
            if pressed and pressed==released then ActivateChoice(released) end
        end)
        inputCatcher:SetScript("OnClick",function(_,mouseButton)
            if mouseButton and mouseButton~="LeftButton" then return end
            ActivateChoice(ChoiceUnderCursor())
        end)
    end

    if RPAL.AnimateFrameIn then RPAL.AnimateFrameIn(m,0.08,0) end
end


function RPAL.ApplyODYNEyeStyle(button,name,isMinimap)
    if not button then return end
    name=tostring(name or "Default")
    if not RPAL.ODYN_EYE_STYLES[name] then name="Default" end
    if not button._odynEyePreview then button:SetSize(isMinimap and 30 or 26,isMinimap and 30 or 26) end
    button._odynEyeStyle=name
    RPAL.EnsureODYNEyeArt(button)
    RPAL.LayoutODYNEyeArt(button)
end

function RPAL.RenderODYNEyePreview(parent,name,size)
    local button=CreateFrame("Frame",nil,parent)
    size=tonumber(size) or 58
    button._odynEyePreview=true
    button:SetSize(size,size)
    local bg=button:CreateTexture(nil,"BACKGROUND");bg:SetTexture("Interface\\Buttons\\WHITE8X8");bg:SetAllPoints();bg:SetAlpha(0);button.roundBG=bg
    local icon=button:CreateTexture(nil,"ARTWORK");icon:SetAlpha(0);button.icon=icon
    local ring=button:CreateTexture(nil,"OVERLAY",nil,2);ring:SetTexture(RPAL.ODYN_EYE_TEXTURE_ROOT.."eye_orb_ring.tga");ring:SetBlendMode("ADD");ring:SetPoint("CENTER");button.ring=ring
    local glow=button:CreateTexture(nil,"BACKGROUND",nil,-1);glow:SetTexture(TEX.."Celestial\\starlight_glow.tga");glow:SetPoint("CENTER");glow:SetSize(size+10,size+10);glow:SetAlpha(0.18);button.glow=glow
    MakeWidgetRound(button)
    RPAL.ApplyODYNEyeStyle(button,name,false)
    return button
end

function H.CreatePlayerFrameWidget()
    if playerFrameWidget then PositionPlayerFrameWidget();return end
    local parent=GetPlayerFrameAnchor()
    if not parent then return end

    local b=CreateFrame("Button","RPAL_PlayerFrameWidget",UIParent)
    playerFrameWidget=b
    b:SetSize(26,26)
    b:SetFrameStrata("LOW")
    b:SetFrameLevel(8)
    b:SetClampedToScreen(true)
    b:RegisterForClicks("LeftButtonUp")
    b:RegisterForDrag("LeftButton")
    local roundBG=b:CreateTexture(nil,"BACKGROUND")
    roundBG:SetTexture("Interface\\Buttons\\WHITE8X8")
    roundBG:SetAllPoints()
    roundBG:SetVertexColor(0.035,0.038,0.045,0.98)
    b.roundBG=roundBG

    local icon=b:CreateTexture(nil,"ARTWORK")
    icon:SetTexture(RPAL.ICONS.eye)
    icon:SetPoint("TOPLEFT",4,-4)
    icon:SetPoint("BOTTOMRIGHT",-4,4)
    icon:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],1)
    b.icon=icon

    -- Custom round ring: never inherit the square action-button silhouette.
    local ring=b:CreateTexture(nil,"OVERLAY",nil,2)
    ring:SetTexture(RPAL.ODYN_EYE_TEXTURE_ROOT.."eye_orb_ring.tga")
    ring:SetBlendMode("ADD")
    ring:SetSize(38,38)
    ring:SetPoint("CENTER")
    ring:SetVertexColor(1.00,0.82,0.24,0.96)
    b.ring=ring

    local glow=b:CreateTexture(nil,"BACKGROUND",nil,-1)
    glow:SetTexture("Interface\\Buttons\\WHITE8X8")
    glow:SetPoint("TOPLEFT",-3,3)
    glow:SetPoint("BOTTOMRIGHT",3,-3)
    glow:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],0.18)
    glow:SetAlpha(0)
    b.glow=glow
    MakeWidgetRound(b)
    RPAL.ApplyODYNEyeStyle(b,(RPAL.GetCosmeticSelection and RPAL.GetCosmeticSelection().odynEye) or "Default",false)

    b:SetScript("OnEnter",function(self)
        self.glow:SetAlpha(1)
        if RPAL.ShowCursorTooltip then RPAL.ShowCursorTooltip({
            eyebrow="ODYN",title="ODYN — Player Frame",
            body="Left-click for ODYN shortcuts.",
            sectionTitle="MOVE",section="Drag the orb around your Player Frame to reposition it.",
            accent=GOLD,width=350,
        }) end
    end)
    b:SetScript("OnLeave",function(self)
        if not playerFrameWidgetDragging then self.glow:SetAlpha(0) end
        if RPAL.HideCursorTooltip then RPAL.HideCursorTooltip() end
    end)
    AttachCenteredEyeHoverGrow(b,0.10)

    -- Keep dragging and clicking on separate event paths.  The old
    -- suppression timer consumed the first real click after repositioning.
    -- OnMouseDown resets the gesture, OnDragStart marks it as a drag, and
    -- OnMouseUp opens the menu only when that gesture never became a drag.
    b:SetScript("OnMouseDown",function(self,button)
        if button~="LeftButton" then return end
        playerFrameWidgetMoved=false
        local scale=UIParent:GetEffectiveScale()
        local mx,my=GetCursorPosition()
        playerFrameWidgetStartX=mx/scale
        playerFrameWidgetStartY=my/scale
    end)

    b:SetScript("OnDragStart",function(self)
        playerFrameWidgetDragging=true
        playerFrameWidgetMoved=true
        local scale=UIParent:GetEffectiveScale()
        local mx,my=GetCursorPosition()
        playerFrameWidgetStartX=mx/scale
        playerFrameWidgetStartY=my/scale
        self.glow:SetAlpha(1)
    end)

    b:SetScript("OnUpdate",function()
        if not playerFrameWidgetDragging then return end
        H.UpdatePlayerFrameWidgetDrag()
    end)

    b:SetScript("OnDragStop",function(self)
        if not playerFrameWidgetDragging then return end
        playerFrameWidgetDragging=false
        H.SavePlayerFrameWidgetPosition()
        if not self:IsMouseOver() then self.glow:SetAlpha(0) end
        -- Deliberately do not clear playerFrameWidgetMoved here.  Depending on
        -- client event ordering, OnMouseUp may run before or after OnDragStop;
        -- leaving the flag intact guarantees the drag-release cannot open the
        -- flyout.  The next MouseDown always resets it, so the next click works.
    end)

    b:SetScript("OnMouseUp",function(self,button)
        if button~="LeftButton" then return end
        if playerFrameWidgetDragging then
            playerFrameWidgetDragging=false
            H.SavePlayerFrameWidgetPosition()
        end
        if playerFrameWidgetMoved then
            playerFrameWidgetMoved=false
            return
        end
        if RPAL.PlayODYNEyeClickFX then RPAL.PlayODYNEyeClickFX(self,self._odynEyeStyle) end
        RPAL.TogglePlayerFrameWidgetMenu(self)
    end)

    PositionPlayerFrameWidget()
end

function RPAL.UpdatePlayerFrameWidget()
    if not playerFrameWidget then
        H.CreatePlayerFrameWidget()
        return
    end
    local eye=(RPAL.GetCosmeticSelection and RPAL.GetCosmeticSelection().odynEye) or "Default"
    RPAL.ApplyODYNEyeStyle(playerFrameWidget,eye,false)
    PositionPlayerFrameWidget()
end


local minimapWidget
local minimapWidgetDragging=false
local minimapWidgetMoved=false
local minimapStartX,minimapStartY

local function PositionMinimapWidget()
    if not minimapWidget then return end
    local minimap=_G.Minimap
    if not minimap then minimapWidget:Hide();return end

    local settings=RPAL.GetSettings()
    if not settings.minimapWidgetEnabled then
        minimapWidget:Hide()
        return
    end

    minimapWidget:Show()

    local angle=math.rad(tonumber(settings.minimapWidgetAngle) or 225)
    local radius=(minimap:GetWidth()/2)+10

    minimapWidget:ClearAllPoints()
    minimapWidget:SetPoint(
        "CENTER",minimap,"CENTER",
        math.cos(angle)*radius,
        math.sin(angle)*radius
    )
end

function H.SaveMinimapWidgetAngle()
    if not minimapWidget or not _G.Minimap then return end

    local mx,my=Minimap:GetCenter()
    local bx,by=minimapWidget:GetCenter()
    if not mx or not my or not bx or not by then return end

    RPAL.GetSettings().minimapWidgetAngle=math.deg(RPALAtan2(by-my,bx-mx))
    PositionMinimapWidget()
end

local function UpdateMinimapWidgetDrag()
    if not minimapWidgetDragging or not minimapWidget or not _G.Minimap then return end

    local scale=UIParent:GetEffectiveScale()
    local cx,cy=GetCursorPosition()
    cx,cy=cx/scale,cy/scale

    local mx,my=Minimap:GetCenter()
    if not mx or not my then return end

    local dx,dy=cx-mx,cy-my
    local distance=math.sqrt(dx*dx+dy*dy)
    if distance<0.001 then distance=1 end

    local radius=(Minimap:GetWidth()/2)+10
    dx=dx/distance*radius
    dy=dy/distance*radius

    minimapWidget:ClearAllPoints()
    minimapWidget:SetPoint("CENTER",Minimap,"CENTER",dx,dy)
end

local function CreateMinimapWidget()
    if minimapWidget then
        PositionMinimapWidget()
        return
    end
    if not _G.Minimap then return end

    local b=CreateFrame("Button","RPAL_MinimapWidget",UIParent)
    minimapWidget=b
    b:SetSize(30,30)
    b:SetFrameStrata("MEDIUM")
    b:SetFrameLevel((_G.Minimap and _G.Minimap:GetFrameLevel() or 1)+8)
    b:SetClampedToScreen(true)
    b:RegisterForClicks("LeftButtonUp")
    b:RegisterForDrag("LeftButton")

    local roundBG=b:CreateTexture(nil,"BACKGROUND")
    roundBG:SetTexture("Interface\\Buttons\\WHITE8X8")
    roundBG:SetAllPoints()
    roundBG:SetVertexColor(0.035,0.038,0.045,0.98)
    b.roundBG=roundBG

    local icon=b:CreateTexture(nil,"ARTWORK")
    icon:SetTexture(RPAL.ICONS.eye)
    icon:SetPoint("TOPLEFT",5,-5)
    icon:SetPoint("BOTTOMRIGHT",-5,5)
    icon:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],1)
    b.icon=icon

    local ring=b:CreateTexture(nil,"OVERLAY",nil,2)
    ring:SetTexture(RPAL.ODYN_EYE_TEXTURE_ROOT.."eye_orb_ring.tga");ring:SetBlendMode("ADD");ring:SetSize(42,42);ring:SetPoint("CENTER");ring:SetVertexColor(1.00,0.82,0.24,0.96);b.ring=ring

    local glow=b:CreateTexture(nil,"BACKGROUND",nil,-1)
    glow:SetTexture("Interface\\Buttons\\WHITE8X8")
    glow:SetPoint("TOPLEFT",-3,3)
    glow:SetPoint("BOTTOMRIGHT",3,-3)
    glow:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],0.18)
    glow:SetAlpha(0)
    b.glow=glow
    MakeWidgetRound(b)
    RPAL.ApplyODYNEyeStyle(b,(RPAL.GetCosmeticSelection and RPAL.GetCosmeticSelection().odynEye) or "Default",true)

    b:SetScript("OnEnter",function(self)
        self.glow:SetAlpha(1)
        if RPAL.ShowCursorTooltip then RPAL.ShowCursorTooltip({
            eyebrow="ODYN",title="ODYN — Minimap",
            body="Left-click to open your profile.",
            sectionTitle="MOVE",section="Drag the orb around your Minimap to reposition it.",
            accent=GOLD,width=350,
        }) end
    end)

    b:SetScript("OnLeave",function(self)
        if not minimapWidgetDragging then self.glow:SetAlpha(0) end
        if RPAL.HideCursorTooltip then RPAL.HideCursorTooltip() end
    end)
    AttachCenteredEyeHoverGrow(b,0.10)

    b:SetScript("OnDragStart",function(self)
        minimapWidgetDragging=true
        minimapWidgetMoved=false
        local scale=UIParent:GetEffectiveScale()
        local x,y=GetCursorPosition()
        minimapStartX=x/scale
        minimapStartY=y/scale
        self.glow:SetAlpha(1)
    end)

    b:SetScript("OnUpdate",function()
        if not minimapWidgetDragging then return end

        local scale=UIParent:GetEffectiveScale()
        local x,y=GetCursorPosition()
        x,y=x/scale,y/scale

        if minimapStartX and ((x-minimapStartX)^2+(y-minimapStartY)^2)>9 then
            minimapWidgetMoved=true
        end

        UpdateMinimapWidgetDrag()
    end)

    b:SetScript("OnDragStop",function(self)
        minimapWidgetDragging=false
        H.SaveMinimapWidgetAngle()
        if not self:IsMouseOver() then self.glow:SetAlpha(0) end
    end)

    b:SetScript("OnClick",function(_,button)
        if button~="LeftButton" then return end
        if minimapWidgetMoved then
            minimapWidgetMoved=false
            return
        end

        if RPAL.PlayODYNEyeClickFX then RPAL.PlayODYNEyeClickFX(b,b._odynEyeStyle) end
        if RPAL.ToggleMainUI then RPAL.ToggleMainUI() end
    end)

    PositionMinimapWidget()
end

function RPAL.UpdateMinimapWidget()
    if not minimapWidget then
        CreateMinimapWidget()
        return
    end
    local eye=(RPAL.GetCosmeticSelection and RPAL.GetCosmeticSelection().odynEye) or "Default"
    RPAL.ApplyODYNEyeStyle(minimapWidget,eye,true)
    PositionMinimapWidget()
end

local function HideAndDetachRPALFrame(frame)
    if not frame then return end
    pcall(function() frame:Hide() end)
    pcall(function() frame:SetScript("OnUpdate",nil) end)
    pcall(function() frame:SetParent(nil) end)
end

function RPAL.HardRebuildUI(reopenMain)
    -- VISUAL RESET ONLY.
    if RPAL.CloseTalentAtlas then pcall(RPAL.CloseTalentAtlas,false) end
    if RPAL.ResetChronicleUI then pcall(RPAL.ResetChronicleUI) end
    if RPAL.ResetInjuryUI then pcall(RPAL.ResetInjuryUI) end
    if RPAL.ResetSkillSetUI then pcall(RPAL.ResetSkillSetUI) end
    if RPAL.ResetCalendarUI then pcall(RPAL.ResetCalendarUI) end
    if RPAL.ResetToastDirector then pcall(RPAL.ResetToastDirector) end
    if RPAL.ResetSessionWidget then pcall(RPAL.ResetSessionWidget) end
    -- Never touches RPAL_DB / RPAL_CHAR_DB / progression / Crests / achievements.
    local wasShown = reopenMain
    if wasShown==nil then
        wasShown = UI.main and UI.main:IsShown()
    end

    -- Close/detach every RPAL-owned top-level window we know about.
    local topFrames={
        UI.main,
        UI.survivalWidget,
        activeBadgeEditorShade,
        activeMemoirDetailShade,
        activeShareView,
        remoteInspectWindow,
    }
    for _,frame in ipairs(topFrames) do
        HideAndDetachRPALFrame(frame)
    end

    -- Clear UI references so BuildMain creates a genuinely fresh visual tree.
    for key in pairs(UI) do
        UI[key]=nil
    end

    activeBadgeEditorShade=nil
    activeMemoirDetailShade=nil
    activeShareView=nil
    remoteInspectWindow=nil
    remoteInspectSender=nil
    remoteInspectRequestID=nil

    -- Rebuild from SavedVariables/state. Apply the palette first so static
    -- frame chrome is born with the new account theme after a live reset.
    ApplyCurrentTheme()
    BuildMain()

    -- Restore button handlers that are normally attached on login.
    if UI.tools then UI.tools:SetScript("OnClick",function()RPAL.OpenTools()end) end
    if UI.settings then UI.settings:SetScript("OnClick",function()RPAL.OpenSettings()end) end
    if UI.customize then UI.customize:SetScript("OnClick",function()RPAL.OpenCustomization()end) end

    ApplyCurrentTheme()
    if RPAL.RefreshThemeVisuals then RPAL.RefreshThemeVisuals()
    elseif RPAL.Refresh then RPAL.Refresh() end

    if wasShown and UI.main then
        UI.main:Show()
    end

    if RPAL.UpdateTargetBadge then RPAL.UpdateTargetBadge() end
    if RPAL.UpdatePlayerFrameWidget then RPAL.UpdatePlayerFrameWidget() end
    if RPAL.UpdateMinimapWidget then RPAL.UpdateMinimapWidget() end
    if RPAL.UpdateSurvivalWidget then RPAL.UpdateSurvivalWidget(0) end
    if RPAL.CreateSessionWidget then RPAL.CreateSessionWidget() end
    if RPAL.UpdateSessionWidget then RPAL.UpdateSessionWidget() end
end



local survivalTicker=nil

local function SurvivalBar(parent,y,label,color)
    local holder=CreateFrame("Frame",nil,parent,"BackdropTemplate")
    holder:SetSize(140,20)
    holder:SetPoint("TOPLEFT",11,y)
    holder:SetBackdrop({
        bgFile="Interface\\Tooltips\\UI-Tooltip-Background",
        edgeFile="Interface\\Tooltips\\UI-Tooltip-Border",
        tile=false,tileSize=16,edgeSize=9,
        insets={left=2,right=2,top=2,bottom=2},
    })
    holder:SetBackdropColor(0.025,0.022,0.026,0.96)
    holder:SetBackdropBorderColor(GOLD[1],GOLD[2],GOLD[3],0.38)

    local sigil=CreateFrame("Frame",nil,holder,"BackdropTemplate")
    sigil:SetSize(20,16);sigil:SetPoint("LEFT",3,0)
    sigil:SetBackdrop({bgFile="Interface\\Buttons\\WHITE8X8",edgeFile="Interface\\Tooltips\\UI-Tooltip-Border",edgeSize=7,insets={left=1,right=1,top=1,bottom=1}})
    sigil:SetBackdropColor(color[1]*0.13,color[2]*0.13,color[3]*0.13,0.98)
    sigil:SetBackdropBorderColor(color[1],color[2],color[3],0.62)
    local rune=Font(sigil,label,8.5,color,DEFAULT_FONT);rune:SetPoint("CENTER",0,0)
    rune:SetFont(DEFAULT_FONT,RPAL.GetScaledFontSize and RPAL.GetScaledFontSize(8.5) or 8.5,"")

    local bar=CreateFrame("StatusBar",nil,holder)
    bar:SetPoint("LEFT",sigil,"RIGHT",4,0);bar:SetPoint("RIGHT",-4,0);bar:SetHeight(11)
    bar:SetMinMaxValues(0,100);bar:SetValue(100)
    bar:SetStatusBarTexture("Interface\\TargetingFrame\\UI-StatusBar")
    bar:SetStatusBarColor(color[1],color[2],color[3],0.94)
    local bg=bar:CreateTexture(nil,"BACKGROUND");bg:SetAllPoints();bg:SetTexture("Interface\\TargetingFrame\\UI-StatusBar");bg:SetVertexColor(0.035,0.035,0.040,0.94)
    local shine=bar:CreateTexture(nil,"ARTWORK");shine:SetTexture("Interface\\Buttons\\WHITE8X8");shine:SetPoint("TOPLEFT",1,-1);shine:SetPoint("TOPRIGHT",-1,-1);shine:SetHeight(1);shine:SetVertexColor(1,1,1,0.14)
    local value=Font(bar,"100%",7.5,PALE,DEFAULT_FONT);value:SetPoint("CENTER",0,0);value:SetFont(DEFAULT_FONT,RPAL.GetScaledFontSize and RPAL.GetScaledFontSize(7.5) or 7.5,"")
    value:SetShadowColor(0,0,0,1);value:SetShadowOffset(1,-1);bar._rpalValueText=value
    holder._rpalSemanticColor=color
    holder._rpalSigil=sigil
    holder._rpalRune=rune
    holder._rpalBar=bar
    bar._rpalBackground=bg
    bar._rpalShine=shine
    return holder,bar
end

local function ApplySurvivalWidgetTheme(widget)
    if not widget then return end
    local signature=table.concat({
        string.format("%.3f:%.3f:%.3f",MAIN_BG[1],MAIN_BG[2],MAIN_BG[3]),
        string.format("%.3f:%.3f:%.3f",PANEL2[1],PANEL2[2],PANEL2[3]),
        string.format("%.3f:%.3f:%.3f",GOLD[1],GOLD[2],GOLD[3]),
        string.format("%.3f:%.3f:%.3f",PALE[1],PALE[2],PALE[3]),
    },"|")
    if widget._rpalThemeSignature==signature then return end
    widget._rpalThemeSignature=signature

    widget:SetBackdropColor(MAIN_BG[1],MAIN_BG[2],MAIN_BG[3],0.96)
    widget:SetBackdropBorderColor(GOLD[1],GOLD[2],GOLD[3],0.66)
    if widget._rpalGlow then widget._rpalGlow:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],0.07) end
    if widget._rpalTopRule then widget._rpalTopRule:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],0.22) end
    if widget._rpalTitle then widget._rpalTitle:SetTextColor(GOLD[1],GOLD[2],GOLD[3],1) end

    for _,holder in ipairs({UI.survivalFoodHolder,UI.survivalWaterHolder}) do
        if holder then
            holder:SetBackdropColor(PANEL2[1],PANEL2[2],PANEL2[3],0.96)
            holder:SetBackdropBorderColor(GOLD[1],GOLD[2],GOLD[3],0.38)
            local semantic=holder._rpalSemanticColor or GOLD
            if holder._rpalSigil then
                holder._rpalSigil:SetBackdropColor(PANEL[1],PANEL[2],PANEL[3],0.98)
                holder._rpalSigil:SetBackdropBorderColor(semantic[1],semantic[2],semantic[3],0.62)
            end
            if holder._rpalRune then holder._rpalRune:SetTextColor(semantic[1],semantic[2],semantic[3],1) end
            if holder._rpalBar and holder._rpalBar._rpalBackground then
                holder._rpalBar._rpalBackground:SetVertexColor(MAIN_BG[1],MAIN_BG[2],MAIN_BG[3],0.94)
            end
            if holder._rpalBar and holder._rpalBar._rpalValueText then
                holder._rpalBar._rpalValueText:SetTextColor(PALE[1],PALE[2],PALE[3],1)
            end
        end
    end

    local hold=UI.survivalHoldButton
    if hold and not hold._survivalHeld and (not hold.IsMouseOver or not hold:IsMouseOver()) then
        hold:SetBackdropColor(PANEL2[1],PANEL2[2],PANEL2[3],0.98)
        hold:SetBackdropBorderColor(GOLD[1],GOLD[2],GOLD[3],0.46)
    end
    if widget._rpalHoldText then widget._rpalHoldText:SetTextColor(PALE[1],PALE[2],PALE[3],1) end
end

local function SetSurvivalDebuffText(hungry,dehydrated)
    if not UI.survivalDebuffText then return end

    if hungry and dehydrated then
        UI.survivalDebuffText:SetText("|cffff9338HUNGRY|r   |cff52b8ffDEHYDRATED|r")
        UI.survivalDebuffText:Show()
    elseif hungry then
        UI.survivalDebuffText:SetText("|cffff9338HUNGRY|r")
        UI.survivalDebuffText:Show()
    elseif dehydrated then
        UI.survivalDebuffText:SetText("|cff52b8ffDEHYDRATED|r")
        UI.survivalDebuffText:Show()
    else
        UI.survivalDebuffText:SetText("")
        UI.survivalDebuffText:Hide()
    end
end

local function SaveSurvivalWidgetPosition(frame)
    if not frame or not RPAL.GetSettings then return end
    local settings=RPAL.GetSettings()

    local centerX,centerY=frame:GetCenter()
    local screenX,screenY=UIParent:GetCenter()
    if centerX and centerY and screenX and screenY then
        settings.survivalWidgetX=math.floor(centerX-screenX+0.5)
        settings.survivalWidgetY=math.floor(centerY-screenY+0.5)
    end
end

local function BuildSurvivalWidget()
    if UI.survivalWidget then return UI.survivalWidget end

    local widget=CreateFrame("Frame",nil,UIParent,"BackdropTemplate")
    UI.survivalWidget=widget
    widget:SetSize(162,98)
    widget:SetFrameStrata("MEDIUM");widget:SetFrameLevel(30);widget:SetClampedToScreen(true);widget:SetMovable(true);widget:EnableMouse(true)

    local settings=RPAL.GetSettings()
    widget:SetPoint("CENTER",UIParent,"CENTER",tonumber(settings.survivalWidgetX) or 0,tonumber(settings.survivalWidgetY) or -120)
    widget:SetBackdrop({bgFile="Interface\\Tooltips\\UI-Tooltip-Background",edgeFile="Interface\\Tooltips\\UI-Tooltip-Border",tile=false,tileSize=16,edgeSize=14,insets={left=4,right=4,top=4,bottom=4}})
    widget:SetBackdropColor(MAIN_BG[1],MAIN_BG[2],MAIN_BG[3],0.96);widget:SetBackdropBorderColor(GOLD[1],GOLD[2],GOLD[3],0.66)

    local glow=widget:CreateTexture(nil,"BACKGROUND",nil,-1);widget._rpalGlow=glow;glow:SetTexture(TEX.."Talent\\sparkle.tga");glow:SetBlendMode("ADD");glow:SetSize(150,62);glow:SetPoint("TOP",0,16);glow:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],0.07)
    local topRule=widget:CreateTexture(nil,"ARTWORK");widget._rpalTopRule=topRule;topRule:SetTexture("Interface\\Buttons\\WHITE8X8");topRule:SetHeight(1);topRule:SetPoint("TOPLEFT",12,-20);topRule:SetPoint("TOPRIGHT",-12,-20);topRule:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],0.22)
    local title=Font(widget,"SURVIVAL",8.5,GOLD,DEFAULT_FONT);widget._rpalTitle=title;title:SetPoint("TOP",0,-7);title:SetFont(DEFAULT_FONT,RPAL.GetScaledFontSize and RPAL.GetScaledFontSize(8.5) or 8.5,"")

    local foodHolder,foodBar=SurvivalBar(widget,-25,"F",HUNGER);UI.survivalFoodHolder=foodHolder;UI.survivalFoodBar=foodBar
    local waterHolder,waterBar=SurvivalBar(widget,-48,"W",THIRST);UI.survivalWaterHolder=waterHolder;UI.survivalWaterBar=waterBar

    local hold=CreateFrame("Button",nil,widget,"BackdropTemplate");UI.survivalHoldButton=hold
    hold:SetSize(140,21);hold:SetPoint("BOTTOM",0,8)
    hold:SetBackdrop({bgFile="Interface\\Tooltips\\UI-Tooltip-Background",edgeFile="Interface\\Tooltips\\UI-Tooltip-Border",tile=false,tileSize=16,edgeSize=9,insets={left=2,right=2,top=2,bottom=2}})
    hold:SetBackdropColor(PANEL2[1],PANEL2[2],PANEL2[3],0.98);hold:SetBackdropBorderColor(GOLD[1],GOLD[2],GOLD[3],0.46)
    local holdText=Font(hold,"HOLD  L: EAT    •    R: DRINK",7.8,PALE,DEFAULT_FONT);widget._rpalHoldText=holdText;holdText:SetPoint("CENTER",0,0);holdText:SetFont(DEFAULT_FONT,RPAL.GetScaledFontSize and RPAL.GetScaledFontSize(7.8) or 7.8,"")

    UI.survivalDebuffText=Font(widget,"",9,PALE,DEFAULT_FONT);UI.survivalDebuffText:SetPoint("TOP",widget,"BOTTOM",0,-4);UI.survivalDebuffText:SetJustifyH("CENTER");UI.survivalDebuffText:SetShadowColor(0,0,0,1);UI.survivalDebuffText:SetShadowOffset(1,-1);UI.survivalDebuffText:Hide()

    widget:SetScript("OnMouseDown",function(self,button)if button=="LeftButton" then self._rpalDragging=true;self:StartMoving() end end)
    widget:SetScript("OnMouseUp",function(self,button)if button=="LeftButton" and self._rpalDragging then self._rpalDragging=nil;self:StopMovingOrSizing();SaveSurvivalWidgetPosition(self) end end)
    widget:SetScript("OnHide",function(self)if self._rpalDragging then self._rpalDragging=nil;self:StopMovingOrSizing();SaveSurvivalWidgetPosition(self) end end)
    widget:SetScript("OnEnter",function()if RPAL.ShowCursorTooltip then RPAL.ShowCursorTooltip({eyebrow="SURVIVAL NEEDS",title="Survival HUD",body="Food and Water decay while this character is logged in; Settings can also pause decay while OOC.",sectionTitle="MOVE",section="Left-drag the frame body to reposition it.",detailTitle="CONSUME",detail="Hold Left Mouse to eat or Right Mouse to drink.",accent=GOLD,width=365}) end end)
    widget:SetScript("OnLeave",function()if RPAL.HideCursorTooltip then RPAL.HideCursorTooltip() end end)

    hold:SetScript("OnEnter",function(self)self:SetScale(1.025);self:SetBackdropColor(ACCENT_BG[1],ACCENT_BG[2],ACCENT_BG[3],0.98);self:SetBackdropBorderColor(GOLD[1],GOLD[2],GOLD[3],0.92)end)
    hold:SetScript("OnLeave",function(self)self:SetScale(1);if not self._survivalHeld then self:SetBackdropColor(PANEL2[1],PANEL2[2],PANEL2[3],0.98);self:SetBackdropBorderColor(GOLD[1],GOLD[2],GOLD[3],0.46)end end)
    hold:SetScript("OnMouseDown",function(self,button)
        if not RPAL.GetSettings().survivalEnabled then return end
        if button=="LeftButton" then self._survivalHeld="food" elseif button=="RightButton" then self._survivalHeld="water" else return end
        self:SetBackdropColor(ACCENT_HOVER[1],ACCENT_HOVER[2],ACCENT_HOVER[3],0.98);self:SetBackdropBorderColor(GOLD[1],GOLD[2],GOLD[3],1)
        self:SetScript("OnUpdate",function(btn,elapsed)
            if not btn._survivalHeld then return end
            local ok,flagsChanged=RPAL.RefillSurvival(btn._survivalHeld,(elapsed or 0)*25)
            if ok and RPAL.UpdateSurvivalWidget then RPAL.UpdateSurvivalWidget(0) end
            if flagsChanged and RPAL.RefreshCharacterPreview then RPAL.RefreshCharacterPreview() end
        end)
    end)
    hold:SetScript("OnMouseUp",function(self)self._survivalHeld=nil;self:SetScript("OnUpdate",nil);self:SetScale(self:IsMouseOver() and 1.025 or 1);self:SetBackdropColor(PANEL2[1],PANEL2[2],PANEL2[3],0.98);self:SetBackdropBorderColor(GOLD[1],GOLD[2],GOLD[3],self:IsMouseOver() and 0.92 or 0.46)end)
    ApplySurvivalWidgetTheme(widget)
    return widget
end

function RPAL.UpdateSurvivalWidget(elapsed)
    local settings=RPAL.GetSettings()
    local widget=BuildSurvivalWidget()
    ApplySurvivalWidgetTheme(widget)

    if not settings.survivalEnabled then
        widget:Hide()
        SetSurvivalDebuffText(false,false)
        return
    end

    local state,flagsChanged=RPAL.UpdateSurvivalState(elapsed or 0)

    widget:Show()

    if UI.survivalFoodBar then
        local v=math.max(0,math.min(100,state.food or 0));UI.survivalFoodBar:SetValue(v)
        if UI.survivalFoodBar._rpalValueText then UI.survivalFoodBar._rpalValueText:SetText(tostring(math.floor(v+0.5)).."%") end
    end
    if UI.survivalWaterBar then
        local v=math.max(0,math.min(100,state.water or 0));UI.survivalWaterBar:SetValue(v)
        if UI.survivalWaterBar._rpalValueText then UI.survivalWaterBar._rpalValueText:SetText(tostring(math.floor(v+0.5)).."%") end
    end

    local hungry,dehydrated=RPAL.GetSurvivalFlags()
    SetSurvivalDebuffText(hungry,dehydrated)

    if flagsChanged and UI.ready and UI.main and UI.main:IsShown() and RPAL.RefreshCharacterPreview then
        RPAL.RefreshCharacterPreview()
    end
end

local function StartSurvivalTicker()
    if survivalTicker then return end
    if not C_Timer or not C_Timer.NewTicker then return end

    -- Survival decay is measured in hours. A five-second safety refresh keeps
    -- the display exact while avoiding a permanent one-Hz SavedVariables pass;
    -- held refill buttons continue to update immediately on their own driver.
    local interval=5.0
    survivalTicker=C_Timer.NewTicker(interval,function()
        if not RPAL.char or not RPAL.db then return end
        RPAL.UpdateSurvivalWidget(interval)
    end)
end

RPAL.StartSurvivalTicker=StartSurvivalTicker

-- Register named RPAL surfaces with WoW's native ESC handler. Full-screen
-- input shields must always have a recovery path even if a child panel fails
-- to render, throws during construction or is unexpectedly layered behind it.
function RPAL.RegisterEscapeFrame(frame)
    local name=frame and frame.GetName and frame:GetName()
    if not name or name=="" then return false end
    _G.UISpecialFrames=_G.UISpecialFrames or {}
    for _,registered in ipairs(_G.UISpecialFrames) do
        if registered==name then frame._rpalEscapeRegistered=true;return true end
    end
    table.insert(_G.UISpecialFrames,name)
    frame._rpalEscapeRegistered=true
    return true
end

function RPAL.ShowMainUI()
    local frame=UI and UI.main
    if not frame then return false end
    if frame._odynExitDriver then frame._odynExitDriver:SetScript("OnUpdate",nil) end
    frame._odynMainFadingOut=nil
    ApplyCurrentTheme()
    if not frame:IsShown() then frame:Show() else frame:SetAlpha(1) end
    if RPAL.Refresh then RPAL.Refresh() end
    return true
end

function RPAL.HideMainUI()
    local frame=UI and UI.main
    if not frame or not frame:IsShown() then return false end
    if frame._odynMainFadingOut then return true end
    frame._odynMainFadingOut=true
    local function Finish()
        if not frame then return end
        frame._odynMainFadingOut=nil
        frame:SetAlpha(1)
        frame:Hide()
    end
    if RPAL.AnimateFrameOut then RPAL.AnimateFrameOut(frame,0.14,0,Finish) else Finish() end
    return true
end

function RPAL.ToggleMainUI()
    local frame=UI and UI.main
    if not frame then return false end
    if frame:IsShown() then return RPAL.HideMainUI() end
    return RPAL.ShowMainUI()
end

BuildMain=function()
    UI.ready=false

    mainFrameSerial=mainFrameSerial+1
    local mainFrameName=mainFrameSerial==1 and "RPAL_MainFrame" or ("RPAL_MainFrame_"..tostring(mainFrameSerial))
    local f=CreateFrame("Frame",mainFrameName,UIParent,"BackdropTemplate")
    UI.main=f
    UI.mainFrameName=mainFrameName

    -- Let the standard WoW ESC key close the main RPAL window.
    RPAL.RegisterEscapeFrame(f)
    f:SetSize(1240,760)
    f:SetPoint("CENTER")
    if RPAL.ApplyUILayer then RPAL.ApplyUILayer(f,"MAIN") else f:SetFrameStrata("DIALOG");f:SetFrameLevel(100);f:SetToplevel(true) end
    f:SetClampedToScreen(true)
    Backdrop(f,MAIN_BG,BORDER)
    AddFrameShadow(f,12,0.72)
    AddBookDecoration(f)
    AddInnerBorder(f,12,0.14)

    local mainCosmetics=RPAL.GetCosmeticSelection and RPAL.GetCosmeticSelection() or {}
    local mainOrnament=RenderUIOrnament(f,mainCosmetics.uiOrnament or "Classic",GOLD,9)
    if mainOrnament then UI.mainOrnament=mainOrnament end
    if RPAL.RenderEffectPack then UI.mainEffect=RPAL.RenderEffectPack(f,mainCosmetics.effectPack or "None",GOLD) end

    local brandPlate=f:CreateTexture(nil,"ARTWORK")
    UI.brandPlate=brandPlate
    brandPlate:SetTexture("Interface\\Buttons\\WHITE8X8")
    brandPlate:SetColorTexture(MAIN_BG[1],MAIN_BG[2],MAIN_BG[3],0.94)
    brandPlate:SetSize(158,14)
    brandPlate:SetPoint("TOPLEFT",36,-1)

    local brand=Font(f,"ODYN",8,GOLD,DEFAULT_FONT)
    UI.brandText=brand
    brand:SetPoint("CENTER",brandPlate,0,0)

    f:SetMovable(true);f:EnableMouse(true);
f:Hide()
    f:RegisterForDrag("LeftButton")
    f:SetScript("OnDragStart",function(self)
        if IsAltKeyDown() then self:StartMoving() end
    end)
    f:SetScript("OnDragStop",function(self)self:StopMovingOrSizing()end)
    f:SetScript("OnHide",function()
        -- ESC hides the parent first. Explicitly close the still-shown child
        -- before detaching it, otherwise it can become a full-screen orphan.
        if UI.customizationFrame and RPAL.CloseCustomization then
            RPAL.CloseCustomization(UI.customizationFrame)
        end
    end)
    f:HookScript("OnShow",function(self)
        if RPAL.CloseTransientUI then RPAL.CloseTransientUI(self) end
        if RPAL.AnimateFrameIn then RPAL.AnimateFrameIn(self,0.14,0) end
        local function ShowPatchNotes()
            if self:IsShown() and RPAL.MaybeShowPatchNotes then RPAL.MaybeShowPatchNotes(false) end
        end
        if C_Timer and C_Timer.After then C_Timer.After(0.12,ShowPatchNotes) else ShowPatchNotes() end
    end)

    local close=CloseButton(f)
    UI.closeButton=close
    close:SetPoint("TOPRIGHT",-8,-8)
    close:SetFrameLevel((f:GetFrameLevel() or 0)+190)
    close:SetScript("OnClick",function()if RPAL.HideMainUI then RPAL.HideMainUI() else f:Hide() end end)

    local header=CreateFrame("Frame",nil,f,"BackdropTemplate")
    UI.header=header
    UI.right=header
    header:SetPoint("TOPLEFT",20,-14)
    header:SetPoint("TOPRIGHT",-14,-14)
    header:SetHeight(142)
    Backdrop(header,PAGE_BG_ALT,BORDER)
    AddPageDecoration(header)
    PolishPanel(header,"major")
    -- The atmosphere is rendered once on the lower book surface.  Keeping the
    -- identity masthead clean prevents the same artwork appearing as two
    -- stacked backgrounds in one window.

    -- Header/content seam gives the whole window a single-book composition.
    local headerRule=header:CreateTexture(nil,"ARTWORK")
    headerRule:SetColorTexture(GOLD[1],GOLD[2],GOLD[3],0.34)
    headerRule:SetHeight(2);headerRule:SetPoint("BOTTOMLEFT",18,3);headerRule:SetPoint("BOTTOMRIGHT",-18,3)

    UI.preview=CreateFrame("Frame",nil,header)
    UI.preview:SetPoint("TOPLEFT",12,-8)
    UI.preview:SetPoint("BOTTOMRIGHT",-405,8)
    UI.preview:SetFrameLevel((header:GetFrameLevel() or 0)+3)

    UI.edit=Button(header,"Edit",64,30,false)
    UI.edit:SetPoint("TOPRIGHT",-38,-14)

    UI.ledgerToggle=Button(header,"Achievements",124,30,false)
    UI.ledgerToggle:SetPoint("RIGHT",UI.edit,"LEFT",-8,0)

    UI.tools=Button(header,"Tools",70,30,true)
    UI.tools:SetPoint("RIGHT",UI.ledgerToggle,"LEFT",-8,0)

    UI.customize=Button(header,"Customize",88,30,false)
    UI.customize:SetPoint("RIGHT",UI.tools,"LEFT",-8,0)
    UI.customize._rpalRedundantHeaderButton=true
    UI.customize:Hide()

    UI.settings=Button(header,"Settings",78,30,false)
    UI.settings:SetPoint("RIGHT",UI.tools,"LEFT",-8,0)

    local actionDivider=header:CreateTexture(nil,"ARTWORK")
    actionDivider:SetTexture("Interface\\Buttons\\WHITE8X8")
    actionDivider:SetColorTexture(1,1,1,0.18)
    actionDivider:SetWidth(1)
    actionDivider:SetPoint("TOPRIGHT",-398,-12)
    actionDivider:SetPoint("BOTTOMRIGHT",-398,12)
    actionDivider._rpalDecorRole="ACCENT"

    local controlsLabel=Font(header,"TOKEN RESERVE",8,MUTED,DEFAULT_FONT)
    UI.controlsLabel=controlsLabel
    controlsLabel:SetPoint("TOPRIGHT",-38,-57)

    local crestCapsule=CreateFrame("Frame",nil,header,"BackdropTemplate")
    UI.crestCapsule=crestCapsule
    crestCapsule:SetFrameLevel((header:GetFrameLevel() or 0)+6)
    crestCapsule:SetSize(70,26)
    crestCapsule:SetPoint("TOPRIGHT",-38,-76)
    Backdrop(crestCapsule,BUTTON_BG,BORDER)

    UI.crestIcon=crestCapsule:CreateTexture(nil,"ARTWORK")
    UI.crestIcon:SetTexture(NATIVE_CREST_ICON)
    UI.crestIcon:SetSize(18,18)
    UI.crestIcon:SetPoint("LEFT",7,0)
    UI.crestIcon:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],1)

    UI.crestText=Font(crestCapsule,"0",11,GOLD,DEFAULT_FONT)
    UI.crestText:SetPoint("LEFT",UI.crestIcon,"RIGHT",5,0)

    UI.talentAtlas=Button(header,"Talent Atlas",104,26,true)
    UI.talentAtlas:SetPoint("RIGHT",crestCapsule,"LEFT",-8,0)
    UI.talentAtlas._rpalRedundantHeaderButton=true
    UI.talentAtlas:Hide()
    UI.talentAtlas:SetFrameLevel((header:GetFrameLevel() or 0)+25)
    RPAL.TalentButton=UI.talentAtlas
    UI.talentAtlas:RegisterForClicks("LeftButtonUp")
    if RPAL.BindTalentAtlasButton then
        RPAL.BindTalentAtlasButton(UI.talentAtlas)
    else
        UI.talentAtlas:SetScript("OnClick",function()
            if RPAL.OpenTalentAtlas then
                RPAL.OpenTalentAtlas()
            else
                print("|cffff6666ODYN:|r Talent Atlas module is not available.")
            end
        end)
    end
    if RPAL.RefreshTalentButton then RPAL.RefreshTalentButton() end

    -- Chronicle is intentionally one-click accessible from the main header.
    -- A Session needs no setup: start tracking immediately, name it only when it ends.
    UI.sceneQuick=Button(header,"Start Session",84,26,true)
    UI.sceneQuick:SetPoint("RIGHT",crestCapsule,"LEFT",-8,0)
    UI.sceneQuick:SetFrameLevel((header:GetFrameLevel() or 0)+25)
    UI.chronicle=Button(header,"Chronicle",76,26,false)
    UI.chronicle:SetPoint("RIGHT",UI.sceneQuick,"LEFT",-8,0)
    UI.chronicle:SetFrameLevel((header:GetFrameLevel() or 0)+25)
    if RPAL.BindSceneButtons then RPAL.BindSceneButtons(UI.sceneQuick,UI.chronicle) end

    local content=CreateFrame("Frame",nil,f,"BackdropTemplate")
    UI.content=content
    content:SetPoint("TOPLEFT",20,-168)
    content:SetPoint("BOTTOMRIGHT",-14,78)
    Backdrop(content,PAGE_BG,BORDER)
    AddPageDecoration(content)
    PolishPanel(content,"major")
    -- One gently animated pass gives the main pages visible ambience while
    -- remaining below every card, label and button.
    RenderAtmosphere(content,mainCosmetics.background or "None",GOLD,0.72,true)

    UI.statistics=CreateFrame("Frame",nil,content)
    UI.statistics:SetAllPoints()
    UI.statisticsContent=CreateFrame("Frame",nil,UI.statistics)
    UI.statisticsContent:SetAllPoints()

    local ledger=CreateFrame("Frame",nil,content)
    UI.ledger=ledger
    UI.left=ledger
    ledger:SetAllPoints()
    ledger:Hide()

    UI.characterTab=Button(ledger,"Character",116,30,false)
    UI.characterTab:SetPoint("TOPLEFT",18,-14)
    Backdrop(UI.characterTab,PANEL2,GOLD)
    UI.characterTab.label:SetTextColor(unpack(PALE))

    UI.accountTab=Button(ledger,"Account",116,30,false)
    UI.accountTab:SetPoint("LEFT",UI.characterTab,"RIGHT",4,0)
    Backdrop(UI.accountTab,BUTTON_BG,BORDER)
    UI.accountTab.label:SetTextColor(unpack(MUTED))

    UI.scopeHint=Font(ledger,"CHARACTER BOOK  •  this TRP identity only",9,MUTED,DEFAULT_FONT)
    UI.scopeHint:SetPoint("TOPRIGHT",-18,-23)
    UI.scopeHint:SetWidth(390)
    UI.scopeHint:SetJustifyH("RIGHT")

    UI.characterTab:SetScript("OnClick",function()RPAL.SetScope("character")end)
    UI.accountTab:SetScript("OnClick",function()RPAL.SetScope("account")end)

    local title=Heading(ledger,"ACHIEVEMENT LEDGER",21)
    title:SetPoint("TOPLEFT",18,-58)
    local titleRule=ledger:CreateTexture(nil,"ARTWORK")
    titleRule:SetTexture("Interface\\Buttons\\WHITE8X8")
    titleRule:SetColorTexture(1,1,1,0.18)
    titleRule:SetHeight(1)
    titleRule:SetPoint("LEFT",title,"RIGHT",160,0)
    titleRule:SetPoint("RIGHT",ledger,-160,0)
    titleRule._rpalDecorRole="ACCENT"
    UI.progressText=Font(ledger,"",14,{0.82,0.76,0.64},DEFAULT_FONT)
    UI.progressText:SetPoint("LEFT",title,"RIGHT",14,0)

    UI.addCategory=Button(ledger,"+ New Category",118,28,true)
    UI.addCategory:SetPoint("TOPRIGHT",-18,-54)

    local sep=ledger:CreateTexture(nil,"ARTWORK")
    sep:SetColorTexture(0.45,0.32,0.13,0.65)
    sep:SetHeight(1)
    sep:SetPoint("TOPLEFT",18,-92)
    sep:SetPoint("TOPRIGHT",-18,-92)

    local scroll=CreateFrame("ScrollFrame",nil,ledger,"UIPanelScrollFrameTemplate")
    if RPAL.SkinScrollFrame then RPAL.SkinScrollFrame(scroll) end
    UI.achievementScroll=scroll
    scroll:SetPoint("TOPLEFT",18,-102)
    scroll:SetPoint("BOTTOMRIGHT",-34,18)

    local achievementContent=CreateFrame("Frame",nil,scroll)
    UI.achievementContent=achievementContent
    achievementContent:SetSize(710,1)
    scroll:SetScrollChild(achievementContent)

    UI.editor=CreateFrame("Frame",nil,content)
    UI.editor:SetPoint("TOPLEFT",18,-14)
    UI.editor:SetPoint("BOTTOMRIGHT",-30,14)
    UI.editor:Hide()

    UI.addCategory:SetScript("OnClick",function()
        if editMode and ledgerMode then RPAL.OpenCategoryEditor() end
    end)

    UI.edit:SetScript("OnClick",function()
        if RPAL.SetMainContentMode then RPAL.SetMainContentMode(editMode and "overview" or "edit") end
    end)

    UI.ledgerToggle:SetScript("OnClick",function()
        if RPAL.SetMainContentMode then RPAL.SetMainContentMode(ledgerMode and "overview" or "achievements") end
    end)

    -- Persistent Renown progression strip.
    local progression=CreateFrame("Frame",nil,f,"BackdropTemplate")
    UI.progression=progression
    progression:SetPoint("BOTTOMLEFT",20,14)
    progression:SetPoint("BOTTOMRIGHT",-14,14)
    progression:SetHeight(54)
    -- Flat, fully theme-controlled background.
    Backdrop(progression,PANEL2,GOLD)
    PolishPanel(progression,"major")

    local progressionArt=progression:CreateTexture(nil,"BACKGROUND",nil,-2)
    progressionArt:SetTexture(ASSET.PROGRESS_PANEL)
    progressionArt:SetPoint("TOPLEFT",7,-5)
    progressionArt:SetPoint("BOTTOMRIGHT",-7,5)
    progressionArt:SetAlpha(0.64)
    pcall(function()progressionArt:SetDesaturated(true)end)
    progressionArt._rpalDecorRole="PAGE"

    UI.levelText=Font(progression,"LEVEL 1",16,PALE,DEFAULT_FONT)
    UI.levelText._rpalColorRole=nil
    UI.levelText:SetPoint("LEFT",22,5)

    UI.levelSubText=Font(progression,"Your legend grows.",9,MUTED,DEFAULT_FONT)
    UI.levelSubText:SetPoint("TOPLEFT",UI.levelText,"BOTTOMLEFT",0,-2)

    UI.renownBarTitle=Font(progression,"RENOWN TO NEXT LEVEL",10,GOLD,DEFAULT_FONT)
    UI.renownBarTitle:SetPoint("LEFT",280,13)

    local track=CreateFrame("StatusBar",nil,progression)
    UI.renownBar=track
    track:SetPoint("LEFT",280,-7)
    track:SetSize(520,20)
    track:SetStatusBarTexture(ASSET.PROGRESS_FILL)
    track:SetStatusBarColor(GOLD[1],GOLD[2],GOLD[3],0.96)
    local bg=track:CreateTexture(nil,"BACKGROUND")
    bg:SetTexture(ASSET.PROGRESS_TRACK)
    bg:SetAllPoints()
    bg:SetVertexColor(PAGE_BG_ALT[1],PAGE_BG_ALT[2],PAGE_BG_ALT[3],0.95)
    track.bg=bg

    local barTop=track:CreateTexture(nil,"ARTWORK")
    barTop:SetTexture("Interface\\Buttons\\WHITE8X8")
    barTop:SetColorTexture(1,1,1,0.20)
    barTop:SetPoint("TOPLEFT",2,-2);barTop:SetPoint("TOPRIGHT",-2,-2);barTop:SetHeight(1)
    barTop._rpalDecorRole="ACCENT"

    UI.renownBarText=Font(track,"0 / 10",11,{0.96,0.91,0.82},DEFAULT_FONT)
    UI.renownBarText:SetPoint("CENTER",0,1)

    local progressionDivider=progression:CreateTexture(nil,"ARTWORK")
    progressionDivider:SetTexture("Interface\\Buttons\\WHITE8X8")
    progressionDivider:SetColorTexture(1,1,1,0.16)
    progressionDivider:SetWidth(1)
    progressionDivider:SetPoint("TOPLEFT",820,-10)
    progressionDivider:SetPoint("BOTTOMLEFT",820,10)
    progressionDivider._rpalDecorRole="ACCENT"
    UI.progressionDivider=progressionDivider

    UI.nextLevelText=Font(progression,"NEXT LEVEL: 2",11,GOLD,DEFAULT_FONT)
    UI.nextLevelText:SetPoint("LEFT",840,9)
    UI.nextLevelNeed=Font(progression,"Need 10 more Renown",9,PALE,DEFAULT_FONT)
    UI.nextLevelNeed:SetPoint("TOPLEFT",UI.nextLevelText,"BOTTOMLEFT",0,-3)

    UI.scaleGrip=CreateFrame("Button",nil,f)
    UI.scaleGrip:SetSize(18,18)
    UI.scaleGrip:SetPoint("BOTTOMRIGHT",-8,8)
    UI.scaleGrip:SetAlpha(0.18)
    for i=0,2 do
        local line=UI.scaleGrip:CreateTexture(nil,"ARTWORK")
        line:SetColorTexture(GOLD[1],GOLD[2],GOLD[3],0.95)
        line:SetSize(4+(i*3),1)
        line:SetPoint("BOTTOMRIGHT",-2,-4-(i*4))
        line:SetRotation(math.rad(-45))
    end
    UI.scaleGrip:HookScript("OnEnter",function(self)self:SetAlpha(0.9)end)
    UI.scaleGrip:HookScript("OnLeave",function(self)self:SetAlpha(0.18)end)

    local scaleDragging=false
    local scaleStartX=0
    local scaleStart=1
    UI.scaleGrip:SetScript("OnMouseDown",function(_,button)
        if button~="LeftButton" then return end
        scaleDragging=true
        scaleStartX=select(1,GetCursorPosition())
        scaleStart=GetUIScaleSetting()
    end)
    UI.scaleGrip:SetScript("OnMouseUp",function()scaleDragging=false end)
    UI.scaleGrip:SetScript("OnUpdate",function()
        if not scaleDragging then return end
        local x=select(1,GetCursorPosition())
        local newScale=math.max(0.70,math.min(1.35,scaleStart+((x-scaleStartX)/500)))
        RPAL.GetSettings().uiScale=newScale
        f:SetScale(newScale)
    end)
    UI.scaleGrip:SetScript("OnEnter",function(self)
        if RPAL.ShowSimpleCursorTooltip then
            RPAL.ShowSimpleCursorTooltip("UI Scale","Drag left or right to resize ODYN.",GOLD,"INTERFACE")
        end
    end)
    UI.scaleGrip:SetScript("OnLeave",function()if RPAL.HideCursorTooltip then RPAL.HideCursorTooltip() end end)

    f:SetScale(GetUIScaleSetting())
    if RPAL.AttachBookChrome then RPAL.AttachBookChrome(f,header,UI) end
    UI.ready=true
end


local function HideTargetMiniGameMenu()
    local menu=UI and UI.targetMiniGameMenu
    if menu then UI.targetMiniGameMenu=nil;menu:Hide();menu:SetParent(nil) end
end

local function ToggleTargetMiniGameMenu(button,targetName)
    if UI.targetMiniGameMenu then HideTargetMiniGameMenu();return end
    if UI.HideTargetInspectMenu then UI.HideTargetInspectMenu() end
    if not button or not targetName then return end
    local m=CreateFrame("Frame",nil,UIParent,"BackdropTemplate");UI.targetMiniGameMenu=m;m:SetSize(248,230);m:SetPoint("TOPRIGHT",button,"BOTTOMRIGHT",0,-8);if RPAL.ApplyUILayer then RPAL.ApplyUILayer(m,"POPOVER",20)else m:SetFrameStrata("TOOLTIP");m:SetFrameLevel(12020);m:SetToplevel(true)end;m:EnableMouse(false);Backdrop(m,PANEL2,GOLD);m._odynDropdownOwner=button;if RPAL.SetOpenDropdown then RPAL.SetOpenDropdown(m)end;m:HookScript("OnHide",function(self)if UI.targetMiniGameMenu==self then UI.targetMiniGameMenu=nil end end)
    local h=m:CreateFontString(nil,"OVERLAY");h:SetFont(STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF",11,"OUTLINE");h:SetText("MINI GAMES");h:SetTextColor(GOLD[1],GOLD[2],GOLD[3],1);h:SetPoint("TOPLEFT",12,-11)
    local sub=m:CreateFontString(nil,"OVERLAY");sub:SetFont(STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF",9,"");sub:SetText("Challenge "..H.SocialShortName(targetName).." to play.");sub:SetTextColor(0.72,0.67,0.58,1);sub:SetPoint("TOPLEFT",12,-31)

    local function GameButton(y,title,desc,border,textColor,onClick)
        local b=CreateFrame("Button",nil,m,"BackdropTemplate");b:SetSize(222,48);b:SetPoint("TOPLEFT",13,y);b:EnableMouse(true);b:SetFrameLevel((m:GetFrameLevel() or 0)+10);Backdrop(b,PANEL,border)
        local icon=b:CreateTexture(nil,"ARTWORK");icon:SetTexture((RPAL.ICONS and (RPAL.ICONS.play or RPAL.ICONS.trophy)) or "Interface\\Icons\\INV_Misc_Dice_02");icon:SetSize(28,28);icon:SetPoint("LEFT",9,0);icon:SetVertexColor(textColor[1],textColor[2],textColor[3],1)
        local t=b:CreateFontString(nil,"OVERLAY");t:SetFont(STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF",9.5,"OUTLINE");t:SetText(title);t:SetTextColor(textColor[1],textColor[2],textColor[3],1);t:SetPoint("LEFT",icon,"RIGHT",10,6)
        local d=b:CreateFontString(nil,"OVERLAY");d:SetFont(STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF",8,"");d:SetText(desc);d:SetTextColor(.70,.63,.53,1);d:SetPoint("LEFT",icon,"RIGHT",10,-9)
        b:SetScript("OnEnter",function(self)self:SetBackdropBorderColor(textColor[1],textColor[2],textColor[3],1)end);b:SetScript("OnLeave",function(self)self:SetBackdropBorderColor(border[1],border[2],border[3],1)end)
        b:SetScript("OnClick",function()
            if SocialFullUnitName("target")~=targetName then HideTargetMiniGameMenu();return end
            onClick();HideTargetMiniGameMenu()
        end)
    end

    GameButton(-52,"UNOFFICIAL HEARTHSTONE","1v1 collectible card table",{.82,.54,.22,1},{1,.84,.50,1},function()
        local game=RPAL.Hearthstone;if game and game.ChallengeTarget then local ok,msg=game.ChallengeTarget(false);if not ok and msg then print("|cffff6666ODYN Unofficial Hearthstone:|r "..tostring(msg)) end end
    end)
    GameButton(-106,"CHESS","1v1 strategy board",{.34,.58,.82,1},{.62,.82,1,1},function()
        local game=RPAL.BoardGames;if game and game.ChallengeTarget then local ok,msg=game.ChallengeTarget("chess");if not ok and msg then print("|cffff6666ODYN Chess:|r "..tostring(msg)) end end
    end)
    GameButton(-160,"CHECKERS","1v1 draughts board",{.78,.30,.18,1},{1,.58,.34,1},function()
        local game=RPAL.BoardGames;if game and game.ChallengeTarget then local ok,msg=game.ChallengeTarget("checkers");if not ok and msg then print("|cffff6666ODYN Checkers:|r "..tostring(msg)) end end
    end)
end

function UI.HideTargetInspectMenu()
    local menu=UI and UI.targetInspectMenu
    if menu then UI.targetInspectMenu=nil;menu:Hide();menu:SetParent(nil) end
end

function UI.ToggleTargetInspectMenu(button,targetName)
    if UI.targetInspectMenu then UI.HideTargetInspectMenu();return end
    HideTargetMiniGameMenu()
    if not button or not targetName then return end

    local m=CreateFrame("Frame",nil,UIParent,"BackdropTemplate")
    UI.targetInspectMenu=m
    m:SetSize(236,132)
    m:SetPoint("TOPRIGHT",button,"BOTTOMRIGHT",0,-8)
    m:SetClampedToScreen(true)
    if RPAL.ApplyUILayer then
        RPAL.ApplyUILayer(m,"POPOVER",70)
    else
        m:SetFrameStrata("TOOLTIP");m:SetFrameLevel(12070);m:SetToplevel(true)
    end

    -- Keep the visual shell out of hit testing.  TargetFrame (and some unit
    -- frame replacements) can cause a toplevel popover parent to be raised
    -- after it opens.  If that parent owns a rectangular mouse region, the
    -- shell can then sit above its own child Buttons: the rows stay visible,
    -- but every click lands on the background.  The Player Frame shortcut
    -- menu already avoids this; Target Inspect now uses the same input model.
    m:EnableMouse(false)
    Backdrop(m,PANEL2,GOLD)
    m._odynDropdownOwner=button
    if RPAL.SetOpenDropdown then RPAL.SetOpenDropdown(m) end
    m:HookScript("OnHide",function(self)
        if UI.targetInspectMenu==self then UI.targetInspectMenu=nil end
    end)

    local h=m:CreateFontString(nil,"OVERLAY")
    h:SetFont(STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF",11,"OUTLINE")
    h:SetText("INSPECT")
    h:SetTextColor(GOLD[1],GOLD[2],GOLD[3],1)
    h:SetPoint("TOPLEFT",12,-11)

    local sub=m:CreateFontString(nil,"OVERLAY")
    sub:SetFont(STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF",8.5,"")
    sub:SetText("Choose what you want to inspect.")
    sub:SetTextColor(.72,.67,.58,1)
    sub:SetPoint("TOPLEFT",12,-31)

    -- A low-level catcher blocks click-through over empty parts of the menu and
    -- proxies rows by cursor coordinates if another addon/unit-frame skin
    -- unexpectedly flattens child frame levels.  Choice buttons remain above
    -- it during normal operation.
    local inputCatcher=CreateFrame("Button",nil,m)
    inputCatcher:SetAllPoints(m)
    inputCatcher:EnableMouse(true)
    inputCatcher:RegisterForClicks("LeftButtonUp")
    if m.GetFrameLevel and inputCatcher.SetFrameLevel then
        pcall(inputCatcher.SetFrameLevel,inputCatcher,(m:GetFrameLevel() or 1)+1)
    end

    local choices={}
    local hoveredChoice

    local function CursorInside(frame)
        if not frame or not frame.IsShown or not frame:IsShown() then return false end
        local left,right,top,bottom=frame:GetLeft(),frame:GetRight(),frame:GetTop(),frame:GetBottom()
        if not left or not right or not top or not bottom then return false end
        local scale=(frame.GetEffectiveScale and frame:GetEffectiveScale()) or (UIParent:GetEffectiveScale() or 1)
        if not scale or scale<=0 then scale=1 end
        local x,y=GetCursorPosition()
        if not x or not y then return false end
        x,y=x/scale,y/scale
        return x>=left and x<=right and y>=bottom and y<=top
    end

    local function SetChoiceHovered(choice,hovered)
        if not choice or choice.hovered==hovered then return end
        choice.hovered=hovered and true or false
        local b,t,d=choice.button,choice.title,choice.description
        if hovered then
            b:SetBackdropColor(BUTTON_HOVER[1],BUTTON_HOVER[2],BUTTON_HOVER[3],BUTTON_HOVER[4] or 1)
            b:SetBackdropBorderColor(1,.72,.30,1)
            t:SetTextColor(1,.91,.66,1)
            d:SetTextColor(.82,.74,.62,1)
        else
            b:SetBackdropColor(PANEL[1],PANEL[2],PANEL[3],PANEL[4] or 1)
            b:SetBackdropBorderColor(unpack(BORDER))
            t:SetTextColor(1,.84,.50,1)
            d:SetTextColor(.70,.63,.53,1)
        end
    end

    local function ChoiceUnderCursor()
        for index=#choices,1,-1 do
            local choice=choices[index]
            if CursorInside(choice.button) then return choice end
        end
    end

    local function ActivateChoice(choice)
        if not choice or m._odynActionDispatched or not m:IsShown() then return end
        m._odynActionDispatched=true
        if SocialFullUnitName("target")~=targetName then
            UI.HideTargetInspectMenu()
            return
        end
        local mode=choice.mode
        UI.HideTargetInspectMenu()
        RPAL.RequestSocialInspect(targetName,mode)
    end

    local function Choice(text,y,mode,desc,index)
        local b=CreateFrame("Button",nil,m,"BackdropTemplate")
        b:SetSize(210,34)
        b:SetPoint("TOPLEFT",12,y)
        if b.SetFrameLevel and m.GetFrameLevel then
            pcall(b.SetFrameLevel,b,(m:GetFrameLevel() or 1)+10+(index or 0))
        end
        b:EnableMouse(true)
        b:RegisterForClicks("LeftButtonUp")
        b:SetHitRectInsets(-2,-2,-1,-1)
        Backdrop(b,PANEL,BORDER)

        local t=b:CreateFontString(nil,"OVERLAY")
        t:SetFont(STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF",9.5,"OUTLINE")
        t:SetText(text)
        t:SetPoint("LEFT",9,5)
        t:SetTextColor(1,.84,.50,1)

        local d=b:CreateFontString(nil,"OVERLAY")
        d:SetFont(STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF",7.2,"")
        d:SetText(desc)
        d:SetPoint("LEFT",9,-8)
        d:SetTextColor(.70,.63,.53,1)

        local choice={button=b,title=t,description=d,mode=mode}
        choices[#choices+1]=choice
        b:SetScript("OnEnter",function()SetChoiceHovered(choice,true)end)
        b:SetScript("OnLeave",function()SetChoiceHovered(choice,false)end)
        b:SetScript("OnMouseDown",function(_,mouseButton)
            if mouseButton=="LeftButton" then choice.pressed=true end
        end)
        b:SetScript("OnMouseUp",function(_,mouseButton)
            if mouseButton~="LeftButton" then return end
            local wasPressed=choice.pressed
            choice.pressed=false
            if wasPressed and CursorInside(b) then ActivateChoice(choice) end
        end)
        b:SetScript("OnClick",function(_,mouseButton)
            if mouseButton and mouseButton~="LeftButton" then return end
            ActivateChoice(choice)
        end)
    end

    Choice("INSPECT PROFILE",-49,"profile","Profile, statistics, achievements and skills",1)
    Choice("INSPECT HEALTH",-87,"health","Body map, active records and medical history",2)

    inputCatcher:SetScript("OnUpdate",function()
        local choice=ChoiceUnderCursor()
        if choice~=hoveredChoice then
            SetChoiceHovered(hoveredChoice,false)
            hoveredChoice=choice
            SetChoiceHovered(hoveredChoice,true)
        end
    end)
    inputCatcher:SetScript("OnMouseDown",function(_,mouseButton)
        if mouseButton=="LeftButton" then inputCatcher._pressedChoice=ChoiceUnderCursor() end
    end)
    inputCatcher:SetScript("OnMouseUp",function(_,mouseButton)
        if mouseButton~="LeftButton" then return end
        local pressed=inputCatcher._pressedChoice
        inputCatcher._pressedChoice=nil
        local released=ChoiceUnderCursor()
        if pressed and pressed==released then ActivateChoice(released) end
    end)
    inputCatcher:SetScript("OnClick",function(_,mouseButton)
        if mouseButton and mouseButton~="LeftButton" then return end
        ActivateChoice(ChoiceUnderCursor())
    end)

    if RPAL.AnimateFrameIn then RPAL.AnimateFrameIn(m,0.08,0) end
end

local function BuildTargetBadge()
    if UI.targetBadge and UI.targetCombatButton and UI.targetPlayButton then return end
    if not TargetFrame then return end

    if not UI.targetBadge then
        local b=CreateFrame("Button","RPAL_TargetBadge",TargetFrame,"BackdropTemplate")
        UI.targetBadge=b
        b:SetSize(32,32)
        b:SetPoint("TOPRIGHT",TargetFrame,"TOPRIGHT",22,-18)
        b:SetFrameStrata("DIALOG")
        b:SetFrameLevel((TargetFrame:GetFrameLevel() or 1)+20)
        Backdrop(b,PANEL2,GOLD)
        b:Hide()

        local icon=b:CreateTexture(nil,"ARTWORK")
        icon:SetTexture(RPAL.ICONS.inspect)
        icon:SetPoint("TOPLEFT",5,-5)
        icon:SetPoint("BOTTOMRIGHT",-5,5)
        icon:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],1)
        b.icon=icon

        b:SetScript("OnEnter",function(self)
            local inCombat = RPAL.Combat and RPAL.Combat.IsInFight and RPAL.Combat.IsInFight(SocialFullUnitName("target"))
            local hc = inCombat and {1,0.18,0.18,1} or {1,0.82,0.35,1}
            self:SetBackdropBorderColor(unpack(hc))
            if self.icon then self.icon:SetVertexColor(hc[1],hc[2],hc[3],1) end
            if RPAL.ShowCursorTooltip then RPAL.ShowCursorTooltip({
                eyebrow=inCombat and "IN COMBAT" or "ODYN USER DETECTED",title=inCombat and "Combat Encounter" or "Inspect ODYN",
                body=inCombat and "This player is currently in an ODYN combat encounter." or "Choose between their public Profile and Health record.",
                sectionTitle="LEFT CLICK",section="Open Inspect Profile / Inspect Health selection.",
                accent=GOLD,width=370,
            }) end
        end)
        b:SetScript("OnLeave",function(self)
            self:SetBackdropBorderColor(unpack(GOLD))
            if self.icon then self.icon:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],1) end
            if RPAL.HideCursorTooltip then RPAL.HideCursorTooltip() end
        end)

        b:SetScript("OnClick",function()
            local targetName=SocialFullUnitName("target")
            if targetName then UI.ToggleTargetInspectMenu(b,targetName) end
        end)
    end

    if not UI.targetCombatButton then
        local c=CreateFrame("Button","RPAL_TargetCombatButton",TargetFrame,"BackdropTemplate")
        UI.targetCombatButton=c
        c:SetSize(32,32)
        c:SetPoint("TOPRIGHT",TargetFrame,"TOPRIGHT",58,-18)
        c:SetFrameStrata("DIALOG")
        c:SetFrameLevel((TargetFrame:GetFrameLevel() or 1)+20)
        Backdrop(c,PANEL2,{0.85,0.22,0.18,1})
        c:Hide()

        local icon=c:CreateTexture(nil,"ARTWORK")
        icon:SetTexture(RPAL.ICONS.skull or RPAL.ICONS.warning)
        icon:SetPoint("TOPLEFT",5,-5)
        icon:SetPoint("BOTTOMRIGHT",-5,5)
        icon:SetVertexColor(1,0.32,0.24,1)
        c.icon=icon

        c:SetScript("OnEnter",function(self)
            local targetName=SocialFullUnitName("target")
            local selfInCombat=RPAL.Combat and RPAL.Combat.IsInFight and RPAL.Combat.IsInFight()
            local inCombat=RPAL.Combat and RPAL.Combat.IsInFight and RPAL.Combat.IsInFight(targetName)
            local hc=(inCombat and not selfInCombat) and {0.75,0.3,0.3,1} or {1,0.38,0.25,1}
            self:SetBackdropBorderColor(unpack(hc))
            if self.icon then self.icon:SetVertexColor(hc[1],hc[2],hc[3],1) end
            if RPAL.ShowCursorTooltip then RPAL.ShowCursorTooltip({
                eyebrow=inCombat and "COMBAT ACTIVE" or (selfInCombat and "COMBAT" or "COMBAT"),
                title=selfInCombat and (inCombat and "Already In Combat" or "Request Join") or (inCombat and "Already In Combat" or "Start Combat"),
                body=selfInCombat and (inCombat and "This player is already participating in your current combat." or "Ask this player to join your current combat. All current combatants will vote, and the invited player must accept.") or (inCombat and "This player is already participating in an ODYN combat encounter." or "Challenge this player to an ODYN combat encounter."),
                sectionTitle="LEFT CLICK",
                section=selfInCombat and (inCombat and "You cannot add a player who is already in the combat." or "Send a join request to your combat host and start the group vote.") or (inCombat and "You cannot start another encounter with this player." or "Send a combat challenge to your current target."),
                accent=hc,width=370,
            }) end
        end)
        c:SetScript("OnLeave",function(self)
            self:SetBackdropBorderColor(0.85,0.22,0.18,1)
            if self.icon then self.icon:SetVertexColor(1,0.32,0.24,1) end
            if RPAL.HideCursorTooltip then RPAL.HideCursorTooltip() end
        end)
        c:SetScript("OnClick",function()
            local targetName=SocialFullUnitName("target")
            if not targetName then return end
            if RPAL.Combat and RPAL.Combat.StartRequest then
                local ok,err=RPAL.Combat.StartRequest(targetName)
                if not ok and err then print("|cffff6666ODYN Combat:|r "..tostring(err)) end
            end
        end)
    end

    if not UI.targetPlayButton then
        local p=CreateFrame("Button","RPAL_TargetPlayButton",TargetFrame,"BackdropTemplate");UI.targetPlayButton=p
        p:SetSize(32,32);p:SetPoint("TOPRIGHT",TargetFrame,"TOPRIGHT",94,-18);p:SetFrameStrata("DIALOG");p:SetFrameLevel((TargetFrame:GetFrameLevel() or 1)+20);Backdrop(p,PANEL2,{0.35,0.70,0.92,1});p:Hide()
        local icon=p:CreateTexture(nil,"ARTWORK");icon:SetTexture((RPAL.ICONS and (RPAL.ICONS.play or RPAL.ICONS.trophy)) or "Interface\\Icons\\INV_Misc_Coin_01");icon:SetPoint("TOPLEFT",5,-5);icon:SetPoint("BOTTOMRIGHT",-5,5);icon:SetVertexColor(.50,.86,1,1);p.icon=icon
        p:SetScript("OnEnter",function(self)self:SetBackdropBorderColor(.55,.90,1,1);if RPAL.ShowCursorTooltip then RPAL.ShowCursorTooltip({eyebrow="ODYN MINI GAMES",title="Play",body="Open the mini-game challenge menu for this ODYN user.",sectionTitle="LEFT CLICK",section="Choose a game and send a request to your current target.",accent={.45,.82,1,1},width=350}) end end)
        p:SetScript("OnLeave",function(self)self:SetBackdropBorderColor(.35,.70,.92,1);if RPAL.HideCursorTooltip then RPAL.HideCursorTooltip() end end)
        p:SetScript("OnClick",function(self)local targetName=SocialFullUnitName("target");if targetName then ToggleTargetMiniGameMenu(self,targetName) end end)
    end

end

function RPAL.UpdateTargetBadge()
    BuildTargetBadge()
    if not UI.targetBadge then return end

    local targetCombat = RPAL.Combat and RPAL.Combat.IsInFight and RPAL.Combat.IsInFight(SocialFullUnitName("target"))
    local badgeColor = targetCombat and {1,0.12,0.12,1} or GOLD
    UI.targetBadge:SetBackdropBorderColor(unpack(badgeColor))
    if UI.targetBadge.icon then UI.targetBadge.icon:SetVertexColor(badgeColor[1],badgeColor[2],badgeColor[3],1) end

    if not UnitExists("target") or not UnitIsPlayer("target") or UnitIsUnit("target","player") then
        UI.targetBadge:Hide();if UI.targetCombatButton then UI.targetCombatButton:Hide() end;if UI.targetPlayButton then UI.targetPlayButton:Hide() end;HideTargetMiniGameMenu();UI.HideTargetInspectMenu()
        return
    end

    local targetName=SocialFullUnitName("target")
    if targetName and H.SocialIsPresent(targetName) then
        UI.targetBadge:Show()
        if UI.targetCombatButton then
            local selfInCombat=RPAL.Combat and RPAL.Combat.IsInFight and RPAL.Combat.IsInFight()
            local inCombat=RPAL.Combat and RPAL.Combat.IsInFight and RPAL.Combat.IsInFight(targetName)
            UI.targetCombatButton:SetAlpha((inCombat and not selfInCombat) and 0.55 or 1)
            UI.targetCombatButton:Show()
        end
        if UI.targetPlayButton then UI.targetPlayButton:Show() end
    else
        UI.targetBadge:Hide()
        if UI.targetCombatButton then UI.targetCombatButton:Hide() end
        if UI.targetPlayButton then UI.targetPlayButton:Hide() end
        HideTargetMiniGameMenu();UI.HideTargetInspectMenu()
        PingTarget()
    end
end

local function RenderPreview()
    local p=RPAL.char.profile
    local host=UI.preview
    ClearChildren(host)
    host:Show()

    local portraitHalo=Keep(host,host:CreateTexture(nil,"BACKGROUND"))
    portraitHalo:SetTexture(TEX.."Cosmetics\\aura_soft_ring.tga")
    portraitHalo:SetSize(126,126)
    portraitHalo:SetPoint("LEFT",-1,0)
    portraitHalo:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],0.18)
    portraitHalo:SetBlendMode("ADD")
    portraitHalo._rpalDecorRole="ACCENT"

    local portrait=Keep(host,host:CreateTexture(nil,"ARTWORK"))
    portrait:SetSize(108,108)
    portrait:SetPoint("LEFT",8,0)
    SetPortrait(portrait)
    MaskCircle(portrait)

    local previewButton=Keep(host,CreateFrame("Button",nil,host,"BackdropTemplate"))
    previewButton:SetSize(24,24)
    previewButton:SetPoint("TOP",portrait,"BOTTOM",0,-5)
    previewButton:RegisterForClicks("LeftButtonUp")
    Backdrop(previewButton,{0.035,0.038,0.045,0.96},GOLD)
    AddFrameShadow(previewButton,3,0.34)

    local previewIcon=Keep(host,previewButton:CreateTexture(nil,"ARTWORK"))
    previewIcon:SetTexture(NATIVE_MAGNIFY_ICON)
    previewIcon:SetPoint("TOPLEFT",3,-3)
    previewIcon:SetPoint("BOTTOMRIGHT",-3,3)
    previewIcon:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],0.94)
    previewIcon._rpalDecorRole="ACCENT"

    previewButton:SetScript("OnEnter",function(self)
        if RPAL.ShowSimpleCursorTooltip then
            RPAL.ShowSimpleCursorTooltip("Preview Public Profile","See the profile, cosmetics and public information other ODYN users receive when inspecting you.",GOLD,"PUBLIC PROFILE")
        end
    end)
    previewButton:SetScript("OnLeave",function()if RPAL.HideCursorTooltip then RPAL.HideCursorTooltip() end end)
    previewButton:SetScript("OnClick",function()
        local ok,err=pcall(RPAL.OpenSelfPublicPreview,false)
        if not ok then
            print("|cffff6666ODYN public preview error:|r "..tostring(err))
        end
    end)

    local cosmetics=RPAL.GetCosmeticSelection and RPAL.GetCosmeticSelection() or {}
    local liteKit=RPAL.IsLiteKit and RPAL.IsLiteKit()

    local aura=RenderProfileAura(host,portrait,cosmetics.profileAura or "None",GOLD,144)
    if aura then Keep(host,aura) end

    local portraitFrame=RenderPortraitCosmetic(host,portrait,cosmetics.portraitFrame or "Default",GOLD,132)
    if portraitFrame then Keep(host,portraitFrame) end

    -- The equipped header is a diagonal pair matching the identity panel's
    -- upper-right and lower-left corners.
    local decorationParent=UI.header or host
    local decoration=RenderHeaderCosmetic(
        decorationParent,cosmetics.headerDecoration or "None",GOLD,
        500,154,
        "masthead"
    )
    if decoration then
        decoration:ClearAllPoints()
        decoration:SetPoint("TOPLEFT",decorationParent,"TOPLEFT",4,-4)
        if decorationParent==host then Keep(host,decoration) end
    end

    local currentLevel=RPAL.GetCurrentRenownLevel and select(1,RPAL.GetCurrentRenownLevel()) or 1
    local lr,lg,lb=GetRenownLevelColor(currentLevel)

    -- Progression medallion occupies the dedicated identity-panel socket
    -- between Glances and Edit. Its color belongs to its earned level tier.
    local badgeAnchor=Keep(host,CreateFrame("Frame",nil,decorationParent))
    badgeAnchor:SetSize(90,90)
    badgeAnchor:SetPoint("CENTER",decorationParent,"TOPRIGHT",-226,-92)
    -- The TRP Glance strip lives at header + 6.  Keep the medallion one level
    -- behind it so an overlapping Badge never paints over a Glance socket.
    if not liteKit then
        local levelBadge=RenderLevelBadge(decorationParent,badgeAnchor,cosmetics.levelBadge or "Initiate Seal",86,currentLevel,0.31,5)
        if levelBadge then Keep(host,levelBadge) end
    end


    local info=Keep(host,CreateFrame("Frame",nil,host))
    info:SetPoint("TOPLEFT",portrait,"TOPRIGHT",20,-4)
    info:SetPoint("BOTTOMRIGHT",-8,4)

    local y=-2

    local levelPixelWidth=0
    if not liteKit then
        local levelTextOnly=Keep(host,Font(info,"Lv. "..tostring(currentLevel),12,{lr,lg,lb},DEFAULT_FONT))
        levelTextOnly._rpalColorRole=nil
        levelTextOnly:SetTextColor(lr,lg,lb,1)
        levelTextOnly:SetPoint("TOPLEFT",0,y)
        levelTextOnly:SetWordWrap(false)
        -- Deterministic width for "Lv. " plus the level digits.
        local levelDigits=#tostring(currentLevel)
        levelPixelWidth=19+(levelDigits*7)
    end

    local name=Keep(host,Heading(info,RPAL.DisplayName(),20))

    -- IMPORTANT:
    -- Do not give this FontString a fixed width without LEFT justification.
    -- The previous 430px region caused WoW to center the visible name inside
    -- that invisible region, which looked like a huge gap after "Lv. N".
    --
    -- The right edge is fitted to the Glance strip below.  This keeps the
    -- complete name + pronouns on one line without letting it run underneath
    -- the five TRP sockets.
    local identityNameOffset=liteKit and 0 or (levelPixelWidth+11)
    name:SetPoint("TOPLEFT",identityNameOffset,y)
    name:SetWidth(300)
    name:SetJustifyH("LEFT")
    name:SetWordWrap(false)

    local hasQuickImport=RPAL.HasImportableTRP3Profile and RPAL.HasImportableTRP3Profile()
    if hasQuickImport then
        local quickImport=Keep(host,Button(info,"IMPORT TRP3",102,24,true))
        quickImport:SetPoint("TOPRIGHT",-2,4)
        quickImport:SetScript("OnClick",function()
            if RPAL.ImportTRP3Quick then
                local ok,err=RPAL.ImportTRP3Quick()
                if not ok and err then print("|cffff6666ODYN:|r "..tostring(err)) end
            end
        end)
        quickImport:SetScript("OnEnter",function()if RPAL.ShowSimpleCursorTooltip then RPAL.ShowSimpleCursorTooltip("Import from TRP3","Import this character's available Total RP 3 information. This first-import shortcut disappears afterwards; the normal import tool remains available.",GOLD,"CHARACTER PROFILE")end end)
        quickImport:SetScript("OnLeave",function()if RPAL.HideCursorTooltip then RPAL.HideCursorTooltip()end end)
    end

    local function FitIdentityName()
        if not name or not name:IsShown() then return end
        local startOffset=identityNameOffset
        local maxWidth=hasQuickImport and math.max(110,(info:GetWidth() or 430)-startOffset-112) or math.max(110,(info:GetWidth() or 430)-startOffset-6)
        local glanceHolder=RPAL._deskUI and RPAL._deskUI.glanceHolder
        if glanceHolder and glanceHolder:IsShown() and info.GetLeft and glanceHolder.GetLeft then
            local infoLeft=info:GetLeft()
            local glanceLeft=glanceHolder:GetLeft()
            if infoLeft and glanceLeft then
                maxWidth=math.max(90,glanceLeft-(infoLeft+startOffset)-8)
            end
        end
        RPAL.FitFontStringToWidth(name,maxWidth,20,6)
    end
    FitIdentityName()
    if C_Timer and C_Timer.After then C_Timer.After(0.01,FitIdentityName) end

    y=y-25

    if p.title and p.title~="" then
        local titleLine=Keep(host,Font(info,p.title,11,GOLD,DEFAULT_FONT))
        titleLine:SetPoint("TOPLEFT",0,y);y=y-18
    end

    local raceText=RPAL.VisibleRace() or ""
    local classText=p.className or ""
    local used=false

    if raceText~="" then
        local raceFS=Keep(host,Font(info,raceText:upper(),11,PALE,DEFAULT_FONT))
        raceFS:SetPoint("TOPLEFT",0,y)
        used=true
        if classText~="" then
            local cc=p.classColor or {r=PALE[1],g=PALE[2],b=PALE[3]}
            local classFS=Keep(host,Font(info,classText:upper(),11,{cc.r,cc.g,cc.b},DEFAULT_FONT))
            classFS:SetPoint("LEFT",raceFS,"RIGHT",6,0)
        end
    elseif classText~="" then
        local cc=p.classColor or {r=PALE[1],g=PALE[2],b=PALE[3]}
        local classFS=Keep(host,Font(info,classText:upper(),11,{cc.r,cc.g,cc.b},DEFAULT_FONT))
        classFS:SetPoint("TOPLEFT",0,y)
        used=true
    end
    if used then y=y-19 end

    local guild=RPAL.GuildDisplay()
    if guild~="" then
        local guildLine=Keep(host,Font(info,guild,10,{0.75,0.63,0.38},DEFAULT_FONT))
        guildLine:SetPoint("TOPLEFT",0,y)
        y=y-18
    end

    local hungry,dehydrated=false,false
    if RPAL.GetSurvivalFlags then
        hungry,dehydrated=RPAL.GetSurvivalFlags()
    end

    local tags={}
    if p.worgen then
        tags[#tags+1]={text="WORGEN CURSE AFFLICTED",color=WORG,width=188,bg={0.28,0.08,0.12,0.95},border={0.67,0.34,0.40,1}}
    end
    if hungry then
        tags[#tags+1]={text="HUNGRY",color=HUNGER,width=82,bg={0.25,0.12,0.035,0.95},border={0.68,0.38,0.12,1}}
    end
    if dehydrated then
        tags[#tags+1]={text="DEHYDRATED",color=THIRST,width=112,bg={0.035,0.11,0.20,0.95},border={0.17,0.45,0.70,1}}
    end

    -- Persona Beta Living State belongs with the character's active status
    -- chips instead of living only inside the Persona observatory. It remains
    -- local/private; public inspect snapshots do not expose this internal read.
    local personaReady=(not RPAL.IsPersonaSystemEnabled or RPAL.IsPersonaSystemEnabled()) and (not RPAL.IsPersonalityCalibrated or RPAL.IsPersonalityCalibrated())
    if personaReady and RPAL.GetPersonaLivingState then
        local ok,living=pcall(RPAL.GetPersonaLivingState)
        local label=ok and living and tostring(living.label or""):upper()or""
        if label~="" then
            local colors={
                CALM={0.45,0.84,0.76},RESTLESS={0.98,0.67,0.27},GUARDED={0.55,0.72,0.90},
                WARM={0.96,0.63,0.52},IRRITATED={1.00,0.38,0.28},REFLECTIVE={0.71,0.57,0.95},
                FOCUSED={0.35,0.84,0.95},WEARY={0.64,0.66,0.72},UNCOMFORTABLE={0.96,0.46,0.42},
                HURT={0.86,0.46,0.66},DISTRAUGHT={0.78,0.42,0.68},SELF_CONSCIOUS={0.82,0.58,0.76},
                WITHDRAWN={0.55,0.58,0.72},RELIEVED={0.43,0.82,0.69},TENSE={0.94,0.55,0.35},CONCERNED={0.46,0.78,0.88},
            }
            local c=colors[label]or{0.58,0.78,0.86}
            local widths={CALM=72,RESTLESS=96,GUARDED=92,WARM=72,IRRITATED=98,REFLECTIVE=102,FOCUSED=88,WEARY=76,UNCOMFORTABLE=126,HURT=70,DISTRAUGHT=108,SELF_CONSCIOUS=132,WITHDRAWN=108,RELIEVED=88,TENSE=72,CONCERNED=104}
            tags[#tags+1]={
                text=label,color=c,width=widths[label] or 96,
                bg={c[1]*0.16,c[2]*0.16,c[3]*0.16,0.95},border={c[1]*0.70,c[2]*0.70,c[3]*0.70,1},
                tooltipTitle="Persona Beta — "..label,
                tooltipBody=tostring(living.detail or"Current internal Living State."),
                tooltipEyebrow="LIVING STATE",
            }
        end
    end

    local renownBoost=RPAL.GetSessionRenownBoostInfo and RPAL.GetSessionRenownBoostInfo()
    if renownBoost and (tonumber(renownBoost.percent) or 0)>0 then
        local lines={string.format("+%g%% Renown from eligible rewards during active Sessions.",tonumber(renownBoost.percent) or 0)}
        if #(renownBoost.sources or {})>1 then
            lines[1]=string.format("+%g%% total Renown while the active boosts overlap.",tonumber(renownBoost.percent) or 0)
        end
        for _,entry in ipairs(renownBoost.sources or {}) do
            local remaining=math.max(0,math.floor(tonumber(entry.sessionsRemaining) or 0))
            lines[#lines+1]=string.format("%s: +%g%%  •  %d Session%s left",tostring(entry.label or "Renown Boost"),tonumber(entry.percent) or 0,remaining,remaining==1 and "" or "s")
        end
        tags[#tags+1]={
            text="RENOWN BOOST",color=GOLD,width=121,
            bg={0.19,0.13,0.035,0.96},border={0.74,0.55,0.18,1},
            tooltipTitle="Renown Boost",tooltipBody=table.concat(lines,"\n"),tooltipEyebrow="SESSION BONUS",
        }
    end

    -- Status chips wrap instead of escaping the identity panel when several
    -- conditions are active at once (for example Worgen + Survival + Boost).
    local tagX,tagY=0,y
    local tagLaneWidth=math.max(150,tonumber(info:GetWidth()) or 430)
    for _,tag in ipairs(tags) do
        if tagX>0 and (tagX+tag.width)>tagLaneWidth then tagX=0;tagY=tagY-27 end
        local buff=Keep(host,CreateFrame("Frame",nil,info,"BackdropTemplate"))
        buff:SetSize(tag.width,23)
        buff:SetPoint("TOPLEFT",tagX,tagY)
        Backdrop(buff,tag.bg,tag.border)

        local dot=Keep(host,buff:CreateTexture(nil,"ARTWORK"))
        dot:SetColorTexture(tag.color[1],tag.color[2],tag.color[3],1)
        dot:SetSize(8,8)
        dot:SetPoint("LEFT",8,0)

        local bt=Keep(host,Font(buff,tag.text,9,tag.color,DEFAULT_FONT))
        bt:SetPoint("LEFT",dot,"RIGHT",6,0)
        if tag.tooltipTitle then
            buff:EnableMouse(true)
            buff:SetScript("OnEnter",function()
                if RPAL.ShowSimpleCursorTooltip then
                    RPAL.ShowSimpleCursorTooltip(tag.tooltipTitle,tag.tooltipBody or "",tag.color or GOLD,tag.tooltipEyebrow or "ODYN")
                end
            end)
            buff:SetScript("OnLeave",function()if RPAL.HideCursorTooltip then RPAL.HideCursorTooltip() end end)
        end

        tagX=tagX+tag.width+7
    end
end
RPAL.RefreshCharacterPreview=RenderPreview
RPAL.OnSessionRenownBoostChanged=function()
    if UI.ready and UI.main and UI.main:IsShown() and UI.preview then RenderPreview() end
end

local function EditorField(parent,label,x,y,w,getter,setter)
    MakeLabel(parent,label,x,y)
    local e=Keep(parent,EditBox(parent,w or 150,30)); e:SetPoint("TOPLEFT",x,y-14)
    H.BindEdit(e,getter,setter)
    return e
end



local function StripTRPColors(value)
    if value == nil then return nil end
    value=tostring(value)
    value=value:gsub("|c%x%x%x%x%x%x%x%x","")
    value=value:gsub("|r","")
    return value
end

local function FirstNonEmpty(...)
    for i=1,select("#",...) do
        local v=select(i,...)
        if v ~= nil and tostring(v) ~= "" then return v end
    end
end

local function SafeCall(fn,...)
    if type(fn)~="function" then return nil end
    local ok,result=pcall(fn,...)
    if ok then return result end
end

local function NormalizeFieldLabel(label)
    return tostring(label or ""):lower():gsub("[^%a]","")
end

local function FindAdditionalField(container, wantedLabels)
    if type(container)~="table" then return nil end

    local wanted={}
    for _,label in ipairs(wantedLabels) do
        wanted[NormalizeFieldLabel(label)]=true
    end

    local seen={}

    local function EntryValue(entry)
        if type(entry)~="table" then return nil end
        return FirstNonEmpty(
            entry.VA,entry.value,entry.Value,
            entry.TX,entry.text,entry.Text,
            entry.DE,entry.description,
            entry.content,entry.Content
        )
    end

    local function EntryMatches(entry,index)
        if type(entry)~="table" then return false end

        local identifiers={
            entry.NA,entry.name,entry.Name,
            entry.label,entry.Label,
            entry.TI,entry.title,entry.Title,
            entry.ID,entry.id,entry.Id,
            entry.key,entry.Key,
            entry.code,entry.Code,
            index,
        }

        for _,identifier in ipairs(identifiers) do
            if identifier~=nil then
                local normalized=NormalizeFieldLabel(identifier)
                if wanted[normalized] then
                    return true
                end
            end
        end

        return false
    end

    local function Scan(tbl,depth)
        if type(tbl)~="table" or seen[tbl] or depth>7 then return nil end
        seen[tbl]=true

        for index,entry in pairs(tbl) do
            if type(entry)=="table" then
                if EntryMatches(entry,index) then
                    local value=EntryValue(entry)
                    if value~=nil and tostring(value)~="" then
                        return StripTRPColors(tostring(value))
                    end
                end
            end
        end

        for _,entry in pairs(tbl) do
            if type(entry)=="table" then
                local found=Scan(entry,depth+1)
                if found then return found end
            end
        end
    end

    return Scan(container,0)
end


local function FindTRPCustomGuildField(profile,kind)
    if type(profile)~="table" then return nil end

    local isName=(kind=="name")
    local wanted=isName and {
        guildname=true,
        customguildname=true,
        guild=true,
        trpguildname=true,
    } or {
        guildrank=true,
        customguildrank=true,
        rank=true,
        trpguildrank=true,
    }

    local seen={}

    local function GetValue(entry)
        if type(entry)~="table" then return nil end
        return FirstNonEmpty(
            entry.VA,entry.value,entry.Value,
            entry.TX,entry.text,entry.Text,
            entry.DE,entry.description,
            entry.content,entry.Content
        )
    end

    local function Matches(entry,index)
        if type(entry)~="table" then return false end
        local values={
            entry.ID,entry.id,entry.Id,
            entry.key,entry.Key,
            entry.code,entry.Code,
            entry.NA,entry.name,entry.Name,
            entry.label,entry.Label,
            entry.TI,entry.title,entry.Title,
            index,
        }

        for _,candidate in ipairs(values) do
            local normalized=NormalizeFieldLabel(candidate)
            if wanted[normalized] then return true end

            -- Catch preset-style IDs like "TRP3_GUILD_NAME",
            -- "misc-guild-rank", etc.
            if isName and normalized:find("guildname",1,true) then return true end
            if (not isName) and normalized:find("guildrank",1,true) then return true end
        end
        return false
    end

    local function Scan(tbl,depth)
        if type(tbl)~="table" or seen[tbl] or depth>8 then return nil end
        seen[tbl]=true

        for index,entry in pairs(tbl) do
            if type(entry)=="table" and Matches(entry,index) then
                local value=GetValue(entry)
                if value~=nil and tostring(value)~="" then
                    return StripTRPColors(tostring(value))
                end
            end
        end

        for _,entry in pairs(tbl) do
            if type(entry)=="table" then
                local found=Scan(entry,depth+1)
                if found then return found end
            end
        end
    end

    return Scan(profile,0)
end

local function CollectAdditionalFieldsFromTable(container, output, seen)
    if type(container)~="table" then return end

    local visited={}

    local function TryEntry(entry,index)
        if type(entry)~="table" then return end

        local label=FirstNonEmpty(
            entry.NA,entry.name,entry.Name,
            entry.label,entry.Label,
            entry.TI,entry.title,entry.Title
        )
        local value=FirstNonEmpty(
            entry.VA,entry.value,entry.Value,
            entry.TX,entry.text,entry.Text,
            entry.DE,entry.description,entry.Description,
            entry.content,entry.Content
        )

        if label==nil or value==nil or type(value)=="table" then return end

        label=StripTRPColors(tostring(label))
        value=StripTRPColors(tostring(value))

        if label=="" or value=="" then return end

        local normalized=NormalizeFieldLabel(label)
        if normalized=="" or seen[normalized] then return end

        seen[normalized]=true
        output[#output+1]={
            id=tostring(entry.ID or entry.id or entry.Id or entry.key or index or normalized),
            label=label,
            value=value,
        }
    end

    local function Scan(tbl,depth)
        if type(tbl)~="table" or visited[tbl] or depth>9 then return end
        visited[tbl]=true

        -- Preserve numeric-array ordering where TRP exposes it.
        for index,entry in ipairs(tbl) do
            TryEntry(entry,index)
        end

        -- Catch dictionary-style fields too.
        for index,entry in pairs(tbl) do
            if type(index)~="number" then
                TryEntry(entry,index)
            end
        end

        -- TRP versions/addons can nest custom fields under different paths.
        -- Recursive scanning makes the collector layout-agnostic.
        for _,entry in pairs(tbl) do
            if type(entry)=="table" then
                Scan(entry,depth+1)
            end
        end
    end

    Scan(container,0)
end

local function CollectTRPAdditionalFields(profile,containers,glances)
    local output,seen={},{}
    for _,container in ipairs(containers or {}) do
        CollectAdditionalFieldsFromTable(container,output,seen)
    end
    CollectAdditionalFieldsFromTable(profile,output,seen)

    -- TRP stores At First Glance entries as TI/TX records, the same generic
    -- shape used by Additional Information. The recursive collector therefore
    -- used to import each Glance twice: once into the five icon sockets and
    -- again as Character Details text. Remove only exact title/body matches so
    -- genuine Additional Information remains untouched.
    local glanceKeys={}
    for _,glance in pairs(type(glances)=="table" and glances or {}) do
        if type(glance)=="table" then
            local title=NormalizeFieldLabel(StripTRPColors(tostring(glance.title or "")))
            local body=StripTRPColors(tostring(glance.description or "")):gsub("%s+"," "):gsub("^%s+",""):gsub("%s+$",""):lower()
            if title~="" and body~="" then glanceKeys[title.."\031"..body]=true end
        end
    end
    if next(glanceKeys) then
        local filtered={}
        for _,field in ipairs(output) do
            local title=NormalizeFieldLabel(StripTRPColors(tostring(field.label or "")))
            local body=StripTRPColors(tostring(field.value or "")):gsub("%s+"," "):gsub("^%s+",""):gsub("%s+$",""):lower()
            if not glanceKeys[title.."\031"..body] then filtered[#filtered+1]=field end
        end
        output=filtered
    end
    return output
end

local function ParseTRPEyeColor(raw)
    if type(raw)~="string" then return nil,nil end

    local hex=raw:match("|cff(%x%x%x%x%x%x)")
        or raw:match("#(%x%x%x%x%x%x)")
        or raw:match("^%s*(%x%x%x%x%x%x)%s*$")

    if not hex then return nil,nil end

    local r=tonumber(hex:sub(1,2),16)
    local g=tonumber(hex:sub(3,4),16)
    local b=tonumber(hex:sub(5,6),16)
    if not r or not g or not b then return nil,nil end

    return {r=r/255,g=g/255,b=b/255},"#"..hex:upper()
end

local function ParseTRPTextColor(raw)
    if type(raw)~="string" then return nil end
    local hex=raw:match("|cff(%x%x%x%x%x%x)")
        or raw:match("#(%x%x%x%x%x%x)")
    if not hex then return nil end

    local r=tonumber(hex:sub(1,2),16)
    local g=tonumber(hex:sub(3,4),16)
    local b=tonumber(hex:sub(5,6),16)
    if not r or not g or not b then return nil end
    return {r=r/255,g=g/255,b=b/255}
end

local function GetTRP3CurrentProfile()
    if not TRP3_API then
        return nil,"Total RP 3 is not loaded."
    end

    -- Documented route.
    if TRP3_API.profile then
        local currentID=SafeCall(TRP3_API.profile.getPlayerCurrentProfileID)
        if currentID then
            local profile=SafeCall(TRP3_API.profile.getProfileByID,currentID)
            if type(profile)=="table" then return profile end
        end
    end

    -- Fallbacks for versions exposing profile storage differently.
    if type(TRP3_API.profile)=="table" then
        local p=TRP3_API.profile.getPlayerCurrentProfile and SafeCall(TRP3_API.profile.getPlayerCurrentProfile)
        if type(p)=="table" then return p end
    end

    if AddOn_TotalRP3 and AddOn_TotalRP3.Player and AddOn_TotalRP3.Player.static and TRP3_API.register and TRP3_API.register.getUnitID then
        local characterID=SafeCall(TRP3_API.register.getUnitID,"player")
        if characterID and AddOn_TotalRP3.Player.static.CreateFromCharacterID then
            local player=SafeCall(AddOn_TotalRP3.Player.static.CreateFromCharacterID,characterID)
            if type(player)=="table" then
                -- Several TRP3 versions expose the raw profile/reference under one of these.
                local profile=FirstNonEmpty(
                    player.profile,
                    player.character,
                    player.data,
                    player.rawProfile,
                    player.getProfile and SafeCall(player.getProfile,player)
                )
                if type(profile)=="table" then return profile end
            end
        end
    end

    return nil,"Could not read the active Total RP 3 profile."
end

local function ExtractCharacteristicContainers(profile)
    local list={}
    local seen={}
    local function Add(tbl)
        if type(tbl)=="table" and not seen[tbl] then
            seen[tbl]=true
            table.insert(list,tbl)
        end
    end

    Add(profile.characteristics)
    Add(profile.character)
    Add(profile.C)
    Add(profile.CH)
    Add(profile.characterData)
    Add(profile.data and profile.data.characteristics)
    Add(profile.data and profile.data.character)
    Add(profile.profile and profile.profile.characteristics)
    Add(profile.profile and profile.profile.character)

    -- Some TRP3 profiles store characteristics under a root "player" block.
    Add(profile.player and profile.player.characteristics)
    Add(profile.player and profile.player.character)

    return list
end

local function FindInContainers(containers, keys)
    for _,container in ipairs(containers) do
        for _,key in ipairs(keys) do
            local v=container[key]
            if v~=nil and tostring(v)~="" then return v,container end
        end
    end
end


local function ExtractTRPValue(raw)
    if raw == nil then return nil end

    if type(raw)=="string" or type(raw)=="number" then
        return StripTRPColors(tostring(raw))
    end

    if type(raw)=="table" then
        local value=FirstNonEmpty(
            raw.VA, raw.value, raw.TX, raw.text,
            raw.NA, raw.name, raw.DE, raw.description,
            raw.label, raw.title
        )
        if value~=nil and type(value)~="table" then
            return StripTRPColors(tostring(value))
        end
    end

    return nil
end

local function ExtractTRPColor(raw)
    if raw==nil then return nil end

    if type(raw)=="string" then
        return ParseTRPTextColor(raw)
    end

    if type(raw)=="table" then
        -- TRP3 versions/modules may store color separately from the visible value.
        local color=FirstNonEmpty(
            raw.CO, raw.color, raw.colour,
            raw.CC, raw.classColor, raw.textColor,
            raw.CL
        )

        if type(color)=="table" then
            local r=tonumber(color.r or color.R or color[1])
            local g=tonumber(color.g or color.G or color[2])
            local b=tonumber(color.b or color.B or color[3])

            if r and g and b then
                -- Support either 0..1 or 0..255 values.
                if r>1 or g>1 or b>1 then
                    r,g,b=r/255,g/255,b/255
                end
                return {r=r,g=g,b=b}
            end
        elseif type(color)=="string" then
            local parsed=ParseTRPTextColor(color)
            if parsed then return parsed end

            local hex=color:match("^#?(%x%x%x%x%x%x)$")
            if hex then
                local r=tonumber(hex:sub(1,2),16)
                local g=tonumber(hex:sub(3,4),16)
                local b=tonumber(hex:sub(5,6),16)
                if r and g and b then return {r=r/255,g=g/255,b=b/255} end
            end
        end

        -- Color may still be embedded in the value.
        local value=FirstNonEmpty(raw.VA,raw.value,raw.TX,raw.text)
        if type(value)=="string" then
            return ParseTRPTextColor(value)
        end
    end

    return nil
end

local function FindRawInContainers(containers,keys)
    for _,container in ipairs(containers or {}) do
        if type(container)=="table" then
            for _,key in ipairs(keys) do
                local value=container[key]
                if value~=nil then return value,container end
            end
        end
    end
end

local function FindRecursiveKey(root,keys,maxDepth)
    local wanted={}
    for _,key in ipairs(keys) do wanted[tostring(key):lower()]=true end
    local seen={}

    local function Scan(tbl,depth)
        if type(tbl)~="table" or seen[tbl] or depth>(maxDepth or 5) then return nil end
        seen[tbl]=true

        for key,value in pairs(tbl) do
            if wanted[tostring(key):lower()] then
                local extracted=ExtractTRPValue(value)
                if extracted and extracted~="" then
                    return value
                end
            end
        end

        for _,value in pairs(tbl) do
            if type(value)=="table" then
                local found=Scan(value,depth+1)
                if found~=nil then return found end
            end
        end
    end

    return Scan(root,0)
end

local function ReadGuildFallback()
    if not GetGuildInfo then return nil,nil end
    local guildName,guildRank=GetGuildInfo("player")
    if guildName=="" then guildName=nil end
    if guildRank=="" then guildRank=nil end
    return guildName,guildRank
end


local function UnwrapMSPField(raw)
    if raw==nil then return nil end
    if type(raw)=="string" or type(raw)=="number" then
        return tostring(raw)
    end
    if type(raw)=="table" then
        local value=raw.value or raw.Value or raw.VA or raw.val or raw.text or raw.TX
        if value~=nil and type(value)~="table" then
            return tostring(value)
        end
    end
    return nil
end

local function GetMSPSelfField(fieldName)
    local lib=_G.msp
    if type(lib)~="table" then return nil end

    -- LibMSP publishing-side storage used by RP addons.
    if type(lib.my)=="table" then
        local raw=lib.my[fieldName]
        if raw==nil and type(lib.my.field)=="table" then raw=lib.my.field[fieldName] end
        if raw==nil and type(lib.my.fields)=="table" then raw=lib.my.fields[fieldName] end
        local value=UnwrapMSPField(raw)
        if value and value~="" then return value end
    end

    -- Registry/cache fallback. Different LibMSP revisions expose the current
    -- character record slightly differently, so try common name forms.
    if type(lib.char)=="table" then
        local playerName,realm=UnitFullName("player")
        local candidates={
            playerName,
            playerName and realm and realm~="" and (playerName.."-"..realm) or nil,
            UnitName("player"),
        }

        for _,name in ipairs(candidates) do
            if name then
                local record=lib.char[name]
                if type(record)=="table" then
                    local fields=record.field or record.fields or record
                    if type(fields)=="table" then
                        local value=UnwrapMSPField(fields[fieldName])
                        if value and value~="" then return value end
                    end
                end
            end
        end
    end

    return nil
end


local function NormalizeTRPStatus(value)
    if value==nil then return nil end

    if type(value)=="table" then
        value=UnwrapMSPField(value) or ExtractTRPValue(value)
    end
    if value==nil then return nil end

    -- Live TRP3/MSP values observed in this setup:
    --   1 = OOC
    --   3 = IC
    --
    -- Do NOT treat 3 as LFC for local-player scoring.
    local numeric=tonumber(value)
    if numeric then
        numeric=math.floor(numeric)
        if numeric==1 then return "OOC" end
        if numeric==3 then return "IC" end
        -- Keep 2 as IC compatibility for clients/MSP layouts that publish
        -- the conventional IC value.
        if numeric==2 then return "IC" end
    end

    local text=tostring(value):upper()
    text=text:gsub("^%s+",""):gsub("%s+$","")

    if text=="IC" or text=="IN CHARACTER" or text=="IN_CHARACTER" then
        return "IC"
    end
    if text=="OOC" or text=="OUT OF CHARACTER" or text=="OUT_OF_CHARACTER" then
        return "OOC"
    end
    if text=="LFC" or text=="LOOKING FOR CONTACT" or text=="LOOKING_FOR_CONTACT" then
        return "LFC"
    end

    return nil
end

-- Local-player TRP character status.
-- 1.3.12 accidentally removed this function while replacing the normalizer.
function RPAL.GetTRPCharacterStatus()
    -- Prefer Total RP 3's current Player API. Modern TRP stores IC/OOC in
    -- character.RP (1 = IC, 2 = OOC); MSP FC uses a different legacy scale.
    local addon=_G.AddOn_TotalRP3
    local playerClass=type(addon)=="table" and addon.Player or nil
    local enums=type(addon)=="table" and addon.Enums or nil
    local roleplayStatus=type(enums)=="table" and enums.ROLEPLAY_STATUS or nil
    if type(playerClass)=="table" and type(playerClass.GetCurrentUser)=="function" and type(roleplayStatus)=="table" then
        local ok,user=pcall(playerClass.GetCurrentUser)
        if ok and type(user)=="table" and type(user.GetRoleplayStatus)=="function" then
            local good,value=pcall(user.GetRoleplayStatus,user)
            if good then
                if value==roleplayStatus.IN_CHARACTER then return "IC","TRP3 Player" end
                if value==roleplayStatus.OUT_OF_CHARACTER then return "OOC","TRP3 Player" end
            end
        end
    end

    local raw=GetMSPSelfField("FC")
    local status=NormalizeTRPStatus(raw)
    if status then
        return status,"MSP helper"
    end

    -- Direct LibMSP fallbacks.
    if type(_G.msp)=="table" and type(_G.msp.my)=="table" then
        local my=_G.msp.my
        local candidates={
            my.FC,
            type(my.field)=="table" and my.field.FC or nil,
            type(my.fields)=="table" and my.fields.FC or nil,
            type(my.char)=="table" and my.char.FC or nil,
        }
        for _,candidate in ipairs(candidates) do
            local value=UnwrapMSPField(candidate) or ExtractTRPValue(candidate) or candidate
            local resolved=NormalizeTRPStatus(value)
            if resolved then return resolved,"LibMSP" end
        end
    end

    -- TRP3 active-profile fallback.
    local profile=GetTRP3CurrentProfile()
    if type(profile)=="table" then
        local rawProfile=FindRecursiveKey(profile,{
            "FC","characterStatus","CharacterStatus",
            "rpStatus","RPStatus","roleplayStatus","RoleplayStatus"
        },12)
        local resolved=NormalizeTRPStatus(
            UnwrapMSPField(rawProfile) or ExtractTRPValue(rawProfile) or rawProfile
        )
        if resolved then return resolved,"TRP3 profile" end
    end

    return "UNKNOWN","none"
end

function RPAL.IsTRPInCharacter()
    local status=select(1,RPAL.GetTRPCharacterStatus())
    -- Strict normal-mode rule: only IC is valid.
    return status=="IC"
end

local function MSPShortName(value)
    value=tostring(value or "")
    return (value:match("^([^%-]+)") or value):lower()
end

RPAL._remoteTRPCache=RPAL._remoteTRPCache or {records={},fields={},presenceShort={}}
RPAL._perfStats=RPAL._perfStats or {}

function RPAL.GetMSPRecordCached(lib,characterName,wantedShort)
    local cache=RPAL._remoteTRPCache
    local now=(GetTime and GetTime()) or 0
    local cached=cache.records[wantedShort]
    local cacheTTL=(cached and cached.miss) and 2 or 20
    if cached and now-(cached.at or 0)<cacheTTL then
        local key=cached.key
        if key and type(lib.char[key])=="table" then
            RPAL._perfStats.mspRecordCacheHits=(RPAL._perfStats.mspRecordCacheHits or 0)+1
            return lib.char[key],key
        elseif cached.miss then
            RPAL._perfStats.mspRecordMissCacheHits=(RPAL._perfStats.mspRecordMissCacheHits or 0)+1
            return nil
        end
    end

    local short=tostring(characterName or ""):match("^([^%-]+)")
    for _,candidate in ipairs({characterName,short}) do
        if candidate and candidate~="" and type(lib.char[candidate])=="table" then
            cache.records[wantedShort]={key=candidate,at=now}
            return lib.char[candidate],candidate
        end
    end

    RPAL._perfStats.mspFullScans=(RPAL._perfStats.mspFullScans or 0)+1
    for key,record in pairs(lib.char) do
        if MSPShortName(key)==wantedShort and type(record)=="table" then
            cache.records[wantedShort]={key=key,at=now}
            return record,key
        end
    end
    cache.records[wantedShort]={miss=true,at=now}
    return nil
end

local function GetMSPOtherField(characterName,fieldName)
    local lib=_G.msp
    if type(lib)~="table" or type(lib.char)~="table" then return nil end
    local wantedShort=MSPShortName(characterName)
    if wantedShort=="" then return nil end
    local cache=RPAL._remoteTRPCache
    local now=(GetTime and GetTime()) or 0
    local cacheKey=wantedShort.."\031"..tostring(fieldName or "")
    local cached=cache.fields[cacheKey]
    if cached and now-(cached.at or 0)<1.25 then
        RPAL._perfStats.mspFieldCacheHits=(RPAL._perfStats.mspFieldCacheHits or 0)+1
        return cached.hasValue and cached.value or nil
    end

    local record=RPAL.GetMSPRecordCached(lib,characterName,wantedShort)
    local value
    if type(record)=="table" then
        local fields=record.field or record.fields or record
        if type(fields)=="table" then value=UnwrapMSPField(fields[fieldName]) end
    end
    cache.fields[cacheKey]={at=now,hasValue=value~=nil and value~="",value=value}
    return value
end

function RPAL.InvalidateRemoteTRPCaches(characterName)
    local cache=RPAL._remoteTRPCache
    if not characterName or tostring(characterName)=="" then
        wipe(cache.records);wipe(cache.fields);wipe(cache.presenceShort);return
    end
    local short=MSPShortName(characterName)
    cache.records[short]=nil
    cache.presenceShort[short]=nil
    for key in pairs(cache.fields) do
        if key:sub(1,#short+1)==short.."\031" then cache.fields[key]=nil end
    end
end

function RPAL.GetOtherTRPCharacterStatus(characterName)
    -- ODYN presence is stored with SocialNormalizeName (usually name-realm).
    -- Keep the fallback short-name lookup off the chat hot path as well.
    local presence
    if socialPresence then
        presence=socialPresence[SocialNormalizeName(characterName)]
        if not presence then
            local wantedShort=MSPShortName(characterName)
            local runtimeNow=(GetTime and GetTime()) or 0
            local cached=RPAL._remoteTRPCache.presenceShort[wantedShort]
            if cached and runtimeNow-(cached.at or 0)<2 then
                presence=cached.entry or nil
            else
                for key,entry in pairs(socialPresence) do
                    if MSPShortName(key)==wantedShort then presence=entry;break end
                end
                RPAL._remoteTRPCache.presenceShort[wantedShort]={entry=presence or false,at=runtimeNow}
            end
            if presence==false then presence=nil end
        end
    end
    if presence and presence.status then
        local status=NormalizeTRPStatus(presence.status)
        if status then return status,"ODYN" end
    end

    local value=GetMSPOtherField(characterName,"FC")
    local status=NormalizeTRPStatus(value)
    if status then return status,"MSP" end
    return "UNKNOWN","none"
end

function RPAL.IsOtherTRPInCharacter(characterName)
    local status=RPAL.GetOtherTRPCharacterStatus(characterName)
    return status=="IC"
end

-- Best-effort Total RP 3 / LibMSP character-name resolver for Chronicle.
-- Returns the in-character first/last name when the remote profile cache has
-- already received it; otherwise callers keep the normal WoW name.
function RPAL.GetOtherTRPCharacterName(characterName)
    -- NA is the canonical LibMSP roleplay-name field and is part of the
    -- standard tooltip payload. TRP's first/last aliases are retained as a
    -- compatibility fallback for caches that expose the richer fields.
    local canonical=GetMSPOtherField(characterName,"NA")
    canonical=StripTRPColors(canonical)
    if canonical and canonical~="" then
        if _G.msp and type(_G.msp.StripTRP3MarkupTags)=="function" then
            local ok,clean=pcall(_G.msp.StripTRP3MarkupTags,_G.msp,canonical)
            if ok and clean and clean~="" then canonical=clean end
        end
        canonical=tostring(canonical):gsub("^%s+",""):gsub("%s+$","")
        if canonical~="" then return canonical end
    end
    local first=GetMSPOtherField(characterName,"FN")
    local last=GetMSPOtherField(characterName,"LN")
    local full=""
    if first and first~="" then full=tostring(first) end
    if last and last~="" then
        full=(full~="" and (full.." "..tostring(last)) or tostring(last))
    end
    full=StripTRPColors(full)
    if full~="" then return full end
    return nil
end

function RPAL.CollectTRP3Glances(profile,containers)
    local results={}
    local seen={}
    local function addCandidate(entry)
        if type(entry)~="table" then return end
        local title=entry.title or entry.name or entry.label or entry.header or entry.textTitle
        local description=entry.description or entry.desc or entry.text or entry.body or entry.content
        local icon=entry.icon or entry.texture or entry.iconPath or entry.iconID or entry.iconId
        if type(title)=="table" then title=ExtractTRPValue(title) end
        if type(description)=="table" then description=ExtractTRPValue(description) end
        if type(icon)=="table" then
            -- Total RP 3 may expose its selected icon as an Ellyb.Icon object.
            -- Such objects do not necessarily expose path/fileID as plain fields.
            local resolved=nil
            if icon.GetFileID then
                local ok,value=pcall(icon.GetFileID,icon)
                if ok and value then resolved=value end
            end
            if not resolved and icon.GetFilePath then
                local ok,value=pcall(icon.GetFilePath,icon)
                if ok and value then resolved=value end
            end
            icon=resolved or icon.path or icon.texture or icon.file or icon.fileName or icon.fileID or icon.id
        end
        title=tostring(title or "");description=tostring(description or "")
        -- Preserve numeric file IDs as numbers so SetTexture() can use them
        -- directly; preserve raw interface paths verbatim.
        if type(icon)=="number" then
            icon=tostring(icon)
        else
            icon=tostring(icon or "")
        end
        if title=="" and description=="" then return end
        local key=title.."\031"..description.."\031"..icon
        if seen[key] then return end
        seen[key]=true
        results[#results+1]={title=title,description=description,icon=icon}
    end

    -- Current Total RP 3 stores "At First Glance" in player/misc.PE.
    -- The normal profile object does not necessarily contain this table,
    -- which is why the previous recursive importer could return zero entries.
    if TRP3_API and TRP3_API.profile and TRP3_API.profile.getData then
        local misc=SafeCall(TRP3_API.profile.getData,"player/misc")
        local pe=type(misc)=="table" and misc.PE or nil
        if type(pe)=="table" then
            local slots={}
            for key,entry in pairs(pe) do
                local n=tonumber(key)
                if n and n>=1 and n<=5 and type(entry)=="table" then
                    slots[#slots+1]={slot=n,entry=entry}
                end
            end
            table.sort(slots,function(a,b)return a.slot<b.slot end)
            for _,slot in ipairs(slots) do
                local entry=slot.entry
                local active=entry.AC
                if active==nil or active==true or active==1 or active=="1" then
                    addCandidate({
                        title=entry.TI or entry.title or entry.name or "",
                        description=entry.TX or entry.description or entry.text or "",
                        icon=entry.IC or entry.icon or entry.texture or "",
                    })
                end
            end
        end
    end

    local function scan(node,depth,keyHint)
        if type(node)~="table" or depth>12 or #results>=5 then return end
        for key,value in pairs(node) do
            local lk=string.lower(tostring(key or ""))
            if lk=="glances" or lk=="glance" or lk=="atfirstglance" or lk=="at_first_glance" or lk=="atfirstglances" then
                if type(value)=="table" then
                    if #value>0 then
                        for _,entry in ipairs(value) do addCandidate(entry) end
                    else
                        addCandidate(value)
                        for _,entry in pairs(value) do
                            if type(entry)=="table" then addCandidate(entry) end
                        end
                    end
                end
            elseif type(value)=="table" and (depth<6 or lk=="misc" or lk=="miscellaneous") then
                scan(value,depth+1,lk)
            end
            if #results>=5 then break end
        end
    end
    for _,container in ipairs(containers or {}) do scan(container,0,"container") end
    scan(profile,0,"profile")
    return results
end

local function ReadTRP3Import()
    local profile,err=GetTRP3CurrentProfile()
    if not profile then return nil,err end

    local containers=ExtractCharacteristicContainers(profile)
    if #containers==0 then
        -- Last resort: profile itself can be the characteristics table.
        table.insert(containers,profile)
    end

    local imported={}

    -- Character identity fields.
    local firstRaw=FindRawInContainers(containers,{"FN","firstName","firstname","FirstName"})
        or FindRecursiveKey(profile,{"FN","firstName","firstname"},5)
    local surnameRaw=FindRawInContainers(containers,{"LN","lastName","lastname","surname","Surname"})
        or FindRecursiveKey(profile,{"LN","lastName","surname"},5)
    local titleRaw=GetMSPSelfField("NT")
        or FindRawInContainers(containers,{"NT","TI","title","Title","FT","fullTitle","fulltitle"})
        or FindRecursiveKey(profile,{"NT","TI","title","FT","fullTitle"},6)

    imported.firstName=ExtractTRPValue(firstRaw)
    imported.surname=ExtractTRPValue(surnameRaw)
    imported.title=ExtractTRPValue(titleRaw)

    -- TRP3 / MSP-compatible aliases.
    -- User requested exact RPAL mapping:
    -- Race -> Race
    -- Class -> Class
    -- Age -> Age
    -- Eye color -> Eye Color
    -- Height -> Height
    -- Body shape -> Weight
    -- Birthplace -> Birthplace
    -- Residence -> Residence
    -- Guild rank -> Guild Rank
    local raceRaw=GetMSPSelfField("RA")
        or FindRawInContainers(containers,{"RA","race","Race","GR"})
        or FindRecursiveKey(profile,{"RA","race"},5)
    imported.race=ExtractTRPValue(raceRaw)

    -- TRP3 publishes the user-editable/custom class through MSP field RC.
    -- RC also preserves the user-selected color code.
    local classRaw=GetMSPSelfField("RC")
        or FindRawInContainers(containers,{"RC","CL","class","Class","GC"})
        or FindRecursiveKey(profile,{"RC","CL","class","GC"},6)
    imported.className=ExtractTRPValue(classRaw)
    imported.classColor=ExtractTRPColor(classRaw)
    if not imported.classColor then
        local classColorRaw=FindRawInContainers(containers,{
            "classColor","ClassColor","CLColor","CC","classColour"
        }) or FindRecursiveKey(profile,{
            "classColor","ClassColor","CLColor","classColour"
        },6)
        imported.classColor=ExtractTRPColor(classColorRaw)
    end

    imported.age=ExtractTRPValue(
        FindRawInContainers(containers,{"AG","age","Age"})
        or FindRecursiveKey(profile,{"AG","age"},5)
    )

    local eyeRaw=FindInContainers(containers,{"EC","AE","eyeColor","eyes","EyeColor"})
    imported.eyeText=ExtractTRPValue(eyeRaw)
    local eyeColorSource=type(eyeRaw)=="string" and eyeRaw or imported.eyeText
    imported.eyeColor,imported.eyeHex=ParseTRPEyeColor(eyeColorSource)
    if not imported.eyeColor then imported.eyeColor=ExtractTRPColor(eyeRaw) end
    if imported.eyeColor and not imported.eyeHex then
        local r=math.max(0,math.min(255,math.floor((tonumber(imported.eyeColor.r) or 0)*255+0.5)))
        local g=math.max(0,math.min(255,math.floor((tonumber(imported.eyeColor.g) or 0)*255+0.5)))
        local b=math.max(0,math.min(255,math.floor((tonumber(imported.eyeColor.b) or 0)*255+0.5)))
        imported.eyeHex=string.format("#%02X%02X%02X",r,g,b)
    end

    local heightRaw=
        FindRawInContainers(containers,{"HE","AH","height","Height"})
        or FindRecursiveKey(profile,{"HE","AH","height","Height"},6)
    imported.height=ExtractTRPValue(heightRaw)

    local bodyShapeRaw=
        FindRawInContainers(containers,{"AW","bodyShape","bodyshape","BodyShape","bodyType","BodyType","weight","Weight"})
        or FindRecursiveKey(profile,{"AW","bodyShape","bodyshape","BodyShape","bodyType","BodyType","weight","Weight"},6)
    imported.bodyShape=ExtractTRPValue(bodyShapeRaw)

    local birthplaceRaw=
        FindRawInContainers(containers,{"BP","birthplace","Birthplace","HB"})
        or FindRecursiveKey(profile,{"BP","birthplace","Birthplace","HB"},6)
    imported.birthplace=ExtractTRPValue(birthplaceRaw)

    local residenceRaw=
        FindRawInContainers(containers,{"RE","residence","Residence","HH"})
        or FindRecursiveKey(profile,{"RE","residence","Residence","HH"},6)
    imported.residence=ExtractTRPValue(residenceRaw)

    imported.guildRank=ExtractTRPValue(
        FindRawInContainers(containers,{"GRK","guildRank","GuildRank"})
        or FindRecursiveKey(profile,{"guildRank","GuildRank","GRK"},5)
    )
    imported.guildName=ExtractTRPValue(
        FindRawInContainers(containers,{"GN","guildName","GuildName"})
        or FindRecursiveKey(profile,{"guildName","GuildName","GN"},5)
    )

    -- Pronouns are optional. Search Additional Information by field label.
    for _,container in ipairs(containers) do
        imported.pronouns=imported.pronouns or FindAdditionalField(container,{
            "Pronouns","Pronoun","Preferred Pronouns"
        })
    end

    -- Some versions keep extra fields on profile root.
    imported.pronouns=imported.pronouns or FindAdditionalField(profile,{
        "Pronouns","Pronoun","Preferred Pronouns"
    })
    imported.guildRank=imported.guildRank or FindTRPCustomGuildField(profile,"rank")
    imported.guildName=imported.guildName or FindTRPCustomGuildField(profile,"name")

    imported.guildRank=imported.guildRank or FindAdditionalField(profile,{
        "Guild Rank","Guildrank","Custom Guild Rank","Rank"
    })
    imported.guildName=imported.guildName or FindAdditionalField(profile,{
        "Guild Name","Custom Guild Name","Guild","Guildname"
    })

    -- Only fall back to the actual WoW guild if TRP has no custom guild values.
    local wowGuildName,wowGuildRank=ReadGuildFallback()
    imported.guildName=imported.guildName or wowGuildName
    imported.guildRank=imported.guildRank or wowGuildRank

    imported.glances=RPAL.CollectTRP3Glances(profile,containers)
    imported.additionalFields=CollectTRPAdditionalFields(profile,containers,imported.glances)

    -- Require at least one recognized profile field before treating the import as valid.
    local count=0
    for k,v in pairs(imported) do
        if k=="eyeColor" then
            if v then count=count+1 end
        elseif v~=nil and tostring(v)~="" then
            count=count+1
        end
    end

    if imported.additionalFields and #imported.additionalFields>0 then
        count=count+#imported.additionalFields
    end

    if count==0 then
        return nil,"TRP3 profile was found, but none of the requested fields could be mapped in this TRP3 version."
    end

    return imported
end

function RPAL.HasImportableTRP3Profile()
    local p=RPAL.char and RPAL.char.profile
    if not p or p.trpImportedOnce then return false end
    local data=select(1,ReadTRP3Import())
    return data~=nil
end

function RPAL.ImportTRP3Quick()
    local p=RPAL.char and RPAL.char.profile
    if not p then return false,"Character data is not ready." end
    if p.trpImportedOnce then return false,"This character has already imported a TRP3 profile." end
    local data,err=ReadTRP3Import()
    if not data then return false,err or "No importable Total RP 3 profile was found." end
    RPAL.ApplyTRP3Import(data)
    return true,data
end

RPAL._trpAutoImportGeneration=RPAL._trpAutoImportGeneration or 0

function RPAL.EnsureActiveTRPProfileImported(silent)
    local profileID=RPAL.GetActiveTRPProfileID and RPAL.GetActiveTRPProfileID()
    if not profileID then return false,"No active Total RP 3 profile is available yet." end
    local p=RPAL.char and RPAL.char.profile
    if not p then return false,"Character data is not ready." end
    if p.trpImportedOnce then
        if RPAL.GetSettings().autoTRPUpdate then RPAL.SyncTRP3(true) end
        return false,"already imported"
    end
    local data,err=ReadTRP3Import()
    if not data then return false,err or "TRP profile is not ready." end
    RPAL.ApplyTRP3Import(data)
    lastTRPSignature=nil
    if not silent and RPAL.ShowSessionStatusToast then
        RPAL.ShowSessionStatusToast("Active TRP profile imported for "..tostring((RPAL.FullName and RPAL.FullName()) or "this character")..".","IC")
    end
    return true,data
end

function RPAL.ScheduleActiveTRPProfileImport(silent)
    RPAL._trpAutoImportGeneration=RPAL._trpAutoImportGeneration+1
    local generation=RPAL._trpAutoImportGeneration
    local delays={0.10,0.75,1.80,3.80}
    for _,delay in ipairs(delays) do
        C_Timer.After(delay,function()
            if generation~=RPAL._trpAutoImportGeneration or not RPAL.char then return end
            local p=RPAL.char.profile
            if p and not p.trpImportedOnce then RPAL.EnsureActiveTRPProfileImported(silent) end
        end)
    end
end

local function RaceExists(name)
    if not name or name=="" then return false end
    local lower=name:lower()

    for _,race in ipairs(RPAL.RACES or {}) do
        if race:lower()==lower then return true,race end
    end

    for _,race in ipairs(RPAL.char.profile.customRaces or {}) do
        if race:lower()==lower then return true,race end
    end

    return false
end

function RPAL.ApplyTRP3Import(data)
    local p=RPAL.char.profile

    if data.title and data.title~="" then p.title=data.title end
    if data.firstName and data.firstName~="" then p.firstName=data.firstName end
    if data.surname and data.surname~="" then p.surname=data.surname end

    if data.race and data.race~="" then
        local rawRace=tostring(data.race)
        local normalized=rawRace:lower():gsub("^%s+",""):gsub("%s+$","")

        local humanAfflicted =
            normalized=="afflicted" or
            normalized=="afflicted gilnean" or
            normalized=="afflicted human" or
            normalized=="worgen"

        local kulTiran =
            normalized=="kul tiran"

        local afflictedKulTiran =
            normalized=="afflicted kul tiran"

        local afflictedKaldorei =
            normalized=="afflicted kaldorei" or
            normalized=="afflicted night elf"

        if afflictedKulTiran then
            -- Kul Tiran afflicted characters are modeled as Human with both
            -- Kul Tiran and Worgen Curse flags enabled.
            p.race="Human"
            p.kulTiran=true
            p.worgen=true

        elseif kulTiran then
            -- Kul Tiran is a Human variant in RPAL.
            p.race="Human"
            p.kulTiran=true
            p.worgen=false

        elseif afflictedKaldorei then
            -- Afflicted Kaldorei/Night Elf is modeled as Kaldorei + Worgen flag.
            p.race="Kaldorei"
            p.kulTiran=false
            p.worgen=true

        elseif humanAfflicted then
            -- Generic afflicted/Gilnean/Worgen maps to Human + Worgen flag.
            p.race="Human"
            p.worgen=true
            p.kulTiran=false

        else
            local exists,canonical=RaceExists(rawRace)
            if exists then
                p.race=canonical
            else
                p.customRaces=p.customRaces or {}
                table.insert(p.customRaces,rawRace)
                p.race=rawRace
            end
        end
    end

    if data.className and data.className~="" then
        p.className=data.className
        p.classColor=data.classColor and {
            r=data.classColor.r,g=data.classColor.g,b=data.classColor.b
        } or nil
    end
    if data.guildName and data.guildName~="" then p.guildName=data.guildName end
    if data.age and data.age~="" then p.age=data.age end
    if data.height and data.height~="" then p.height=data.height end
    if data.bodyShape and data.bodyShape~="" then p.bodyShape=data.bodyShape end
    if data.birthplace and data.birthplace~="" then p.birthplace=data.birthplace end
    if data.residence and data.residence~="" then p.residence=data.residence end
    if data.guildRank and data.guildRank~="" then p.guildRank=data.guildRank end
    if data.pronouns and data.pronouns~="" then p.pronouns=data.pronouns end

    if data.eyeColor then
        p.eyeMode="single"
        p.eyeColor={r=data.eyeColor.r,g=data.eyeColor.g,b=data.eyeColor.b}
    end
    p.trpEyeText=tostring(data.eyeText or "")
    p.trpEyeHex=tostring(data.eyeHex or "")

    -- Once a TRP profile is imported, Information follows TRP's Additional Fields.
    p.useTRPInformation=true
    p.trpImportedOnce=true
    p.trpAdditionalFields={}
    for _,field in ipairs(data.additionalFields or {}) do
        p.trpAdditionalFields[#p.trpAdditionalFields+1]={
            id=tostring(field.id or RPAL.NewID("trpfield")),
            label=tostring(field.label or "Additional Field"),
            value=tostring(field.value or ""),
        }
    end

    p.glances={}
    for index,glance in ipairs(data.glances or {}) do
        if index<=5 and type(glance)=="table" then
            p.glances[index]={
                title=tostring(glance.title or ""),
                description=tostring(glance.description or ""),
                icon=tostring(glance.icon or ""),
            }
        end
    end

    if RPAL.RefreshHoverLogIdentity then RPAL.RefreshHoverLogIdentity() end
    if RPAL.RefreshThemeVisuals then RPAL.RefreshThemeVisuals()
    elseif RPAL.Refresh then RPAL.Refresh() end
end

function RPAL.OpenTRP3Import()
    local data,err=ReadTRP3Import()
    if not data then
        print("|cffff6666ODYN:|r "..(err or "Could not import from Total RP 3."))
        return
    end

    local shade,m=Modal("Import from Total RP 3",620,500)

    local hint=Font(m,"Review the fields found in your active TRP3 profile. Only these fields will be imported.",10,MUTED)
    hint:SetPoint("TOPLEFT",16,-48)
    hint:SetWidth(585)
    hint:SetWordWrap(true)

    local rows={}
    local function Add(label,value)
        if value==nil or tostring(value)=="" then return end
        table.insert(rows,{label=label,value=tostring(value)})
    end

    Add("Title",data.title)
    Add("First Name",data.firstName)
    Add("Surname",data.surname)
    Add("Race",data.race)
    Add("Class",data.className)
    if data.classColor then
        Add("Class Color",("#%02X%02X%02X"):format(
            math.floor(data.classColor.r*255+0.5),
            math.floor(data.classColor.g*255+0.5),
            math.floor(data.classColor.b*255+0.5)
        ))
    end
    Add("Age",data.age)
    Add("Eye Color",data.eyeHex or data.eyeText)
    Add("Height",data.height)
    Add("Body Shape → Weight",data.weight)
    Add("Birthplace",data.birthplace)
    Add("Residence",data.residence)
    Add("Pronouns",data.pronouns)
    Add("Guild Rank",data.guildRank)
    Add("Guild Name",data.guildName)

    local scroll=CreateFrame("ScrollFrame",nil,m,"UIPanelScrollFrameTemplate")
    if RPAL.SkinScrollFrame then RPAL.SkinScrollFrame(scroll) end
    scroll:SetPoint("TOPLEFT",16,-88)
    scroll:SetPoint("BOTTOMRIGHT",-32,62)

    local content=CreateFrame("Frame",nil,scroll)
    content:SetSize(550,math.max(1,#rows*34))
    scroll:SetScrollChild(content)

    local y=0
    for _,row in ipairs(rows) do
        local line=CreateFrame("Frame",nil,content,"BackdropTemplate")
        line:SetPoint("TOPLEFT",0,-y)
        line:SetPoint("TOPRIGHT",0,-y)
        line:SetHeight(30)
        Backdrop(line,PANEL2,BORDER)

        local key=Font(line,row.label,10,MUTED)
        key:SetPoint("LEFT",8,0)
        key:SetWidth(155)
        key:SetJustifyH("LEFT")

        local value=Font(line,row.value,11,PALE)
        value:SetPoint("LEFT",170,0)
        value:SetPoint("RIGHT",-8,0)
        value:SetJustifyH("LEFT")
        value:SetWordWrap(false)

        y=y+34
    end

    local auto=Check(m,"Keep automatically synced with TRP3")
    auto:SetSize(260,30)
    auto:SetPoint("BOTTOMLEFT",16,16)
    auto:SetChecked(RPAL.GetSettings().autoTRPUpdate)
    auto:SetScript("OnClick",function(self)
        self:SetChecked(not self:GetChecked())
    end)

    local cancel=Button(m,"Cancel",90,30,false)
    cancel:SetPoint("BOTTOMRIGHT",-116,16)
    cancel:SetScript("OnClick",function()shade:Hide()end)

    local import=Button(m,"Import",90,30,true)
    import:SetPoint("BOTTOMRIGHT",-16,16)
    import:SetScript("OnClick",function()
        RPAL.GetSettings().autoTRPUpdate=auto:GetChecked()
        RPAL.ApplyTRP3Import(data)
        lastTRPSignature=nil
        if RPAL.StartTRPAutoUpdate then RPAL.StartTRPAutoUpdate(true) end
        shade:Hide()
        print("|cffd9a644ODYN:|r imported available fields from Total RP 3.")
    end)
end



local function TRPSignature(data)
    if not data then return "" end
    local parts={
        data.title or "",data.firstName or "",data.surname or "",data.race or "",
        data.className or "",data.age or "",data.eyeHex or "",data.height or "",
        data.weight or "",data.birthplace or "",data.residence or "",
        data.pronouns or "",data.guildRank or "",data.guildName or "",
        data.classColor and (tostring(data.classColor.r)..","..tostring(data.classColor.g)..","..tostring(data.classColor.b)) or "",
    }

    for _,field in ipairs(data.additionalFields or {}) do
        parts[#parts+1]=(field.label or "").."="..(field.value or "")
    end
    return table.concat(parts,"\031")
end

function RPAL.SyncTRP3(force)
    if not RPAL.GetSettings().autoTRPUpdate then return false end
    local profile=RPAL.char and RPAL.char.profile
    -- First-use import is handled by EnsureActiveTRPProfileImported; after that
    -- normal opt-in live synchronization keeps the imported fields current.
    if profile and not profile.trpImportedOnce then return false end
    local data=ReadTRP3Import()
    if not data then return false end

    local signature=TRPSignature(data)
    if force or signature~=lastTRPSignature then
        lastTRPSignature=signature
        RPAL.ApplyTRP3Import(data)
        return true
    end
    return false
end

function RPAL.StartTRPAutoUpdate(force)
    if trpTicker then
        trpTicker:Cancel()
        trpTicker=nil
    end

    if not RPAL.GetSettings().autoTRPUpdate then
        lastTRPSignature=nil
        return
    end

    -- Immediate delayed pull for login/reload, then live local profile checks.
    C_Timer.After(1.5,function()
        if RPAL.GetSettings().autoTRPUpdate then
            RPAL.SyncTRP3(true)
        end
    end)

    -- Local edits already trigger direct sync paths. This is only a fallback
    -- signature poll for changes made through TRP. Local edits use direct sync
    -- paths, so a slower fallback avoids repeatedly walking the active profile.
    trpTicker=C_Timer.NewTicker(10.0,function()
        if RPAL.GetSettings().autoTRPUpdate then
            RPAL.SyncTRP3(false)
        end
    end)
end

function RPAL.OnTRPProfileScopeChanged(newProfileID,oldProfileID,newSourceProfileID,oldSourceProfileID)
    lastTRPSignature=nil
    -- Session status is runtime state, while RPAL.char just switched to a new
    -- TRP-backed scope. If both profiles are IC, keeping the old cached "IC"
    -- makes the monitor think nothing changed and the new scope never starts a
    -- Session. Reset and force one check after the scope swap settles.
    RPAL._lastSessionTRPStatus=nil
    if C_Timer and C_Timer.After and RPAL.CheckTRPSessionStatus then
        C_Timer.After(0.12,function()
            if RPAL.CheckTRPSessionStatus then RPAL.CheckTRPSessionStatus(true) end
        end)
    end
    if RPAL.ReconcileLevelRewards then RPAL.ReconcileLevelRewards() end
    if RPAL.EnsureActiveTRPProfileImported then RPAL.EnsureActiveTRPProfileImported(true) end
    if RPAL.ScheduleActiveTRPProfileImport then RPAL.ScheduleActiveTRPProfileImport(false) end
    if C_Timer and C_Timer.After then
        local expected=tostring(newProfileID or "")
        C_Timer.After(2.4,function()
            local _,active=RPAL.GetCurrentTRPProfileScope and RPAL.GetCurrentTRPProfileScope()
            if tostring(active or "")==expected and RPAL.MaybePromptLegacyCalibration then RPAL.MaybePromptLegacyCalibration() end
        end)
    end
    if RPAL.RefreshThemeVisuals then RPAL.RefreshThemeVisuals()
    elseif RPAL.Refresh then RPAL.Refresh() end
    if UI.customizationFrame and UI.customizationFrame:IsShown() and RPAL.OpenCustomization then
        RPAL.OpenCustomization(UI.customizationFrame._selectedTab or "Themes")
    end
    if RPAL.IsChronicleEmbeddedOpen and RPAL.IsChronicleEmbeddedOpen() and RPAL.RefreshChronicle then RPAL.RefreshChronicle() end
    if RPAL.RefreshTalentAtlas then RPAL.RefreshTalentAtlas() end
    if RPAL.RefreshTalentButton then RPAL.RefreshTalentButton() end
    if RPAL.UpdateSessionWidget then RPAL.UpdateSessionWidget() end
    if RPAL.ShowSessionStatusToast then
        RPAL.ShowSessionStatusToast("TRP profile changed — this character's own ODYN data is now active.","PAUSED")
    end
end

local function RenderEditor()
    local p=RPAL.char.profile
    local host=UI.editor
    if UI.dropdown then UI.dropdown:Hide();UI.dropdown=nil end
    ClearChildren(host)
    host:Show()

    local scroll=Keep(host,CreateFrame("ScrollFrame",nil,host,"UIPanelScrollFrameTemplate"))
    if RPAL.SkinScrollFrame then RPAL.SkinScrollFrame(scroll) end
    scroll:SetAllPoints()
    local content=Keep(host,CreateFrame("Frame",nil,scroll))
    content:SetSize(520,1100)
    scroll:SetScrollChild(content)

    local portrait=Keep(host,content:CreateTexture(nil,"ARTWORK")); portrait:SetSize(86,86); portrait:SetPoint("TOPLEFT",5,-8); SetPortrait(portrait); MaskCircle(portrait)
    local y=-8
    local x=112
    EditorField(content,"Title",x,y,132,function()return p.title end,function(v)p.title=v end)
    EditorField(content,"Name",x+142,y,132,function()return p.firstName end,function(v)p.firstName=v end)
    y=y-52
    EditorField(content,"Surname",x,y,132,function()return p.surname end,function(v)p.surname=v end)
    EditorField(content,"Pronouns",x+142,y,132,function()return p.pronouns end,function(v)p.pronouns=v end)
    y=y-52
    EditorField(content,"Guild Rank",x,y,132,function()return p.guildRank end,function(v)p.guildRank=v end)
    EditorField(content,"Guild Name",x+142,y,132,function()return p.guildName end,function(v)p.guildName=v end)
    y=y-52
    EditorField(content,"Class",x,y,132,function()return p.className end,function(v)p.className=v end)

    y=y-62
    local trpButton=Keep(content,Button(content,"Import from TRP3",142,30,true))
    trpButton:SetPoint("TOPLEFT",5,y)
    trpButton:SetScript("OnClick",function()RPAL.OpenTRP3Import()end)

    local trpStatus=Keep(content,Font(content,(TRP3_API and "Total RP 3 detected" or "Total RP 3 not loaded"),10,(TRP3_API and {0.55,0.78,0.55} or MUTED)))
    trpStatus:SetPoint("LEFT",trpButton,"RIGHT",10,0)

    y=y-50
    local rh=Heading(content,"RACE",14); rh:SetPoint("TOPLEFT",5,y)
    local race=Keep(content,Button(content,RPAL.VisibleRace()~="" and RPAL.VisibleRace() or "Select Race",190,30,false)); race:SetPoint("TOPLEFT",5,y-24)
    race:SetScript("OnClick",function()
        local all={}
        for _,r in ipairs(RPAL.RACES) do table.insert(all,r) end
        for _,r in ipairs(p.customRaces or {}) do table.insert(all,r) end
        PopupDropdown(content,race,all,p.race,function(v)p.race=v;RPAL.Refresh() end)
    end)
    local addRace=Keep(content,Button(content,"+",32,30,true)); addRace:SetPoint("LEFT",race,"RIGHT",6,0)
    addRace:SetScript("OnClick",function()
        RPAL.OpenTextPrompt("Custom Race","Race name:",function(v)
            if v and v~="" then table.insert(p.customRaces,v);p.race=v;RPAL.Refresh() end
        end)
    end)

    local easy=Keep(content,Check(content,"Easy Mode")); easy:SetSize(130,30); easy:SetPoint("LEFT",addRace,"RIGHT",8,0); easy:SetChecked(p.easyMode)
    easy:SetScript("OnClick",function(self)p.easyMode=not self:GetChecked();self:SetChecked(p.easyMode);RPAL.Refresh()end)
    easy:SetScript("OnEnter",function(self) if RPAL.ShowSimpleCursorTooltip then RPAL.ShowSimpleCursorTooltip("Easy Mode","Uses easier-to-understand race wording when you do not know the lore-accurate names.",GOLD,"CHARACTER EDITOR") end end)
    easy:SetScript("OnLeave",function()if RPAL.HideCursorTooltip then RPAL.HideCursorTooltip() end end)

    y=y-66
    if p.race=="Human" then
        local kul=Keep(content,Check(content,"Kul Tiran")); kul:SetSize(130,30); kul:SetPoint("TOPLEFT",5,y); kul:SetChecked(p.kulTiran)
        kul:SetScript("OnClick",function(self)p.kulTiran=not self:GetChecked();self:SetChecked(p.kulTiran);RPAL.Refresh()end)
    end
    if p.race=="Human" or p.race=="Kaldorei" then
        local wor=Keep(content,Check(content,"Worgen Curse Afflicted")); wor:SetSize(190,30); wor:SetPoint("TOPLEFT",145,y); wor:SetChecked(p.worgen)
        wor:SetScript("OnClick",function(self)p.worgen=not self:GetChecked();self:SetChecked(p.worgen);RPAL.Refresh()end)
    elseif p.race=="Orc" then
        local mg=Keep(content,Check(content,"Mag'har")); mg:SetSize(105,30); mg:SetPoint("TOPLEFT",5,y);mg:SetChecked(p.maghar)
        mg:SetScript("OnClick",function(self)p.maghar=not self:GetChecked();self:SetChecked(p.maghar);RPAL.Refresh()end)
        local au=Keep(content,Check(content,"Alternative Universe (AU)"));au:SetSize(180,30);au:SetPoint("TOPLEFT",118,y);au:SetChecked(p.au)
        au:SetScript("OnClick",function(self)p.au=not self:GetChecked();if p.au then p.mu=false end;self:SetChecked(p.au);RPAL.Refresh()end)
        local mu=Keep(content,Check(content,"Main Universe (MU)"));mu:SetSize(160,30);mu:SetPoint("TOPLEFT",305,y);mu:SetChecked(p.mu)
        mu:SetScript("OnClick",function(self)p.mu=not self:GetChecked();if p.mu then p.au=false end;self:SetChecked(p.mu);RPAL.Refresh()end)
    end

    y=y-52
    local ih=Heading(content,"INFORMATION",14);ih:SetPoint("TOPLEFT",5,y)
    y=y-25

    if p.useTRPInformation then
        local modeHint=Keep(content,Font(content,
            "Using TRP3 profile data. Core characteristics and every detected Additional Field remain editable here.",
            10,MUTED))
        modeHint:SetPoint("TOPLEFT",5,y)
        modeHint:SetWidth(402)
        modeHint:SetWordWrap(true)
        y=y-40

        local charTitle=Keep(content,Font(content,"TRP CHARACTERISTICS",10,GOLD))
        charTitle:SetPoint("TOPLEFT",5,y)
        y=y-22

        EditorField(content,"Age",5,y,126,function()return p.age end,function(v)p.age=v end)
        EditorField(content,"Height",143,y,126,function()return p.height end,function(v)p.height=v end)
        EditorField(content,"Body Shape",281,y,126,function()return p.bodyShape end,function(v)p.bodyShape=v end)
        y=y-52

        EditorField(content,"Birthplace",5,y,196,function()return p.birthplace end,function(v)p.birthplace=v end)
        EditorField(content,"Residence",213,y,194,function()return p.residence end,function(v)p.residence=v end)
        y=y-58

        local addTitle=Keep(content,Font(content,"ADDITIONAL FIELDS",10,GOLD))
        addTitle:SetPoint("TOPLEFT",5,y)
        y=y-22

        for index,field in ipairs(p.trpAdditionalFields or {}) do
            MakeLabel(content,"Field "..index,5,y)

            local labelBox=Keep(content,EditBox(content,150,30))
            labelBox:SetPoint("TOPLEFT",5,y-14)
            labelBox:SetText(field.label or "")
            labelBox:SetScript("OnEditFocusLost",function(self)
                field.label=self:GetText() or ""
                RPAL.Refresh()
            end)

            local valueBox=Keep(content,EditBox(content,200,30))
            valueBox:SetPoint("TOPLEFT",165,y-14)
            valueBox:SetText(field.value or "")
            valueBox:SetScript("OnEditFocusLost",function(self)
                field.value=self:GetText() or ""
                RPAL.Refresh()
            end)

            local del=Keep(content,Button(content,"-",34,30,false))
            del:SetPoint("TOPLEFT",373,y-14)
            del:SetScript("OnClick",function()
                table.remove(p.trpAdditionalFields,index)
                RPAL.Refresh()
            end)

            y=y-50
        end

        local addField=Keep(content,Button(content,"+ Add Field",110,28,true))
        addField:SetPoint("TOPLEFT",5,y)
        addField:SetScript("OnClick",function()
            p.trpAdditionalFields[#p.trpAdditionalFields+1]={
                id=RPAL.NewID("field"),
                label="New Field",
                value="",
            }
            RPAL.Refresh()
        end)

        local useDefault=Keep(content,Button(content,"Use Default Information",165,28,false))
        useDefault:SetPoint("LEFT",addField,"RIGHT",8,0)
        useDefault:SetScript("OnClick",function()
            p.useTRPInformation=false
            RPAL.Refresh()
        end)
        y=y-46
    else
        EditorField(content,"Age",5,y,126,function()return p.age end,function(v)p.age=v end)
        EditorField(content,"Birthplace",143,y,126,function()return p.birthplace end,function(v)p.birthplace=v end)
        EditorField(content,"Residence",281,y,126,function()return p.residence end,function(v)p.residence=v end)
        y=y-52
        EditorField(content,"Height",5,y,126,function()return p.height end,function(v)p.height=v end)
        EditorField(content,"Weight",143,y,126,function()return p.weight end,function(v)p.weight=v end)
        EditorField(content,"Hair Color",281,y,126,function()return p.hairColor end,function(v)p.hairColor=v end)
        y=y-52

        MakeLabel(content,"Eye Color",5,y)
        if p.eyeMode=="single" then
            local cb=Keep(content,ColorButton(content,p.eyeColor,function()RPAL.Refresh()end));cb:SetPoint("TOPLEFT",5,y-15)
        elseif p.eyeMode=="heterochromia" then
            local l=Keep(content,ColorButton(content,p.leftEyeColor,function()RPAL.Refresh()end));l:SetPoint("TOPLEFT",5,y-15)
            local r=Keep(content,ColorButton(content,p.rightEyeColor,function()RPAL.Refresh()end));r:SetPoint("LEFT",l,"RIGHT",8,0)
        else
            local blindText=Keep(content,Font(content,"Blind",11,PALE));blindText:SetPoint("TOPLEFT",5,y-23)
        end

        local blind=Keep(content,Check(content,"Blind"));blind:SetSize(90,30);blind:SetPoint("TOPLEFT",88,y-15);blind:SetChecked(p.eyeMode=="blind")
        blind:SetScript("OnClick",function(self)p.eyeMode=self:GetChecked() and "single" or "blind";RPAL.Refresh()end)

        local hetero=Keep(content,Check(content,"Heterochromia"));hetero:SetSize(135,30);hetero:SetPoint("TOPLEFT",185,y-15);hetero:SetChecked(p.eyeMode=="heterochromia")
        hetero:SetScript("OnClick",function(self)p.eyeMode=self:GetChecked() and "single" or "heterochromia";RPAL.Refresh()end)

        if p.worgen and (p.race=="Human" or p.race=="Kaldorei") then
            MakeLabel(content,"Fur Color",330,y)
            local fur=Keep(content,ColorButton(content,p.furColor,function()RPAL.Refresh()end));fur:SetPoint("TOPLEFT",330,y-15)
        end
        y=y-55

        MakeLabel(content,"Relationship Status",5,y)
        local rel=Keep(content,Button(content,p.relationship~="" and p.relationship or "Don't Show",170,30,false));rel:SetPoint("TOPLEFT",5,y-15)
        rel:SetScript("OnClick",function()
            PopupDropdown(content,rel,RPAL.RELATIONSHIPS,p.relationship~="" and p.relationship or "Don't Show",function(v)
                p.relationship=(v=="Don't Show") and "" or v;RPAL.Refresh()
            end)
        end)
        EditorField(content,"Commonly Found",188,y,219,function()return p.commonlyFound end,function(v)p.commonlyFound=v end)
        y=y-70

        if #(p.trpAdditionalFields or {})>0 then
            local useTRP=Keep(content,Button(content,"Use TRP3 Information",150,28,false))
            useTRP:SetPoint("TOPLEFT",5,y)
            useTRP:SetScript("OnClick",function()
                p.useTRPInformation=true
                RPAL.Refresh()
            end)
            y=y-46
        end
    end

    local fh=Heading(content,"FAVOURITE ACHIEVEMENTS",14);fh:SetPoint("TOPLEFT",5,y)
    local fav=Keep(content,Button(content,"Choose up to 3",130,28,false));fav:SetPoint("TOPLEFT",5,y-24)
    fav:SetScript("OnClick",function() RPAL.OpenFavouritePicker() end)
end








local function RenderAchievements()
    local host=UI.achievementContent
    if RPAL.CheckAutomaticAchievements then RPAL.CheckAutomaticAchievements(false,false) end

    GameTooltip:Hide()
    if RPAL.HideCursorTooltip then RPAL.HideCursorTooltip() end
    if UI.tooltip then
        UI.tooltip:Hide()
        UI.tooltip:ClearLines()
    end

    ClearChildren(host)
    UI.categoryFrames={}
    UI.badgeFrames={}
    local completed,total=RPAL.ProgressTotals()
    UI.progressText:SetText(("%d / %d%s"):format(completed,total,total>0 and (" ("..math.floor(completed/total*100+0.5).."%)") or ""))
    UI.addCategory:SetShown(editMode)

    local library=RPAL.GetLibrary()

    -- If a book is empty, make category creation impossible to miss.
    if #library==0 then
        local emptyTitle=Keep(host,Heading(host,RPAL.scope=="account" and "ACCOUNT ACHIEVEMENTS" or "CHARACTER ACHIEVEMENTS",16))
        emptyTitle:SetPoint("TOP",0,-40)

        local emptyText=Keep(host,Font(host,"This achievement book has no categories yet.",11,MUTED))
        emptyText:SetPoint("TOP",emptyTitle,"BOTTOM",0,-12)

        if editMode then
            local addEmpty=Keep(host,Button(host,"+ New Category",150,34,true))
            addEmpty:SetPoint("TOP",emptyText,"BOTTOM",0,-18)
            addEmpty:SetScript("OnClick",function()RPAL.OpenCategoryEditor()end)
        else
            local hint=Keep(host,Font(host,"Press Edit to add categories and achievements.",10,{0.42,0.45,0.48}))
            hint:SetPoint("TOP",emptyText,"BOTTOM",0,-12)
        end

        host:SetHeight(180)
        return
    end

    local y=0
    for catIndex,category in ipairs(library) do
        -- Snapshot loop values for WoW/Lua callbacks. Never let closures depend
        -- on a reused generic-for variable.
        local categoryRef=category
        local categoryID=categoryRef.id
        local categoryName=categoryRef.name or "Category"

        local done,count=RPAL.CategoryTotals(categoryRef)
        local cat=Keep(host,CreateFrame("Frame",nil,host,"BackdropTemplate"));table.insert(UI.categoryFrames,cat)
        cat:SetPoint("TOPLEFT",0,-y);cat:SetPoint("TOPRIGHT",-4,-y)
        local rows=categoryRef.collapsed and 0 or math.max(1,math.ceil((#categoryRef.badges+(editMode and 1 or 0))/7))
        local h=52+rows*98
        cat:SetHeight(h);Backdrop(cat,PANEL2,BORDER)
        PolishPanel(cat,"major")

        local header=Keep(host,CreateFrame("Button",nil,cat,"BackdropTemplate"));header:SetPoint("TOPLEFT");header:SetPoint("TOPRIGHT");header:SetHeight(48)
        Backdrop(header,PANEL,BORDER)
        AddCardTexture(header,0.34)
        AttachGentleFloat(header,2,false)

        local catMark=Keep(host,header:CreateTexture(nil,"ARTWORK"))
        catMark:SetTexture(NATIVE_CREST_ICON)
        catMark:SetSize(19,19)
        catMark:SetPoint("LEFT",11,0)
        catMark:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],0.88)
        catMark._rpalDecorRole="ACCENT"

        local name=Heading(header,categoryName:upper(),15);name:SetPoint("LEFT",38,0)
        local ct=Font(header,done.." / "..count,13,PALE,DEFAULT_FONT);ct:SetPoint("RIGHT",-34,0)
        -- Keep this ASCII-only: several WoW font variants render the old
        -- Unicode chevron as a square fallback glyph.
        local arrow=Font(header,categoryRef.collapsed and ">" or "V",14,GOLD,DEFAULT_FONT)
        arrow:SetWidth(18);arrow:SetJustifyH("CENTER");arrow:SetPoint("RIGHT",-9,0)
        header:SetScript("OnClick",function()
            categoryRef.collapsed=not categoryRef.collapsed
            if RPAL.Refresh then RPAL.Refresh() end
        end)

        if editMode then
            local edit=Button(header,"Edit",42,23,false);edit:SetPoint("RIGHT",ct,"LEFT",-8,0)
            edit:SetScript("OnClick",function() RPAL.OpenCategoryEditor(categoryID) end)
        end

        if not categoryRef.collapsed then
            UI.badgeFrames[categoryID]={}
            local bx,by=18,-62
            for badgeIndex,badge in ipairs(categoryRef.badges) do
                -- Snapshot ALL achievement values used by mouse callbacks.
                local badgeRef=badge
                local badgeID=badgeRef.id
                local badgeName=badgeRef.name or "Unnamed Achievement"
                local badgeDescription=badgeRef.description or ""
                local badgeAdditional=badgeRef.additional or ""
                local badgeIcon=badgeRef.icon or ""
                local slot=Keep(host,CreateFrame("Frame",nil,cat));slot:SetSize(78,92);slot:SetPoint("TOPLEFT",bx,by)
                table.insert(UI.badgeFrames[categoryID],slot)

                local b=Keep(host,CreateFrame("Button",nil,slot,"BackdropTemplate"))
                b:SetSize(62,62)
                b:SetPoint("TOP",0,0)
                b:RegisterForClicks("LeftButtonUp","RightButtonUp")
                Backdrop(b,BUTTON_BG,GOLD)
                PolishPanel(b,"card")
                AttachGentleFloat(b,3,true)

                local icon=Keep(host,b:CreateTexture(nil,"ARTWORK"))
                icon:SetPoint("TOPLEFT",2,-2)
                icon:SetPoint("BOTTOMRIGHT",-2,2)
                icon:SetTexture((badgeIcon~="") and badgeIcon or RPAL.ICONS.question)

                local prog=RPAL.GetProgress(badgeID)
                b:SetAlpha(prog.completed and 1 or .46)
                pcall(function()icon:SetDesaturated(not prog.completed)end)
                if prog.completed then
                    -- IconSet_1 (36): use the pack's brush check as the
                    -- completion corner mark, without a square backing plate.
                    local seal=Keep(host,b:CreateTexture(nil,"OVERLAY",nil,5))
                    seal:SetTexture(RPAL.ICONS.check)
                    seal:SetSize(20,20)
                    seal:SetPoint("BOTTOMRIGHT",-2,2)
                    seal:SetVertexColor(0.72,0.96,0.62,1)
                    seal:SetBlendMode("ADD")
                end

                b:SetScript("OnClick",function(self,button)
                    if RPAL.IsAutomaticAchievement and RPAL.IsAutomaticAchievement(badgeID) then
                        if DEFAULT_CHAT_FRAME and DEFAULT_CHAT_FRAME.AddMessage then
                            DEFAULT_CHAT_FRAME:AddMessage("|cffd9a644ODYN:|r This achievement is tracked automatically.")
                        end
                        return
                    end
                    if button=="LeftButton" then
                        RPAL.SetAchievementCompleted(categoryID,badgeID,true)
                    elseif button=="RightButton" then
                        RPAL.SetAchievementCompleted(categoryID,badgeID,false)
                    end
                end)
                b:SetScript("OnEnter",function(self)
                    local currentProg=RPAL.GetProgress(badgeID)
                    local description=RPAL.ReplaceName(badgeDescription)
                    local autoStatus=RPAL.GetAutomaticAchievementStatus and RPAL.GetAutomaticAchievementStatus(badgeID) or nil
                    local note=(currentProg.note and currentProg.note~="") and currentProg.note or badgeAdditional
                    local statusText
                    if currentProg.completed then
                        statusText="Completed: "..(currentProg.completedAt and date("%Y-%m-%d %H:%M",currentProg.completedAt) or "")
                    elseif autoStatus then
                        statusText=(autoStatus.scope=="account" and "Account-wide automatic  •  " or "Automatic  •  ")..tostring(autoStatus.text or "In progress")
                    else
                        statusText="Not completed"
                    end
                    if autoStatus and (not note or note=="" or note=="Automatic." or note=="Automatic account milestone.") then
                        note=autoStatus.scope=="account" and "Tracked across your ODYN roster. Each character/profile contributes when you log into it; manual completion is disabled." or "Tracked automatically by ODYN. Manual completion is disabled for this milestone."
                    end
                    local noteText=(note and note~="") and RPAL.ReplaceName(note) or ""
                    local rewardText=RPAL.GetAchievementCosmeticRewardText and RPAL.GetAchievementCosmeticRewardText(badgeID) or nil
                    if RPAL.ShowCursorTooltip then RPAL.ShowCursorTooltip({
                        eyebrow=autoStatus and (autoStatus.scope=="account" and "ACCOUNT-WIDE ACHIEVEMENT" or "AUTOMATIC ACHIEVEMENT") or "ACHIEVEMENT",
                        title=RPAL.ReplaceName(badgeName),
                        body=description,
                        sectionTitle=currentProg.completed and "COMPLETED" or "STATUS",
                        section=statusText,
                        detailTitle=rewardText and (currentProg.completed and "COSMETICS UNLOCKED" or "UNLOCKS ON COMPLETION") or (noteText~="" and "NOTE" or nil),
                        detail=rewardText or noteText,
                        footer=(rewardText and noteText~="") and ("NOTE  •  "..noteText) or "",
                        accent=GOLD,width=400,
                    }) end
                end)
                b:SetScript("OnLeave",function()
                    if RPAL.HideCursorTooltip then RPAL.HideCursorTooltip() end
                end)
                local label=Font(slot,RPAL.ReplaceName(badgeName),10,PALE,DEFAULT_FONT);label:SetPoint("TOP",b,"BOTTOM",0,-4);label:SetWidth(78);label:SetJustifyH("CENTER");label:SetWordWrap(true)

                if editMode then
local eb=H.AchievementEditButton(slot)
                    eb:SetPoint("BOTTOMRIGHT",4,-2)
                    eb:SetFrameStrata("DIALOG")
                    eb:SetFrameLevel((UI.main and UI.main:GetFrameLevel() or 100)+200)
                    eb:SetScript("OnMouseDown",function(_,button)
                        if button=="LeftButton" then
                            RPAL.OpenBadgeEditor(categoryID,badgeID)
                        end
                    end)
                end

                bx=bx+92;if bx>625 then bx=18;by=by-98 end
            end
            if editMode then
                local add=Button(cat,"+",62,62,false);add:SetPoint("TOPLEFT",bx,by)
                add.label:SetFont(DEFAULT_FONT,22,"")
                add:SetScript("OnClick",function()RPAL.OpenBadgeEditor(categoryID,nil)end)
            end
        end
        y=y+h+10
    end
    host:SetHeight(math.max(1,y))
end



local function RemoteThemeColor(request)
    local hex=request and request.themeHex or ""
    local name=request and request.themeName or ""
    local validHex=type(hex)=="string" and hex:match("^%x%x%x%x%x%x$")
    local paletteName=(name=="Class Specific" or THEMES[name]) and name or (validHex and "Class Specific" or "Obsidian Crown")
    local palette=RPAL.GetThemePaletteByName(paletteName,validHex and hex or nil)
    return palette.gold,palette
end

function RPAL.SetIconTexture(tex,path)
    if not tex then return end
    if path and path ~= "" then
        tex:SetTexture(path)
        tex:SetTexCoord(0.06,0.94,0.06,0.94)
    elseif ASSET.MOMENTS then
        tex:SetTexture(ASSET.MOMENTS)
        tex:SetTexCoord(0.06,0.94,0.06,0.94)
    end
end

-- Fit a one-line identity string without clipping it.  The fitted base size is
-- registered with the global font-scale system, so changing RPAL's font scale
-- later preserves the fit instead of restoring the original oversized value.
function RPAL.FitFontStringToWidth(fontString,maxWidth,preferredBase,minBase)
    if not fontString or type(fontString.SetFont)~="function" then return end
    maxWidth=math.max(24,tonumber(maxWidth) or 240)
    preferredBase=tonumber(preferredBase) or 20
    minBase=math.max(6,math.min(preferredBase,tonumber(minBase) or 8))

    local fontPath,_,flags=fontString:GetFont()
    fontPath=fontPath or DEFAULT_FONT
    flags=flags or "OUTLINE"
    fontString._rpalFitWidth=maxWidth
    fontString._rpalFitPreferredBase=preferredBase
    fontString._rpalFitMinimumBase=minBase
    fontString:SetWidth(maxWidth)
    fontString:SetWordWrap(false)
    if fontString.SetMaxLines then pcall(fontString.SetMaxLines,fontString,1) end

    local fitted=preferredBase
    local base=preferredBase
    while base>=minBase do
        local scaled=RPAL.GetScaledFontSize and RPAL.GetScaledFontSize(base) or base
        pcall(fontString.SetFont,fontString,fontPath,scaled,flags)
        local width
        if fontString.GetUnboundedStringWidth then
            local ok,value=pcall(fontString.GetUnboundedStringWidth,fontString)
            if ok then width=tonumber(value) end
        end
        if not width and fontString.GetStringWidth then width=tonumber(fontString:GetStringWidth()) end
        fitted=base
        if not width or width<=maxWidth then break end
        base=base-0.5
    end

    if RPAL.RegisterScalableFontObject then
        RPAL.RegisterScalableFontObject(fontString,fontPath,fitted,flags)
    end
end

function RPAL.BuildPublicDetailFields(information,glances)
    information=type(information)=="table" and information or {}
    local fields={}
    local seen={}
    local glanceKeys={}
    for _,glance in pairs(type(glances)=="table" and glances or {}) do
        if type(glance)=="table" then
            local glanceLabel=NormalizeFieldLabel(StripTRPColors(tostring(glance.title or "")))
            local glanceValue=StripTRPColors(tostring(glance.description or "")):gsub("%s+"," "):gsub("^%s+",""):gsub("%s+$",""):lower()
            if glanceLabel~="" and glanceValue~="" then glanceKeys[glanceLabel.."\031"..glanceValue]=true end
        end
    end
    local function Add(label,value,isAdditional)
        label=tostring(label or ""):gsub("^%s+",""):gsub("%s+$","")
        value=tostring(value or ""):gsub("^%s+",""):gsub("%s+$","")
        if label=="" or value=="" then return end
        local normalized=NormalizeFieldLabel(StripTRPColors(label))
        if normalized=="" then normalized=label:lower() end
        if isAdditional then
            local cleanValue=StripTRPColors(value):gsub("%s+"," "):gsub("^%s+",""):gsub("%s+$",""):lower()
            if glanceKeys[normalized.."\031"..cleanValue] then return end
        end
        local key=normalized.."\031"..value
        if seen[key] then return end
        seen[key]=true
        fields[#fields+1]={label=label,value=value}
    end

    Add("Age",information.age)
    Add("Height",information.height)
    Add("Body Shape",information.bodyShape)
    Add("Weight",information.weight)
    Add("Hair Color",information.hairColor)
    Add("Eye Color",information.eyeColor)
    Add("Pronouns",information.pronouns)
    Add("Relationship",information.relationship)
    Add("Residence",information.residence)
    Add("Birthplace",information.birthplace)
    Add("Commonly Found",information.commonlyFound)
    for _,field in ipairs(information.additionalFields or {}) do
        Add(field.label or "Additional Field",field.value,true)
    end
    return fields
end

function RPAL.ParsePublicEyeColors(value)
    local raw=StripTRPColors(tostring(value or ""))
    local colors={}
    local seenHex={}

    -- A real TRP color selector wins over every descriptive word. This also
    -- supports RPAL heterochromia's "#RRGGBB / #RRGGBB" public value.
    for hex in raw:gmatch("#(%x%x%x%x%x%x)") do
        hex=hex:upper()
        if not seenHex[hex] and #colors<2 then
            seenHex[hex]=true
            colors[#colors+1]={
                r=(tonumber(hex:sub(1,2),16) or 255)/255,
                g=(tonumber(hex:sub(3,4),16) or 255)/255,
                b=(tonumber(hex:sub(5,6),16) or 255)/255,
            }
        end
    end
    if #colors>0 then return colors end

    local lower=raw:lower()
    lower=lower:gsub("%s+[aA][nN][dD]%s+","/"):gsub("%s+[vV][eE]%s+","/"):gsub("[,&]","/")
    local named={
        {{"turquoise","turkuaz"},{0.08,0.82,0.78}},
        {{"cyan","aqua"},{0.08,0.88,1.00}},
        {{"teal"},{0.05,0.56,0.58}},
        {{"emerald","zümrüt","zumrut"},{0.08,0.72,0.34}},
        {{"jade"},{0.20,0.70,0.45}},
        {{"lime"},{0.55,0.92,0.16}},
        {{"hazel","ela"},{0.56,0.39,0.16}},
        {{"amber","kehribar","honey","bal rengi"},{0.94,0.55,0.08}},
        {{"gold","golden","altın","altin"},{0.95,0.72,0.16}},
        {{"copper","bakır","bakir"},{0.72,0.30,0.12}},
        {{"silver","gümüş","gumus"},{0.72,0.76,0.82}},
        {{"ice blue","icy blue","buz mavisi"},{0.58,0.90,1.00}},
        {{"sapphire","safir"},{0.08,0.32,0.88}},
        {{"cobalt","kobalt"},{0.08,0.22,0.82}},
        {{"navy","lacivert"},{0.04,0.10,0.34}},
        {{"blue","mavi"},{0.18,0.48,0.94}},
        {{"green","yeşil","yesil"},{0.16,0.68,0.30}},
        {{"brown","kahverengi"},{0.40,0.20,0.08}},
        {{"crimson","scarlet","blood red"},{0.78,0.035,0.055}},
        {{"red","kırmızı","kirmizi"},{0.90,0.10,0.10}},
        {{"orange","turuncu"},{1.00,0.36,0.06}},
        {{"yellow","sarı","sari"},{0.96,0.84,0.12}},
        {{"lavender","lilac","leylak"},{0.68,0.48,0.94}},
        {{"violet","purple","mor"},{0.58,0.18,0.86}},
        {{"pink","pembe"},{1.00,0.38,0.66}},
        {{"grey","gray","gri"},{0.48,0.52,0.58}},
        {{"black","siyah"},{0.035,0.040,0.050}},
        {{"white","beyaz"},{0.92,0.94,0.98}},
    }

    local function ResolvePart(part)
        for _,definition in ipairs(named) do
            for _,word in ipairs(definition[1]) do
                if part:find(word,1,true) then
                    local c=definition[2]
                    local r,g,b=c[1],c[2],c[3]
                    if part:find("light",1,true) or part:find("pale",1,true) or part:find("açık",1,true) or part:find("acik",1,true) then
                        r=r+((1-r)*0.30);g=g+((1-g)*0.30);b=b+((1-b)*0.30)
                    elseif part:find("dark",1,true) or part:find("deep",1,true) or part:find("koyu",1,true) then
                        r=r*0.55;g=g*0.55;b=b*0.55
                    end
                    return {r=r,g=g,b=b}
                end
            end
        end
    end

    for part in lower:gmatch("[^/]+") do
        local color=ResolvePart(part)
        if color and #colors<2 then colors[#colors+1]=color end
    end
    return colors
end

function RPAL.EnsurePublicDetailPanel(panel)
    if not panel or panel._rpalDetailScroll then return end

    local premadeFrame=panel:CreateTexture(nil,"ARTWORK",nil,-2)
    premadeFrame:SetTexture(RPAL.PremadeDetailFrame)
    premadeFrame:SetPoint("TOPLEFT",1,-1)
    premadeFrame:SetPoint("BOTTOMRIGHT",-1,1)
    premadeFrame:SetBlendMode("BLEND")
    panel._rpalPremadeFrame=premadeFrame

    local title=Font(panel,"CHARACTER DETAILS",11,GOLD,DEFAULT_FONT)
    title:SetPoint("TOPLEFT",12,-13)
    panel._rpalDetailTitle=title

    local rule=panel:CreateTexture(nil,"ARTWORK")
    rule:SetTexture("Interface\\Buttons\\WHITE8X8")
    rule:SetHeight(1);rule:SetPoint("TOPLEFT",12,-35);rule:SetPoint("TOPRIGHT",-12,-35)
    panel._rpalDetailRule=rule

    local statusHolder=CreateFrame("Frame",nil,panel)
    statusHolder:SetPoint("TOPLEFT",10,-42)
    statusHolder:SetPoint("TOPRIGHT",-24,-42)
    statusHolder:SetHeight(1)
    statusHolder:Hide()
    panel._rpalPublicStatusHolder=statusHolder

    local scroll=CreateFrame("ScrollFrame",nil,panel,"UIPanelScrollFrameTemplate")
    if RPAL.SkinScrollFrame then RPAL.SkinScrollFrame(scroll) end
    scroll:SetPoint("TOPLEFT",10,-43)
    scroll:SetPoint("BOTTOMRIGHT",-25,10)
    scroll:EnableMouseWheel(true)
    scroll:SetScript("OnMouseWheel",function(self,delta)
        local range=self.GetVerticalScrollRange and self:GetVerticalScrollRange() or 0
        self:SetVerticalScroll(math.max(0,math.min(range,(self:GetVerticalScroll() or 0)-(delta*34))))
    end)

    local content=CreateFrame("Frame",nil,scroll)
    content:SetSize(150,1)
    scroll:SetScrollChild(content)
    panel._rpalDetailScroll=scroll
    panel._rpalDetailContent=content
end

function RPAL.RenderPublicStatusBadges(panel,flags)
    local holder=panel and panel._rpalPublicStatusHolder
    if not holder then return 0 end
    ClearChildren(holder)

    flags=type(flags)=="table" and flags or {}
    local definitions={
        {key="hungry",label="HUNGRY",color={1.00,0.55,0.16}},
        {key="dehydrated",label="DEHYDRATED",color={0.30,0.72,1.00}},
        {key="injured",label="INJURED",color={1.00,0.28,0.30}},
        {key="healing",label="HEALING",color={0.32,0.90,0.52}},
    }
    local active={}
    for _,definition in ipairs(definitions) do
        if flags[definition.key] then active[#active+1]=definition end
    end
    if #active==0 then holder:Hide();holder:SetHeight(1);return 0 end

    local availableWidth=math.max(120,(tonumber(panel:GetWidth()) or 194)-34)
    local gap=5
    local badgeWidth=math.floor((availableWidth-gap)/2)
    for index,definition in ipairs(active) do
        local col=(index-1)%2
        local row=math.floor((index-1)/2)
        local c=definition.color
        local badge=CreateFrame("Frame",nil,holder,"BackdropTemplate")
        badge:SetSize(badgeWidth,19)
        badge:SetPoint("TOPLEFT",col*(badgeWidth+gap),-row*23)
        badge:SetBackdrop({
            bgFile="Interface\\Buttons\\WHITE8X8",
            edgeFile="Interface\\Tooltips\\UI-Tooltip-Border",
            tile=false,tileSize=8,edgeSize=8,
            insets={left=2,right=2,top=2,bottom=2},
        })
        badge:SetBackdropColor(c[1]*0.12,c[2]*0.12,c[3]*0.12,0.96)
        badge:SetBackdropBorderColor(c[1],c[2],c[3],0.78)
        local text=Font(badge,definition.label,7.2,c,DEFAULT_FONT)
        text:SetPoint("CENTER",0,0)
    end
    local height=(math.ceil(#active/2)*23)-4
    holder:SetHeight(height)
    holder:Show()
    return height
end

function RPAL.RenderPublicDetailPanel(panel,information,accent,loading,glances,statusFlags)
    if not panel then return end
    RPAL.EnsurePublicDetailPanel(panel)
    accent=accent or GOLD

    if panel._rpalDetailTitle then panel._rpalDetailTitle:SetTextColor(accent[1],accent[2],accent[3],1) end
    if panel._rpalDetailRule then panel._rpalDetailRule:SetVertexColor(accent[1],accent[2],accent[3],0.34) end
    if panel._rpalPremadeFrame then panel._rpalPremadeFrame:SetVertexColor(accent[1],accent[2],accent[3],0.58) end
    if panel.SetBackdropBorderColor then panel:SetBackdropBorderColor(accent[1],accent[2],accent[3],0.28) end

    local content=panel._rpalDetailContent
    local scroll=panel._rpalDetailScroll
    if not content or not scroll then return end
    local statusHeight=RPAL.RenderPublicStatusBadges(panel,statusFlags)
    scroll:ClearAllPoints()
    scroll:SetPoint("TOPLEFT",10,-43-statusHeight-(statusHeight>0 and 5 or 0))
    scroll:SetPoint("BOTTOMRIGHT",-25,10)
    ClearChildren(content)

    local contentWidth=math.max(118,(tonumber(panel:GetWidth()) or 194)-43)
    content:SetWidth(contentWidth)
    local fields=RPAL.BuildPublicDetailFields(information,glances)
    local y=0
    for _,field in ipairs(fields) do
        local label=Keep(content,Font(content,string.upper(field.label),8.5,MUTED,DEFAULT_FONT))
        label:SetPoint("TOPLEFT",1,y)

        local normalizedLabel=NormalizeFieldLabel(StripTRPColors(field.label))
        local eyeColors=(normalizedLabel=="eye color" or normalizedLabel=="eyecolor" or normalizedLabel=="eye colour") and RPAL.ParsePublicEyeColors(field.value) or {}
        if #eyeColors>0 then
            local swatchWidth=#eyeColors>1 and 38 or 54
            for index,color in ipairs(eyeColors) do
                local swatch=Keep(content,CreateFrame("Frame",nil,content,"BackdropTemplate"))
                swatch:SetSize(swatchWidth,22)
                swatch:SetPoint("TOPLEFT",1+((index-1)*(swatchWidth+7)),y-14)
                swatch:SetBackdrop({
                    bgFile="Interface\\Buttons\\WHITE8X8",
                    edgeFile="Interface\\Tooltips\\UI-Tooltip-Border",
                    tile=false,tileSize=8,edgeSize=9,
                    insets={left=2,right=2,top=2,bottom=2},
                })
                swatch:SetBackdropColor(color.r,color.g,color.b,1)
                swatch:SetBackdropBorderColor(0.82,0.84,0.88,0.84)
                local shine=swatch:CreateTexture(nil,"ARTWORK")
                shine:SetTexture("Interface\\Buttons\\WHITE8X8")
                shine:SetPoint("TOPLEFT",3,-3);shine:SetPoint("TOPRIGHT",-3,-3);shine:SetHeight(1)
                shine:SetVertexColor(1,1,1,0.34)
            end
            y=y-(14+22+13)
        else
            local value=Keep(content,Font(content,field.value,10.5,PALE,DEFAULT_FONT))
            value:SetPoint("TOPLEFT",1,y-14)
            value:SetWidth(contentWidth-4)
            value:SetJustifyH("LEFT")
            value:SetWordWrap(true)
            local measured=tonumber(value.GetStringHeight and value:GetStringHeight()) or 0
            if measured<=0 then
                local approxPerLine=math.max(8,math.floor((contentWidth-4)/6))
                measured=math.max(14,math.ceil(#field.value/approxPerLine)*14)
            end
            y=y-(14+math.max(14,measured)+13)
        end
    end

    if #fields==0 then
        local message=loading and "Receiving character details..." or "No public character details shared."
        local empty=Keep(content,Font(content,message,9.5,MUTED,DEFAULT_FONT))
        empty:SetPoint("TOPLEFT",1,-4)
        empty:SetWidth(contentWidth-4)
        empty:SetWordWrap(true)
        y=-48
    end
    content:SetHeight(math.max(scroll:GetHeight() or 1,-y+6))
end

function RPAL.CreatePublicGlanceStrip(parent,size,gap)
    if not parent then return nil end
    size=math.max(24,tonumber(size) or 32)
    gap=math.max(0,tonumber(gap) or 5)
    local holder=CreateFrame("Frame",nil,parent)
    holder:SetSize((size*5)+(gap*4),size)
    holder._rpalGlanceCards={}

    for slot=1,5 do
        local card=CreateFrame("Button",nil,holder,"BackdropTemplate")
        card:SetSize(size,size)
        card:SetPoint("TOPLEFT",(slot-1)*(size+gap),0)
        Backdrop(card,PANEL2,BORDER)
        local iconInset=math.max(3,math.floor(size*0.12))
        local icon=card:CreateTexture(nil,"ARTWORK")
        icon:SetPoint("TOPLEFT",iconInset,-iconInset);icon:SetPoint("BOTTOMRIGHT",-iconInset,iconInset)
        icon:SetTexCoord(.04,.96,.04,.96)
        card.icon=icon
        local wash=card:CreateTexture(nil,"OVERLAY")
        wash:SetPoint("BOTTOMLEFT",iconInset,iconInset);wash:SetPoint("BOTTOMRIGHT",-iconInset,iconInset);wash:SetHeight(1)
        card.wash=wash
        local frameArt=card:CreateTexture(nil,"OVERLAY",nil,2)
        frameArt:SetTexture((RPAL.PremadeGlanceFrames or {})[slot])
        frameArt:SetPoint("TOPLEFT",-2,2);frameArt:SetPoint("BOTTOMRIGHT",2,-2)
        frameArt:SetBlendMode("BLEND")
        card.premadeFrame=frameArt
        card:SetScript("OnEnter",function(self)
            local glance=holder._rpalGlances and holder._rpalGlances[slot]
            if glance and ((glance.title or "")~="" or (glance.description or "")~="") and RPAL.ShowCursorTooltip then
                RPAL.ShowCursorTooltip({
                    eyebrow="AT FIRST GLANCE",
                    title=(glance.title and glance.title~="") and glance.title or "At First Glance",
                    body=glance.description or "",
                    accent=holder._rpalAccent or GOLD,
                    width=360,
                })
            end
            local c=holder._rpalAccent or GOLD
            self:SetBackdropBorderColor(c[1],c[2],c[3],1)
            if self.premadeFrame then self.premadeFrame:SetAlpha(1) end
        end)
        card:SetScript("OnLeave",function(self)
            if RPAL.HideCursorTooltip then RPAL.HideCursorTooltip() end
            local c=holder._rpalAccent or GOLD
            self:SetBackdropBorderColor(c[1],c[2],c[3],0.58)
            if self.premadeFrame then self.premadeFrame:SetAlpha(self._rpalHasGlance and 0.78 or 0.38) end
        end)
        holder._rpalGlanceCards[slot]=card
    end
    return holder
end

function RPAL.UpdatePublicGlanceStrip(holder,glances,accent)
    if not holder then return end
    glances=type(glances)=="table" and glances or {}
    accent=accent or GOLD
    holder._rpalGlances=glances
    holder._rpalAccent=accent
    for slot,card in ipairs(holder._rpalGlanceCards or {}) do
        local glance=glances[slot]
        local has=type(glance)=="table" and ((glance.title or "")~="" or (glance.description or "")~="" or (glance.icon or "")~="")
        card._rpalHasGlance=has
        card:SetBackdropColor(PANEL2[1],PANEL2[2],PANEL2[3],has and 0.96 or 0.62)
        card:SetBackdropBorderColor(accent[1],accent[2],accent[3],has and 0.26 or 0.12)
        card.wash:SetColorTexture(accent[1],accent[2],accent[3],has and 0.46 or 0.13)
        if card.premadeFrame then
            card.premadeFrame:SetVertexColor(accent[1],accent[2],accent[3],1)
            card.premadeFrame:SetAlpha(has and 0.78 or 0.38)
        end
        if has and glance.icon and glance.icon~="" then
            local texture=RPAL.ResolveGlanceIcon and RPAL.ResolveGlanceIcon(glance.icon) or tonumber(glance.icon) or glance.icon
            card.icon:SetTexture(texture)
            card.icon:SetDesaturated(false)
            card.icon:SetVertexColor(1,1,1,1)
            card.icon:SetAlpha(1)
        else
            card.icon:SetTexture(RPAL.ICONS.question)
            card.icon:SetDesaturated(true)
            card.icon:SetVertexColor(MUTED[1],MUTED[2],MUTED[3],1)
            card.icon:SetAlpha(0.20)
        end
    end
end

function RPAL.OrderedArray(value,limit)
    local out={}
    if type(value)~="table" then return out end
    local indexed={}
    for k,v in pairs(value) do
        local n=tonumber(k)
        if n and v~=nil then indexed[#indexed+1]={n=n,v=v} end
    end
    table.sort(indexed,function(a,b)return a.n<b.n end)
    for i,item in ipairs(indexed) do
        if not limit or i<=limit then out[#out+1]=item.v end
    end
    return out
end

RPAL.FormatDuration = function(sec)
    sec=math.max(0,math.floor(tonumber(sec) or 0))
    if sec < 60 then return string.format("%ds",sec) end
    local mins=math.floor(sec/60)
    local secs=sec%60
    if mins < 60 then return string.format("%dm %02ds",mins,secs) end
    local hours=math.floor(mins/60)
    mins=mins%60
    return string.format("%dh %02dm",hours,mins)
end

RPAL.SumMap = function(map)
    local total=0
    if type(map)~="table" then return 0 end
    for _,v in pairs(map) do
        if type(v)=="number" then total=total+v end
    end
    return total
end

local function OpenMomentDetail(moment,accent)
    if not moment then return end
    -- Saved Session previews must use the exact same active palette as the
    -- editable Session Recap.  Never inherit a remote/card accent here.
    ApplyCurrentTheme()
    local themeAccent=GOLD

    local shade,m=Modal("SAVED SESSION  •  "..tostring(moment.title or "A Saved Session"),960,760)
    if m.SetBackdropBorderColor then m:SetBackdropBorderColor(themeAccent[1],themeAccent[2],themeAccent[3],0.92) end

    local revealIndex=0
    local function Reveal(frame,delay,duration)
        revealIndex=revealIndex+1
        frame:SetAlpha(0);frame:SetScale(0.985)
        C_Timer.After(delay or ((revealIndex-1)*0.045),function()
            if not frame or not frame:IsShown() then return end
            duration=duration or 0.12
            local steps=4
            local stepDelay=duration/steps
            for step=1,steps do
                C_Timer.After(stepDelay*step,function()
                    if not frame or not frame:IsShown() then return end
                    local k=math.min(1,step/steps)
                    local eased=1-((1-k)*(1-k))
                    frame:SetAlpha(eased)
                    frame:SetScale(0.985+(0.015*eased))
                    if step==steps then frame:SetScale(1) end
                end)
            end
        end)
    end

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

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

    local titleKicker=Font(m,"CHRONICLE  •  CURATED MEMORY",8,themeAccent,DEFAULT_FONT)
    titleKicker:SetPoint("TOPLEFT",leftX,-48);Reveal(titleKicker,0.02)
    local titlePreview=Font(m,moment.title or "A Saved Session",20,PALE,DEFAULT_FONT)
    titlePreview:SetPoint("TOPLEFT",leftX,-66);titlePreview:SetWidth(leftW);titlePreview:SetWordWrap(false);Reveal(titlePreview,0.06)
    local where=Font(m,(moment.zone or "Unknown Place").."  •  "..(moment.at and date("%d %b %Y  %H:%M",moment.at) or ""),9.5,MUTED,DEFAULT_FONT)
    where:SetPoint("TOPLEFT",leftX,-92);Reveal(where,0.10)

    -- The same four headline stats as the editable recap.
    local details=RPAL.OrderedArray(moment.peopleDetails,24)
    if #details==0 then
        for _,name in ipairs(RPAL.OrderedArray(moment.people,24)) do
            details[#details+1]={name=tostring(name),met=false,presence=true,trpName="",activeRP=false}
        end
    end
    local activeDetails,otherDetails={},{}
    for _,person in ipairs(details) do
        person.activeRP=person.activeRP==true or person.met==true or (tonumber(person.interactions) or 0)>0
    end
    table.sort(details,function(a,b)
        if a.activeRP~=b.activeRP then return a.activeRP end
        if (a.met==true)~=(b.met==true) then return a.met==true end
        local ai=tonumber(a.interactions) or 0
        local bi=tonumber(b.interactions) or 0
        if ai~=bi then return ai>bi end
        return tostring(a.name or ""):lower()<tostring(b.name or ""):lower()
    end)
    for _,person in ipairs(details) do
        if person.activeRP then activeDetails[#activeDetails+1]=person else otherDetails[#otherDetails+1]=person end
    end

    local locations=RPAL.OrderedArray(moment.locations,24)
    local interactions=tonumber(moment.interactions) or 0
    if interactions==0 and type(moment.interactionsByType)=="table" then interactions=RPAL.SumMap(moment.interactionsByType) end
    if interactions==0 and type(moment.interactionsMap)=="table" then interactions=RPAL.SumMap(moment.interactionsMap) end
    if interactions==0 then
        for _,person in ipairs(details) do interactions=interactions+(tonumber(person.interactions) or 0) end
    end
    local duration=tonumber(moment.duration) or 0
    if duration==0 and moment.startedAt and moment.endedAt then duration=math.max(0,(tonumber(moment.endedAt) or 0)-(tonumber(moment.startedAt) or 0)) end
    local renown=math.floor(tonumber(moment.renownGained) or 0)
    local stats={{"TIME",RPAL.FormatDuration(duration)},{"CHARACTERS",tostring(#details)},{"INTERACTIONS",tostring(interactions)},{"RENOWN","+"..tostring(renown)}}
    for i,item in ipairs(stats) do
        local x=leftX+((i-1)*158)
        local card=Card(m,148,56);card:SetPoint("TOPLEFT",x,-112)
        local lab=Font(card,item[1],7.5,MUTED,DEFAULT_FONT);lab:SetPoint("TOPLEFT",11,-9)
        local val=Font(card,item[2],15,themeAccent,DEFAULT_FONT);val:SetPoint("BOTTOMLEFT",11,13)
        Reveal(card,0.14+((i-1)*0.035))
    end

    local l1=Font(m,"SESSION NAME",8,MUTED,DEFAULT_FONT);l1:SetPoint("TOPLEFT",leftX,-180);Reveal(l1,0.30)
    local titleCard=Card(m,leftW,36);titleCard:SetPoint("TOPLEFT",leftX,-198);Reveal(titleCard,0.33)
    local title=Font(titleCard,moment.title or "A Saved Session",13,PALE,DEFAULT_FONT)
    title:SetPoint("LEFT",11,0);title:SetPoint("RIGHT",-11,0);title:SetWordWrap(false)

    local l2=Font(m,"SUMMARY",8,MUTED,DEFAULT_FONT);l2:SetPoint("TOPLEFT",leftX,-244);Reveal(l2,0.37)
    local bodyPanel=Card(m,leftW,78);bodyPanel:SetPoint("TOPLEFT",leftX,-262);Reveal(bodyPanel,0.40)
    local body=Font(bodyPanel,(moment.text and moment.text~="") and moment.text or "No note was written for this Saved Session.",11.5,PALE,DEFAULT_FONT)
    body:SetPoint("TOPLEFT",11,-10);body:SetPoint("TOPRIGHT",-11,-10);body:SetWordWrap(true);body:SetJustifyH("LEFT")

    local peopleHeader=Font(m,"PEOPLE IN THIS SESSION",9,themeAccent,DEFAULT_FONT);peopleHeader:SetPoint("TOPLEFT",leftX,-356);Reveal(peopleHeader,0.46)
    local peopleSub=Font(m,tostring(#activeDetails).." ACTIVE RP  •  "..tostring(#details).." TOTAL PRESENT",7.5,MUTED,DEFAULT_FONT);peopleSub:SetPoint("TOPRIGHT",leftX+leftW,-356);peopleSub:SetJustifyH("RIGHT");Reveal(peopleSub,0.49)

    local peoplePanel=CreateFrame("Frame",nil,m,"BackdropTemplate");peoplePanel:SetSize(leftW,252);peoplePanel:SetPoint("TOPLEFT",leftX,-370);Backdrop(peoplePanel,PANEL,BORDER);Reveal(peoplePanel,0.52)
    local peopleScroll=CreateFrame("ScrollFrame",nil,peoplePanel,"UIPanelScrollFrameTemplate");peopleScroll:SetPoint("TOPLEFT",8,-8);peopleScroll:SetPoint("BOTTOMRIGHT",-24,8)
    if RPAL.SkinScrollFrame then RPAL.SkinScrollFrame(peopleScroll) end
    local peopleContent=CreateFrame("Frame",nil,peopleScroll);peopleContent:SetWidth(leftW-40);peopleContent:SetHeight(1);peopleScroll:SetScrollChild(peopleContent)
    local peopleY=0
    local renderedPeople=0
    local function RenderPeopleSection(label,hint,list,isActive)
        if #list==0 and not isActive then return end
        local section=Font(peopleContent,label.."  •  "..tostring(#list),7.5,isActive and themeAccent or MUTED,DEFAULT_FONT)
        section:SetPoint("TOPLEFT",2,-peopleY);section:SetWidth(190)
        local sectionHint=Font(peopleContent,hint,6.5,MUTED,DEFAULT_FONT);sectionHint:SetPoint("TOPRIGHT",-2,-peopleY);sectionHint:SetJustifyH("RIGHT")
        peopleY=peopleY+20
        local limit=math.min(#list,24-renderedPeople)
        for i=1,limit do
            local person=list[i];local col=(i-1)%2;local row=math.floor((i-1)/2)
            local card=CreateFrame("Frame",nil,peopleContent,"BackdropTemplate");card:SetSize(290,46);card:SetPoint("TOPLEFT",col*298,-peopleY-(row*54));Backdrop(card,PANEL2,isActive and themeAccent or BORDER)
            local icon=card:CreateTexture(nil,"ARTWORK");RPAL.SetIconTexture(icon,ASSET.PERSON or ASSET.MOMENTS);icon:SetSize(23,23);icon:SetPoint("LEFT",8,0);icon:SetVertexColor((isActive and themeAccent or MUTED)[1],(isActive and themeAccent or MUTED)[2],(isActive and themeAccent or MUTED)[3],isActive and 0.96 or 0.70)
            local displayName=Font(card,person.name or "Unknown",10,PALE,DEFAULT_FONT);displayName:SetPoint("TOPLEFT",39,-7);displayName:SetWidth(172);displayName:SetWordWrap(false)
            local pc=tonumber(person.interactions) or 0
            local subText=isActive and ((pc>0 and (tostring(pc).." tracked interaction"..(pc==1 and "" or "s"))) or (person.met and "Character met during roleplay" or "Direct roleplay signal")) or "Present nearby • no direct RP signal"
            local sub=Font(card,subText,7.2,isActive and themeAccent or MUTED,DEFAULT_FONT);sub:SetPoint("TOPLEFT",39,-25);sub:SetWidth(205);sub:SetWordWrap(false)
            local tag=Font(card,isActive and "ACTIVE RP" or "PRESENT",7,isActive and GREEN or MUTED,DEFAULT_FONT);tag:SetPoint("TOPRIGHT",-8,-8)
            Reveal(card,0.55+((renderedPeople+i-1)*0.025))
        end
        renderedPeople=renderedPeople+limit
        peopleY=peopleY+(math.max(1,math.ceil(limit/2))*54)+8
    end
    RenderPeopleSection("ACTIVE ROLEPLAYERS","direct speech, emotes or interactions",activeDetails,true)
    RenderPeopleSection("OTHER PEOPLE PRESENT","nearby context, kept separately",otherDetails,false)
    peopleContent:SetHeight(math.max(1,peopleY))
    shade._rpalActiveParticipantCount=#activeDetails
    shade._rpalPresentParticipantCount=#details
    if #details>24 then
        local more=Font(peoplePanel,"+"..tostring(#details-24).." more recorded characters",7,MUTED,DEFAULT_FONT);more:SetPoint("BOTTOMRIGHT",-28,7);Reveal(more,1.10)
    elseif #details==0 then
        local empty=Font(peoplePanel,"No characters were recorded during this Session.",9,MUTED,DEFAULT_FONT);empty:SetPoint("CENTER");Reveal(empty,0.56)
    end

    local routePanel=CreateFrame("Frame",nil,m,"BackdropTemplate");routePanel:SetSize(rightW,638);routePanel:SetPoint("TOPLEFT",rightX,-48);Backdrop(routePanel,PANEL,BORDER);Reveal(routePanel,0.18)
    local routeTitle=Font(routePanel,"SESSION ROUTE",10,themeAccent,DEFAULT_FONT);routeTitle:SetPoint("TOPLEFT",14,-12)
    local routeHint=Font(routePanel,#locations>0 and "RECORDED IN ORDER  •  TIMELINE" or "NO ROUTE INFORMATION RECORDED",7.5,MUTED,DEFAULT_FONT);routeHint:SetPoint("TOPLEFT",14,-31);routeHint:SetWidth(rightW-28);routeHint:SetWordWrap(false)
    local routeScroll=CreateFrame("ScrollFrame",nil,routePanel,"UIPanelScrollFrameTemplate");routeScroll:SetPoint("TOPLEFT",10,-52);routeScroll:SetPoint("BOTTOMRIGHT",-24,12)
    if RPAL.SkinScrollFrame then RPAL.SkinScrollFrame(routeScroll) end
    local routeContent=CreateFrame("Frame",nil,routeScroll);routeContent:SetWidth(rightW-44);routeContent:SetHeight(math.max(1,#locations*62));routeScroll:SetScrollChild(routeContent)
    if #locations>0 then
        local line=routeContent:CreateTexture(nil,"ARTWORK");line:SetTexture("Interface\\Buttons\\WHITE8X8");line:SetWidth(2);line:SetPoint("TOPLEFT",18,-16);line:SetPoint("BOTTOMLEFT",18,18);line:SetVertexColor(themeAccent[1],themeAccent[2],themeAccent[3],0.34)
        for i,loc in ipairs(locations) do
            local node=CreateFrame("Frame",nil,routeContent,"BackdropTemplate");node:SetSize(rightW-58,52);node:SetPoint("TOPLEFT",30,-((i-1)*62));Backdrop(node,PANEL2,BORDER)
            local dot=node:CreateTexture(nil,"ARTWORK");dot:SetTexture(SPARK);dot:SetBlendMode("ADD");dot:SetSize(20,20);dot:SetPoint("LEFT",-21,0);dot:SetVertexColor(themeAccent[1],themeAccent[2],themeAccent[3],0.78)
            local n=Font(node,string.format("%02d",i),7.5,themeAccent,DEFAULT_FONT);n:SetPoint("LEFT",-29,0)
            local label=tostring((loc.customName and loc.customName~="") and loc.customName or loc.label or loc.subZone or loc.zone or "Unknown Place")
            local t=Font(node,label,9.5,PALE,DEFAULT_FONT);t:SetPoint("TOPLEFT",11,-8);t:SetWidth(rightW-92);t:SetWordWrap(false)
            local times=(loc.firstAt and date("%H:%M",loc.firstAt) or "")..((loc.lastAt and loc.firstAt and loc.lastAt~=loc.firstAt) and ("  •  "..date("%H:%M",loc.lastAt)) or "")
            local tm=Font(node,times,7,MUTED,DEFAULT_FONT);tm:SetPoint("TOPLEFT",11,-29)
            Reveal(node,0.22+((i-1)*0.035))
        end
    else
        local empty=Font(routeContent,"This Saved Session has no recorded route.",9,MUTED,DEFAULT_FONT);empty:SetPoint("TOPLEFT",10,-18);empty:SetWidth(rightW-64);empty:SetWordWrap(true);Reveal(empty,0.25)
    end

    local newFaces=0
    for _,person in ipairs(details) do if person.met then newFaces=newFaces+1 end end
    local events=Font(m,tostring(newFaces).." met characters  •  "..tostring(#locations).." recorded stops",8,MUTED,DEFAULT_FONT);events:SetPoint("TOPLEFT",leftX,-640);Reveal(events,1.08)
    local footer=Font(m,"CURATED MEMORY  •  READ ONLY  •  ODYN",8.5,MUTED,DEFAULT_FONT);footer:SetPoint("BOTTOMLEFT",18,22);Reveal(footer,0.75)
    local close=Button(m,"Close",84,30,false);close:SetPoint("BOTTOMRIGHT",-18,18);close:SetScript("OnClick",function()shade:Hide()end);Reveal(close,0.78)
end
RPAL.OpenMomentDetail=OpenMomentDetail

function RPAL.OpenMemoirDetail(definition,accent)
    if not definition then return end
    if activeMemoirDetailShade and activeMemoirDetailShade:IsShown() then
        activeMemoirDetailShade:Raise()
        return activeMemoirDetailShade,activeMemoirDetailShade.modal
    end
    accent=accent or GOLD
    local shade,m=Modal("MEMOIR — "..tostring(definition.title or "Renown Atlas Memory"),640,420)
    activeMemoirDetailShade=shade
    shade:HookScript("OnHide",function(self)
        if activeMemoirDetailShade==self then activeMemoirDetailShade=nil end
    end)
    if m.SetBackdropBorderColor then m:SetBackdropBorderColor(accent[1],accent[2],accent[3],1) end
    local branch=RPAL.TALENT_BRANCHES and RPAL.TALENT_BRANCHES[definition.branch]
    local branchName=(branch and branch.name) or "Renown Atlas"
    local eyebrow=Font(m,string.upper(branchName).."  •  PUBLIC LEGACY",8,accent,DEFAULT_FONT)
    eyebrow:SetPoint("TOPLEFT",20,-56)
    local quote=Font(m,"“"..tostring(definition.quote or definition.subtitle or "A memory carried forward.").."”",16,PALE,DEFAULT_FONT)
    quote:SetPoint("TOPLEFT",20,-84);quote:SetPoint("TOPRIGHT",-20,-84);quote:SetWordWrap(true);quote:SetJustifyH("LEFT")
    local rule=m:CreateTexture(nil,"ARTWORK");rule:SetTexture("Interface\\Buttons\\WHITE8X8")
    rule:SetHeight(1);rule:SetPoint("TOPLEFT",20,-176);rule:SetPoint("TOPRIGHT",-20,-176);rule:SetVertexColor(accent[1],accent[2],accent[3],0.34)
    local legacy=Font(m,"RENOWN ATLAS MEMORY",8,MUTED,DEFAULT_FONT);legacy:SetPoint("TOPLEFT",20,-199)
    local body=Font(m,tostring(definition.description or definition.body or "This memoir marks a character-specific milestone unlocked at the far edge of the Renown Atlas."),10.5,PALE,DEFAULT_FONT)
    body:SetPoint("TOPLEFT",20,-222);body:SetPoint("TOPRIGHT",-20,-222);body:SetWordWrap(true);body:SetJustifyH("LEFT")
    local footer=Font(m,"This public memory is shared through ODYN inspect and does not expose private session logs.",9,MUTED,DEFAULT_FONT)
    footer:SetPoint("BOTTOMLEFT",20,24);footer:SetPoint("RIGHT",-20,0);footer:SetWordWrap(true)
    return shade,m
end

local function RenderPinnedMomentsDock(parent,entries,accent,titleText,emptyText)
    if not parent then return end
    -- The caller owns the palette.  Remote inspect enters the inspected
    -- character's scoped palette before drawing this shelf; self-preview uses
    -- the local palette naturally.  Never mutate global colours from here.
    accent=accent or GOLD
    titleText=titleText or "PINNED SESSIONS"
    emptyText=emptyText or "No pinned sessions selected."

    entries=entries or {}
    local list={}
    if #entries>0 then
        for i=1,math.min(3,#entries) do list[#list+1]=entries[i] end
    else
        -- Remote inspect packets can arrive out of order, leaving a sparse
        -- table temporarily.  ipairs/# would then incorrectly render nothing.
        local indexed={}
        for key,item in pairs(entries) do
            local index=tonumber(key)
            if index and item then indexed[#indexed+1]={index=index,item=item} end
        end
        table.sort(indexed,function(a,b)return a.index<b.index end)
        for i=1,math.min(3,#indexed) do list[#list+1]=indexed[i].item end
    end

    -- Remote inspect refreshes once per incoming packet.  Do not rebuild an
    -- unchanged dock: this prevents flicker and keeps hover state stable.
    local signature={
        titleText,emptyText,
        string.format("%.3f:%.3f:%.3f",accent[1] or 0,accent[2] or 0,accent[3] or 0),
        string.format("%.3f:%.3f:%.3f",PANEL2[1] or 0,PANEL2[2] or 0,PANEL2[3] or 0),
    }
    for _,item in ipairs(list) do
        local routeSig={}
        for _,loc in ipairs(item.locations or {}) do
            routeSig[#routeSig+1]=tostring(loc.customName or loc.label or loc.zone or "")
        end
        local peopleSig={}
        for _,person in ipairs(item.peopleDetails or {}) do
            peopleSig[#peopleSig+1]=tostring(person.name or "")..":"..tostring(person.trpName or "")..":"..(person.met and "1" or "0")..":"..(person.activeRP and "1" or "0")
        end
        signature[#signature+1]=table.concat({
            tostring(item.title or ""),tostring(item.text or ""),
            tostring(item.zone or ""),tostring(item.at or 0),
            tostring(item.duration or 0),tostring(item.interactions or 0),tostring(item.renownGained or 0),
            table.concat(routeSig,"|"),table.concat(peopleSig,"|")
        },"\031")
    end
    local renderKey=table.concat(signature,"\030")
    if parent._rpalMomentRenderKey==renderKey then return end
    parent._rpalMomentRenderKey=renderKey

    parent:SetBackdrop({
        bgFile="Interface\\Tooltips\\UI-Tooltip-Background",edgeFile="Interface\\Tooltips\\UI-Tooltip-Border",
        tile=false,tileSize=16,edgeSize=14,insets={left=4,right=4,top=4,bottom=4},
    })
    parent:SetBackdropColor(PANEL2[1],PANEL2[2],PANEL2[3],0.95)
    parent:SetBackdropBorderColor(accent[1],accent[2],accent[3],0.62)

    if parent._children then
        for _,obj in ipairs(parent._children) do if obj and obj.Hide then obj:Hide() end end
    end
    parent._children={}
    local function Keep(obj) table.insert(parent._children,obj); return obj end

    local title=Keep(Font(parent,titleText,11,accent,DEFAULT_FONT))
    title:SetPoint("TOPLEFT",12,-9)
    local sub=Keep(Font(parent,"CURATED FROM CHRONICLE",7.5,MUTED,DEFAULT_FONT))
    sub:SetPoint("TOPRIGHT",-12,-11)
    local rule=Keep(parent:CreateTexture(nil,"ARTWORK"))
    rule:SetTexture("Interface\\Buttons\\WHITE8X8")
    rule:SetHeight(1);rule:SetPoint("TOPLEFT",12,-30);rule:SetPoint("TOPRIGHT",-12,-30)
    rule:SetVertexColor(accent[1],accent[2],accent[3],0.26)

    if #list==0 then
        local empty=Keep(Font(parent,emptyText,9.5,MUTED,DEFAULT_FONT))
        empty:SetPoint("TOPLEFT",12,-46);empty:SetPoint("RIGHT",-12,0)
        empty:SetJustifyH("LEFT"); empty:SetWordWrap(true)
        return
    end

    local width=(parent.GetWidth and parent:GetWidth()) or 520
    local spacing=8
    local cols=#list
    local cardW=math.max(110,math.floor((width-24-((cols-1)*spacing))/cols))
    local x=12
    for _,moment in ipairs(list) do
        local currentMoment=moment
        local card=Keep(CreateFrame("Button",nil,parent,"BackdropTemplate"))
        card:SetSize(cardW,34)
        card:SetPoint("TOPLEFT",x,-37)
        Backdrop(card,MAIN_BG,accent)
        PolishPanel(card,"card")
        local accentBar=Keep(card:CreateTexture(nil,"ARTWORK"))
        accentBar:SetColorTexture(accent[1],accent[2],accent[3],0.92)
        accentBar:SetPoint("TOPLEFT",1,-3);accentBar:SetPoint("BOTTOMLEFT",1,3);accentBar:SetWidth(2)
        local marker=Keep(Font(card,"◆",8,accent,DEFAULT_FONT))
        marker:SetPoint("LEFT",8,0)
        local mt=Keep(Font(card,currentMoment.title or "A Saved Session",9.2,PALE,DEFAULT_FONT))
        mt:SetPoint("TOPLEFT",23,-5);mt:SetPoint("RIGHT",-6,0)
        mt:SetJustifyH("LEFT"); mt:SetWordWrap(false)
        local meta=(currentMoment.zone and currentMoment.zone~="") and currentMoment.zone or "Remembered saved session"
        local mb=Keep(Font(card,meta,7.5,MUTED,DEFAULT_FONT))
        mb:SetPoint("TOPLEFT",23,-20);mb:SetPoint("RIGHT",-6,0)
        mb:SetWordWrap(false)
        card._rpalHoverAccent={accent[1],accent[2],accent[3],1}
        card._rpalHoverRestColor={accent[1],accent[2],accent[3],0.62}
        AttachHoverGrow(card,0.025)
        card:SetScript("OnEnter",function(self)self:SetBackdropBorderColor(accent[1],accent[2],accent[3],1)end)
        card:SetScript("OnLeave",function(self)self:SetBackdropBorderColor(accent[1],accent[2],accent[3],0.62)end)
        card:SetScript("OnClick",function()OpenMomentDetail(currentMoment,accent)end)
        x=x+cardW+spacing
    end
end

local function AnimatePreviewAlpha(frame,target,duration,after)
    if not frame then if after then after() end; return end
    target=tonumber(target) or 1
    duration=math.max(0.01,tonumber(duration) or 0.22)
    local start=frame:GetAlpha() or 1
    frame._rpalFadeStart=start;frame._rpalFadeTarget=target;frame._rpalFadeElapsed=0;frame._rpalFadeDuration=duration;frame._rpalFadeAfter=after
    if frame._rpalFadeActive then return end
    frame._rpalFadeActive=true
    local function Update(self,elapsed)
        local e=(self._rpalFadeElapsed or 0)+(elapsed or 0);self._rpalFadeElapsed=e
        local t=math.min(1,e/(self._rpalFadeDuration or duration));t=t*t*(3-2*t)
        self:SetAlpha((self._rpalFadeStart or start)+((self._rpalFadeTarget or target)-(self._rpalFadeStart or start))*t)
        if t>=1 then
            self:SetAlpha(self._rpalFadeTarget or target)
            if self.SetScript then pcall(self.SetScript,self,"OnUpdate",nil) end
            self._rpalFadeActive=nil
            local fn=self._rpalFadeAfter;self._rpalFadeAfter=nil;if fn then fn() end
        end
    end

    -- FontString exposes SetScript but rejects OnUpdate.  Frames keep the
    -- smooth per-frame path; Regions use a short generation-guarded timer so
    -- the same helper can safely fade both without throwing a Lua error.
    local objectType=frame.GetObjectType and frame:GetObjectType() or ""
    local acceptsUpdate=(objectType=="Frame" or objectType=="Button" or objectType=="ScrollFrame" or objectType=="PlayerModel" or objectType=="Model")
    if acceptsUpdate then
        local ok=pcall(frame.SetScript,frame,"OnUpdate",Update)
        if ok then return end
    end

    frame._rpalFadeGeneration=(frame._rpalFadeGeneration or 0)+1
    local generation=frame._rpalFadeGeneration
    local steps=6
    if not C_Timer or not C_Timer.After then
        frame:SetAlpha(target);frame._rpalFadeActive=nil
        local fn=frame._rpalFadeAfter;frame._rpalFadeAfter=nil;if fn then fn() end
        return
    end
    for step=1,steps do
        local currentStep=step
        C_Timer.After(duration*(currentStep/steps),function()
            if not frame or frame._rpalFadeGeneration~=generation then return end
            Update(frame,duration/steps)
        end)
    end
end

local function SetPreviewShelfMode(host,isInjury,animate)
    if not host then return end
    if host.pageScroll then
        host.pageScroll:ClearAllPoints()
        host.pageScroll:SetPoint("TOPLEFT",230,host._rpalScrollTop or -218)
        host.pageScroll:SetPoint("BOTTOMRIGHT",host._rpalScrollRight or -34,host._rpalNormalBottom or 265)
    end
    -- These docks are permanent inspect chrome. Keeping one fixed shelf for
    -- every page prevents Memoirs/Pinned Sessions from vanishing and stops the
    -- scroll viewport from changing height underneath the page tabs.
    local target=1
    if animate then
        AnimatePreviewAlpha(host.memoirDock,target,.22)
        AnimatePreviewAlpha(host.momentDock,target,.22)
        AnimatePreviewAlpha(host.storyDock,target,.22)
    else
        if host.memoirDock then host.memoirDock:SetAlpha(target) end
        if host.momentDock then host.momentDock:SetAlpha(target) end
        if host.storyDock then host.storyDock:SetAlpha(target) end
    end
end

local function RenderRemoteInspect()
    if not remoteInspectWindow or not remoteInspectWindow:IsShown() then return end
    local m=remoteInspectWindow.modal
    if not m or not m.content then return end

    local content=m.content
    local request,usingCache,cachedAge,activeRequest=H.SelectInspectSnapshot(remoteInspectSender,remoteInspectRequestID,"profile")
    local remoteAccent,remotePalette=RemoteThemeColor(request)

    return RPAL.WithThemePalette(remotePalette,function()
    ClearChildren(content)
    m._rpalRemotePalette=remotePalette

    if m.syncStatus then
        local statusText,statusColor
        if usingCache then
            local age=math.max(0,math.floor(tonumber(cachedAge) or 0))
            local ageText=age<60 and (tostring(age).."s") or (tostring(math.floor(age/60)).."m")
            if activeRequest and activeRequest.timedOut then
                statusText="CACHED SNAPSHOT  •  refresh timed out  •  "..ageText.." old"
                statusColor=remotePalette.muted
            elseif activeRequest then
                statusText="CACHED SNAPSHOT  •  refreshing in background  •  "..ageText.." old"
                statusColor=remoteAccent
            else
                statusText="CACHED SNAPSHOT  •  "..ageText.." old"
                statusColor=remotePalette.muted
            end
        elseif activeRequest and not activeRequest.complete and not activeRequest.private then
            statusText=activeRequest.timedOut and "LIVE UPDATE TIMED OUT" or "RECEIVING LIVE SNAPSHOT…"
            statusColor=activeRequest.timedOut and remotePalette.muted or remoteAccent
        elseif request and request.complete then
            statusText="LIVE SNAPSHOT  •  updated now"
            statusColor=remoteAccent
        else
            statusText="REQUESTING LIVE SNAPSHOT…"
            statusColor=remotePalette.muted
        end
        m.syncStatus:SetText(statusText)
        m.syncStatus:SetTextColor(statusColor[1],statusColor[2],statusColor[3],statusColor[4] or 1)
    end

    -- Modal chrome, fixed detail column and scrolling cards must all belong to
    -- the inspected character.  Previously only the red/blue accent changed,
    -- leaving the viewer's panel and text palette underneath it.
    if m.SetBackdropColor then m:SetBackdropColor(remotePalette.panel2[1],remotePalette.panel2[2],remotePalette.panel2[3],remotePalette.panel2[4] or 0.98) end
    if m.SetBackdropBorderColor then m:SetBackdropBorderColor(remoteAccent[1],remoteAccent[2],remoteAccent[3],1) end
    if m._rpalModalPaper then m._rpalModalPaper:SetVertexColor(remoteAccent[1],remoteAccent[2],remoteAccent[3],1);m._rpalModalPaper:SetAlpha(0.13) end
    if m._rpalModalTitleBand then m._rpalModalTitleBand:SetColorTexture(remotePalette.main[1],remotePalette.main[2],remotePalette.main[3],0.72) end
    if m._rpalModalTitle then m._rpalModalTitle:SetTextColor(remoteAccent[1],remoteAccent[2],remoteAccent[3],1) end
    if m._rpalModalRule then m._rpalModalRule:SetVertexColor(remoteAccent[1],remoteAccent[2],remoteAccent[3],0.32) end
    if m._rpalModalClose then
        if m._rpalModalClose._rpalNormalTexture then m._rpalModalClose._rpalNormalTexture:SetVertexColor(remoteAccent[1],remoteAccent[2],remoteAccent[3],0.90) end
        if m._rpalModalClose._rpalHoverTexture then m._rpalModalClose._rpalHoverTexture:SetVertexColor(remoteAccent[1],remoteAccent[2],remoteAccent[3],1) end
    end
    if m.pageTabs then
        for page,tab in pairs(m.pageTabs) do
            local active=(remoteInspectPage==page)
            tab._active=active
            tab._rpalRemotePalette=remotePalette
            tab._rpalInteractionAccent=remoteAccent
            tab:SetBackdropColor(unpack(active and remotePalette.accent or remotePalette.button))
            tab:SetBackdropBorderColor(unpack(active and remoteAccent or remotePalette.border))
            if tab.label then tab.label:SetTextColor(unpack(remotePalette.pale)) end
        end
    end

    -- The fixed public-information card owns an inner scroll, so every TRP
    -- Additional Field remains reachable without making the whole inspect
    -- window grow. The five Glance sockets above it are updated in place.
    if m.detailPanel then
        local infoAccent=remoteAccent
        local info=(request and request.information) or {}
        m.detailPanel:SetBackdropColor(remotePalette.panel2[1],remotePalette.panel2[2],remotePalette.panel2[3],0.96)
        RPAL.RenderPublicDetailPanel(m.detailPanel,info,infoAccent,not (request and request.finished),(request and request.glances) or {},(request and request.publicFlags) or {})
        if m.detailGlances then RPAL.UpdatePublicGlanceStrip(m.detailGlances,(request and request.glances) or {},infoAccent) end
    end

    if request and request.private then
        local hiddenTitle=Keep(content,Heading(content,"ODYN profile hidden",18))
        hiddenTitle:SetPoint("TOP",0,-42)
        local hiddenHint=Keep(content,Font(content,"This player has chosen not to share their ODYN public profile.",10,MUTED))
        hiddenHint:SetPoint("TOP",hiddenTitle,"BOTTOM",0,-12)
        hiddenHint:SetWidth(560);hiddenHint:SetJustifyH("CENTER");hiddenHint:SetWordWrap(true)
        if m.detailPanel then RPAL.RenderPublicDetailPanel(m.detailPanel,{},remoteAccent,false,{},{} ) end
        if m.memoirDock and RPAL.RenderMemoirDock then RPAL.RenderMemoirDock(m.memoirDock,{},remoteAccent,remotePalette) end
        if m.momentDock then RenderPinnedMomentsDock(m.momentDock,{},remoteAccent,"PINNED MEMORIES","Profile hidden.") end
        if m.storyDock and RPAL.RenderPinnedStoryDock then RPAL.RenderPinnedStoryDock(m.storyDock,{},remoteAccent,"PINNED STORIES","Profile hidden.") end
        return
    end

    if not request or not request.header then
        local loading=Keep(content,Heading(content,"Requesting ODYN profile...",17))
        loading:SetPoint("TOP",0,-42)

        local hint=Keep(content,Font(content,"Waiting for the target player's addon to respond.",10,MUTED))
        hint:SetPoint("TOP",loading,"BOTTOM",0,-12)
        if m.memoirDock and RPAL.RenderMemoirDock then
            RPAL.RenderMemoirDock(m.memoirDock,{},remoteAccent,remotePalette)
        end
        if m.momentDock then
            RenderPinnedMomentsDock(m.momentDock,{},remoteAccent,"PINNED MEMORIES","No pinned memories shared yet.")
        end
        if m.storyDock and RPAL.RenderPinnedStoryDock then
            RPAL.RenderPinnedStoryDock(m.storyDock,{},remoteAccent,"PINNED STORIES","No pinned stories shared yet.")
        end
        return
    end

    local h=request.header
    local remoteCos=request.cosmetics or {}

    if m.profileModel then
        local animationSelection=remoteCos.characterAnimations
        if type(animationSelection)~="table" then
            animationSelection={[remoteCos.characterAnimation or "Stand"]=true}
        end
        local animationSignature=RPAL.AnimationSelectionSignature(animationSelection)
        m.profileModel._rpalAnimationSelection=animationSelection
        if m.profileModel._rpalModelReady and m.profileModel._rpalAppliedAnimationSignature~=animationSignature then
            m.profileModel._rpalAppliedAnimationSignature=animationSignature
            RPAL.ApplyProfileModelAnimation(m.profileModel,animationSelection)
        end
    end
    if m._rpalUpdateRemotePlatform then
        m._rpalUpdateRemotePlatform(remoteCos.platformStyle or "Default",remoteAccent)
    end

    if m.memoirDock and RPAL.RenderMemoirDock then
        m.memoirDock._expected=tonumber(request.memoirExpected) or 0
        m.memoirDock._finished=request.finished and true or false
        RPAL.RenderMemoirDock(m.memoirDock,request.memoirs or {},remoteAccent,remotePalette)
    end
    if m.momentDock then
        RenderPinnedMomentsDock(m.momentDock,request.pinnedMoments or {},remoteAccent,"PINNED MEMORIES","No pinned memories shared yet.")
    end
    if m.storyDock and RPAL.RenderPinnedStoryDock then
        RPAL.RenderPinnedStoryDock(m.storyDock,request.pinnedStories or {},remoteAccent,"PINNED STORIES","No pinned stories shared yet.")
    end

    -- RenderAtmosphere owns a cached holder on the content frame.  Incoming
    -- inspect packets can rebuild text/cards without restarting its pulse.
    RenderAtmosphere(content,remoteCos.background or "None",remoteAccent,0.72)

    local remoteOrnament=RenderUIOrnament(content,remoteCos.uiOrnament or "Classic",remoteAccent,6)
    if remoteOrnament then Keep(content,remoteOrnament) end

    -- Effects belong to the whole inspect window, not only the scrolling page.
    -- A dedicated surface keeps them above ScrollFrame content while the fixed
    -- detail/tabs/docks retain their own higher levels. Rendering directly on
    -- the modal left effects behind template-created scroll layers in live WoW.
    if RPAL.RenderEffectPack then
        local effectName=remoteCos.effectPack or "None"
        local effectSignature=effectName..":"..string.format("%.3f:%.3f:%.3f",remoteAccent[1] or 0,remoteAccent[2] or 0,remoteAccent[3] or 0)
        if not m._rpalRemoteEffectSurface then
            m._rpalRemoteEffectSurface=CreateFrame("Frame",nil,m)
            m._rpalRemoteEffectSurface:SetAllPoints(m)
            m._rpalRemoteEffectSurface:SetClipsChildren(true)
            m._rpalRemoteEffectSurface:EnableMouse(false)
            m._rpalRemoteEffectSurface:EnableMouseWheel(false)
            m._rpalRemoteEffectSurface:SetFrameLevel((m:GetFrameLevel() or 1)+18)
        end
        local effectMissing=effectName~="None" and (not m._rpalRemoteEffect or not m._rpalRemoteEffect:IsShown())
        if m._rpalRemoteEffectSignature~=effectSignature or effectMissing then
            if m._rpalRemoteEffect and m._rpalRemoteEffect.Hide then m._rpalRemoteEffect:Hide() end
            m._rpalRemoteEffect=nil
            m._rpalRemoteEffectName=effectName
            m._rpalRemoteEffectSignature=effectSignature
            if effectName~="None" then
                m._rpalRemoteEffect=RPAL.RenderEffectPack(m._rpalRemoteEffectSurface,effectName,remoteAccent,{frameLevelOffset=1})
            end
        end
    end

    local deco=RenderHeaderCosmetic(content,remoteCos.headerDecoration or "None",remoteAccent,420,105)
    if deco then
        Keep(content,deco)
        deco:SetPoint("TOPLEFT",-18,-2)
    end

    if m.SetBackdropBorderColor then
        m:SetBackdropBorderColor(remoteAccent[1],remoteAccent[2],remoteAccent[3],1)
    end
    local y=-22

    local portraitBox=Keep(content,CreateFrame("Frame",nil,content,"BackdropTemplate"))
    portraitBox:SetSize(62,62);portraitBox:SetPoint("TOPRIGHT",-26,-18)
    Backdrop(portraitBox,PANEL2,remoteAccent)

    -- Actual inspected-player face, using the same native unit portrait texture
    -- treatment as the player/target-frame widgets. Cosmetics/aura remain layered
    -- around it instead of replacing the portrait with an empty circle.
    local portrait=portraitBox:CreateTexture(nil,"ARTWORK")
    portrait:SetPoint("TOPLEFT",4,-4);portrait:SetPoint("BOTTOMRIGHT",-4,4)
    local portraitUnit=H.ResolveInspectUnit(remoteInspectSender,request)
    if portraitUnit and SetPortraitTexture then
        local ok=pcall(SetPortraitTexture,portrait,portraitUnit)
        if not ok then portrait:SetTexture("Interface\\Icons\\INV_Misc_QuestionMark") end
    else
        portrait:SetTexture("Interface\\Icons\\INV_Misc_QuestionMark")
        portrait:SetTexCoord(.08,.92,.08,.92)
    end
    if portraitBox.CreateMaskTexture and portrait.AddMaskTexture then
        local mask=portraitBox:CreateMaskTexture(nil,"ARTWORK")
        mask:SetTexture("Interface\\CharacterFrame\\TempPortraitAlphaMask")
        mask:SetAllPoints(portrait);portrait:AddMaskTexture(mask)
    end

    local remoteAura=RenderProfileAura(content,portraitBox,remoteCos.profileAura or "None",remoteAccent,88)
    if remoteAura then Keep(content,remoteAura) end

    local remotePortraitFrame=RenderPortraitCosmetic(content,portraitBox,remoteCos.portraitFrame or "Default",remoteAccent,78)
    if remotePortraitFrame then Keep(content,remotePortraitFrame) end

    -- Large decorative Level Badge display.
    -- It is deliberately independent from the inspect header/layout so it
    -- cannot push, cover, or break identity/stat/achievement rendering.
    local remoteLevel=1
    local remaining=math.max(0,tonumber(h.score) or 0)
    local testLevel=1
    while testLevel<200 do
        local need=RPAL.RenownRequiredForLevel and RPAL.RenownRequiredForLevel(testLevel) or 10
        if remaining<need then break end
        remaining=remaining-need
        testLevel=testLevel+1
    end
    remoteLevel=testLevel

    local remoteBadgeAnchor=Keep(content,CreateFrame("Frame",nil,content))
    remoteBadgeAnchor:SetSize(68,68);remoteBadgeAnchor:SetPoint("TOPRIGHT",-118,-18)
    local remoteBadge=RenderLevelBadge(content,remoteBadgeAnchor,remoteCos.levelBadge or "Initiate Seal",64,remoteLevel,0.32)
    if remoteBadge then Keep(content,remoteBadge) end



    -- Display-only element. No mouse capture, no influence on scroll content.


    -- Align the identity block with the portrait's top edge.
    y=-18

    local name=Keep(content,Heading(content,h.displayName~="" and h.displayName or H.SocialShortName(remoteInspectSender),22))
    -- Reserve the fixed tab strip on the right side of remote Inspect. Long TRP
    -- names previously ran underneath Show Skill Set / Injuries and made those
    -- tabs look like they were embedded in the character name.
    name:SetWidth(220)
    name:SetWordWrap(false)
    if name.SetNonSpaceWrap then name:SetNonSpaceWrap(false) end
    if RPAL.FitFontStringToWidth then RPAL.FitFontStringToWidth(name,220,22,12) end

    local rlr,rlg,rlb=GetRenownLevelColor(remoteLevel or 1)
    local remoteLevelText=Keep(content,Font(content,"Lv. "..tostring(remoteLevel or 1),12,{rlr,rlg,rlb}))
    remoteLevelText._rpalColorRole=nil
    remoteLevelText:SetTextColor(rlr,rlg,rlb,1)
    remoteLevelText:SetPoint("TOPLEFT",22,y)
    remoteLevelText:SetWidth(remoteLevelText:GetStringWidth()+1)
    remoteLevelText:SetWordWrap(false)

    name:SetPoint("LEFT",remoteLevelText,"RIGHT",4,0)
    y=y-28

    if h.title~="" then
        local title=Keep(content,Font(content,h.title,11,remoteAccent))
        title:SetPoint("TOPLEFT",22,y)
        y=y-19
    end

    local raceClass=((h.race or "").." "..(h.className or "")):gsub("^%s+",""):gsub("%s+$","")
    if raceClass~="" then
        local rc=Keep(content,Font(content,raceClass,11,PALE))
        rc:SetPoint("TOPLEFT",22,y)
        y=y-19
    end

    if h.guild~="" then
        local guild=Keep(content,Font(content,h.guild,10,{0.75,0.63,0.38}))
        guild:SetPoint("TOPLEFT",22,y)
        y=y-20
    end

    if request.themeName and request.themeName~="" then
        local themeLine=Keep(content,Font(content,"Theme: "..request.themeName,9,remoteAccent))
        themeLine:SetPoint("TOPLEFT",22,y)
        y=y-23
    else
        y=y-7
    end

    local summary=Keep(content,CreateFrame("Frame",nil,content,"BackdropTemplate"))
    summary:SetPoint("TOPLEFT",22,y)
    summary:SetSize(650,70)
    Backdrop(summary,PANEL2,remoteAccent)
    PolishPanel(summary,"card")
    summary._rpalBorderRole=nil

    -- Four equal public summary columns. Characters Met used to be present in
    -- the inspect packet but was accidentally dropped from the rendered card.
    local summaryStats={
        {"Renown",tostring(h.score or 0)},
        {"Characters Met",tostring(h.charactersMet or 0)},
        {"Achievements",(h.achievementCompleted or 0).." / "..(h.achievementTotal or 0)},
        {"Interactions",tostring(h.totalEvents or 0)},
    }
    local summaryX={-244,-81,81,244}
    for i,entry in ipairs(summaryStats) do
        local label=Keep(content,Font(summary,entry[1],8.5,MUTED));label:SetPoint("TOP",summary,"TOP",summaryX[i],-12)
        local value=Keep(content,Heading(summary,entry[2],i==1 and 24 or 20));value:SetPoint("BOTTOM",summary,"BOTTOM",summaryX[i],11)
    end

    y=y-92

    if remoteInspectPage=="skills" then
        local skillTitle=Keep(content,Heading(content,"SKILL SET",17))
        skillTitle:SetPoint("TOPLEFT",22,y)
        local skillHint=Keep(content,Font(content,"READ ONLY  •  CHARACTER PROFICIENCY",9,MUTED,DEFAULT_FONT))
        skillHint:SetPoint("TOPRIGHT",700,y-1)
        y=y-34

        local skillData=request.skillSet or {}
        if not request.skillSetReceived then
            local loading=Keep(content,Font(content,request.finished and "No Skill Set data was shared by this player." or "Receiving Skill Set...",10,MUTED,DEFAULT_FONT))
            loading:SetPoint("TOPLEFT",22,y)
            y=y-28
        else
            local skillPalette=(RPAL.GetCurrentThemePalette and RPAL.GetCurrentThemePalette()) or {gold=remoteAccent,pale=PALE,muted=MUTED,panel=PANEL,panel2=PANEL2,border=BORDER,button=BUTTON_BG}
            local width=700
            local gap=12
            local colWidth=math.floor((width-gap)/2)
            local levelNames={"UNTRAINED","NOVICE","COMPETENT","SKILLED","EXPERT","MASTER"}
            local iconNames={one_handed="badge.tga",two_handed="badge.tga",magic="wand.tga",skill="eye.tga",work="tools.tga"}
            for _,category in ipairs(RPAL.SKILL_SET_CATEGORIES or {}) do
                local values=skillData[category.id] or {}
                local header=Keep(content,CreateFrame("Button",nil,content,"BackdropTemplate"))
                header:SetSize(width,46);header:SetPoint("TOPLEFT",22,y)
                Backdrop(header,skillPalette.panel or PANEL,remoteAccent)
                local icon=Keep(content,header:CreateTexture(nil,"ARTWORK"))
                local iconName=iconNames[category.id] or "badge.tga"
                icon:SetTexture("Interface\\AddOns\\"..tostring(ADDON_NAME or "ODYN").."\\Textures\\IconPack\\"..iconName)
                icon:SetSize(22,22);icon:SetPoint("LEFT",12,0);icon:SetVertexColor(remoteAccent[1],remoteAccent[2],remoteAccent[3],0.92)
                local arrow=Keep(content,Font(header,m.remoteSkillExpanded[category.id] and "▼" or "▶",10,remoteAccent,DEFAULT_FONT));arrow:SetPoint("LEFT",icon,"RIGHT",8,0)
                local title=Keep(content,Font(header,category.name,12.5,remoteAccent,DEFAULT_FONT));title:SetPoint("LEFT",arrow,"RIGHT",8,5)
                local desc=Keep(content,Font(header,category.description or "",8.5,MUTED,DEFAULT_FONT));desc:SetPoint("LEFT",arrow,"RIGHT",8,-13);desc:SetPoint("RIGHT",-12,-13);desc:SetWordWrap(false)
                header:SetScript("OnClick",function() m.remoteSkillExpanded[category.id]=not m.remoteSkillExpanded[category.id]; RenderRemoteInspect() end)
                -- WoW TOPLEFT offsets descend with increasingly negative Y.
                -- The old code added to Y here, so every following category
                -- climbed back into the profile summary/header.
                y=y-54
                if m.remoteSkillExpanded[category.id] then
                    local rows=math.ceil(#(category.skills or {})/2)
                    for i,skill in ipairs(category.skills or {}) do
                        local col=(i-1)%2;local rowIndex=math.floor((i-1)/2)
                        local card=Keep(content,CreateFrame("Frame",nil,content,"BackdropTemplate"))
                        card:SetSize(colWidth,58);card:SetPoint("TOPLEFT",22+col*(colWidth+gap),y-rowIndex*68)
                        Backdrop(card,skillPalette.panel2 or PANEL2,skillPalette.border or BORDER);PolishPanel(card,"card")
                        local name=Keep(content,Font(card,skill,11.5,PALE,DEFAULT_FONT));name:SetPoint("TOPLEFT",12,-10);name:SetWidth(colWidth-145);name:SetWordWrap(false)
                        local value=math.max(0,math.min(5,math.floor(tonumber(values[i]) or 0)))
                        local level=Keep(content,Font(card,levelNames[value+1] or levelNames[1],8.5,remoteAccent,DEFAULT_FONT));level:SetPoint("TOPLEFT",12,-34)
                        for pip=1,5 do
                            local t=Keep(content,card:CreateTexture(nil,"ARTWORK"));t:SetTexture("Interface\\Buttons\\WHITE8X8");t:SetSize(13,5);t:SetPoint("RIGHT",-10-(5-pip)*16,0);t:SetVertexColor(remoteAccent[1],remoteAccent[2],remoteAccent[3],pip<=value and 0.95 or 0.20)
                        end
                    end
                    y=y-(rows*68+12)
                else
                    y=y-8
                end
            end
        end
        content:SetHeight(math.max(480,-y+35))
        return
    end

    if remoteInspectPage=="statistics" then
        local statTitle=Keep(content,Heading(content,"SOCIAL INTERACTIONS",15))
        statTitle:SetPoint("TOPLEFT",22,y)
        local statMeta=Keep(content,Font(content,"TOP 20 EMOTES RECEIVED FROM OTHER PLAYERS",8,MUTED,DEFAULT_FONT))
        statMeta:SetPoint("TOPRIGHT",-38,y-2)
        y=y-28
        local columns=3
        local cardW=210
        for index,key in ipairs(RPAL.GetSortedStatisticKeys and RPAL.GetSortedStatisticKeys(request,20) or RPAL.STAT_ORDER or {}) do
            local def=RPAL.STAT_DEFINITIONS[key]
            local row=math.floor((index-1)/columns)
            local col=(index-1)%columns
            local card=Keep(content,CreateFrame("Frame",nil,content,"BackdropTemplate"))
            card:SetSize(cardW,64)
            card:SetPoint("TOPLEFT",22+(col*(cardW+10)),y-(row*74))
            Backdrop(card,PANEL2,remoteAccent);PolishPanel(card,"card");AttachGentleFloat(card,3,true)
            local statIcon=RPAL.CreateStatMedallion(card,STAT_ICON[key] or STAT_ICON.renown,remoteAccent,36)
            if statIcon then statIcon:SetPoint("LEFT",8,0);Keep(content,statIcon) end
            local label=Keep(content,Font(card,(def and def.command) or ("/"..key),14,PALE));label:SetPoint("LEFT",52,0);label:SetWidth(96);label:SetWordWrap(false)
            local value=Keep(content,Heading(card,tostring((request.counts or {})[key] or 0),22));value:SetPoint("RIGHT",-11,0)
        end
        content:SetHeight(math.max(480,y*-1+math.ceil(#(RPAL.GetSortedStatisticKeys and RPAL.GetSortedStatisticKeys(request,20) or RPAL.STAT_ORDER or {})/columns)*74+80))
        return
    end

    local achTitle=Keep(content,Heading(content,"COMPLETED ACHIEVEMENTS",15))
    achTitle:SetPoint("TOPLEFT",22,y)
    local achHint=Keep(content,Font(content,"GROUPED BY CATEGORY",8,MUTED,DEFAULT_FONT))
    achHint:SetPoint("TOPRIGHT",-38,y-2)
    y=y-31

    if #(request.achievements or {})==0 and not request.finished then
        local loading=Keep(content,Font(content,"Receiving achievement list...",10,MUTED))
        loading:SetPoint("TOPLEFT",22,y)
        y=y-22
    end

    local categoryOrder={}
    local categoryBuckets={}
    local rendered=0
    for index=1,(h.achievementCompleted or 0) do
        local item=request.achievements[index]
        if item then
            local categoryName=tostring(item.category or "")
            if categoryName=="" then categoryName="Other" end
            if not categoryBuckets[categoryName] then
                categoryBuckets[categoryName]={}
                categoryOrder[#categoryOrder+1]=categoryName
            end
            categoryBuckets[categoryName][#categoryBuckets[categoryName]+1]=item
            rendered=rendered+1
        end
    end

    for _,categoryName in ipairs(categoryOrder) do
        local bucket=categoryBuckets[categoryName]
        local header=Keep(content,CreateFrame("Frame",nil,content,"BackdropTemplate"))
        header:SetPoint("TOPLEFT",22,y);header:SetSize(650,31)
        Backdrop(header,PANEL,remoteAccent);PolishPanel(header,"major")
        local mark=Keep(content,header:CreateTexture(nil,"ARTWORK"));mark:SetTexture(NATIVE_CREST_ICON);mark:SetSize(17,17);mark:SetPoint("LEFT",9,0);mark:SetVertexColor(remoteAccent[1],remoteAccent[2],remoteAccent[3],0.9)
        local categoryLabel=Keep(content,Font(header,string.upper(categoryName),10.5,remoteAccent,DEFAULT_FONT));categoryLabel:SetPoint("LEFT",33,0)
        local categoryCount=Keep(content,Font(header,tostring(#bucket).." COMPLETED",8,MUTED,DEFAULT_FONT));categoryCount:SetPoint("RIGHT",-10,0)
        y=y-37

        for _,item in ipairs(bucket) do
            local itemRef=item
            local categoryLabelText=categoryName
            local row=Keep(content,CreateFrame("Frame",nil,content,"BackdropTemplate"))
            row:SetPoint("TOPLEFT",34,y);row:SetSize(626,42)
            Backdrop(row,itemRef.favourite and PANEL2 or BUTTON_BG,remoteAccent);PolishPanel(row,"card")
            if itemRef.favourite then row._rpalBorderRole=nil end
            local icon=Keep(content,row:CreateTexture(nil,"ARTWORK"));icon:SetSize(30,30);icon:SetPoint("LEFT",7,0);icon:SetTexture((itemRef.icon and itemRef.icon~="") and itemRef.icon or RPAL.ICONS.question)
            local nameText=(itemRef.favourite and "[FAV] " or "")..(itemRef.name or "Achievement")
            local n=Keep(content,Font(row,nameText,11,PALE));n:SetPoint("LEFT",45,0);n:SetPoint("RIGHT",-12,0);n:SetJustifyH("LEFT");n:SetWordWrap(false)
            row:EnableMouse(true)
            row:SetScript("OnEnter",function(self)
                local desc=itemRef.description or ""
                if RPAL.ShowCursorTooltip then RPAL.ShowCursorTooltip({eyebrow="INSPECTED ACHIEVEMENT",title=itemRef.name or "Achievement",body=(function() local owner=tostring((h and h.displayName) or ""); if owner=="" then owner=tostring(H.SocialShortName(remoteInspectSender) or "Unknown") end; return tostring(desc or ""):gsub("%*name%*",function() return owner end) end)(),sectionTitle="CATEGORY",section=categoryLabelText,footer=itemRef.favourite and "FAVOURITE" or "",accent=remoteAccent,width=400}) end
            end)
            row:SetScript("OnLeave",function() if RPAL.HideCursorTooltip then RPAL.HideCursorTooltip() end end)
            AttachGentleFloat(row,2,true)
            y=y-47
        end
        y=y-9
    end

    if rendered==0 and request.finished then
        local none=Keep(content,Font(content,"No completed achievements shared yet.",10,MUTED));none:SetPoint("TOPLEFT",22,y);y=y-22
    end

    content:SetHeight(math.max(480,-y+35))
    end)
end

function RPAL.RefreshRemoteInspect(sender,requestID)
    if remoteInspectSender and SocialNormalizeName(sender)==SocialNormalizeName(remoteInspectSender)
       and requestID==remoteInspectRequestID then
        local ok,err=pcall(RenderRemoteInspect)
        if not ok then
            print("|cffff6666ODYN inspect error:|r "..tostring(err))
        end
    end
end

function RPAL.RenderRemoteHealthInspect()
    if not UI.remoteHealthWindow or not UI.remoteHealthWindow:IsShown() then return end
    local m=UI.remoteHealthWindow.modal;if not m or not m.healthContent then return end
    local request,usingCache,cachedAge,activeRequest=H.SelectInspectSnapshot(UI.remoteHealthSender,UI.remoteHealthRequestID,"health")
    local accent,palette=RemoteThemeColor(request)
    return RPAL.WithThemePalette(palette,function()
        local content=m.healthContent;ClearChildren(content)
        if m.syncStatus then
            local statusText,statusColor
            if usingCache then
                local age=math.max(0,math.floor(tonumber(cachedAge)or 0));local ageText=age<60 and (tostring(age).."s") or (tostring(math.floor(age/60)).."m")
                statusText=(activeRequest and activeRequest.timedOut) and ("CACHED HEALTH  •  refresh timed out  •  "..ageText.." old") or (activeRequest and ("CACHED HEALTH  •  refreshing  •  "..ageText.." old") or ("CACHED HEALTH  •  "..ageText.." old"))
                statusColor=activeRequest and not activeRequest.timedOut and accent or palette.muted
            elseif activeRequest and not activeRequest.complete and not activeRequest.private then
                statusText=activeRequest.timedOut and "HEALTH UPDATE TIMED OUT" or "RECEIVING LIVE HEALTH SNAPSHOT…";statusColor=activeRequest.timedOut and palette.muted or accent
            elseif request and request.complete then statusText="LIVE HEALTH  •  updated now";statusColor=accent
            else statusText="REQUESTING LIVE HEALTH SNAPSHOT…";statusColor=palette.muted end
            m.syncStatus:SetText(statusText);m.syncStatus:SetTextColor(statusColor[1],statusColor[2],statusColor[3],statusColor[4]or 1)
        end
        if m.SetBackdropColor then m:SetBackdropColor(palette.panel2[1],palette.panel2[2],palette.panel2[3],palette.panel2[4] or .98)end
        if m.SetBackdropBorderColor then m:SetBackdropBorderColor(accent[1],accent[2],accent[3],1)end
        if request and request.private then
            local t=Keep(content,Heading(content,"Health sharing unavailable",18));t:SetPoint("TOP",0,-70);local d=Keep(content,Font(content,"This player is not sharing a public ODYN profile/Health snapshot.",10,MUTED));d:SetPoint("TOP",t,"BOTTOM",0,-12);return
        end
        if not request or not request.finished then
            local t=Keep(content,Heading(content,"Receiving Health snapshot...",16));t:SetPoint("TOP",0,-46)
            local d=Keep(content,Font(content,"Body map, active records and public Medical History will appear when the transfer completes.",9,MUTED));d:SetPoint("TOP",t,"BOTTOM",0,-12);d:SetWidth(680);d:SetJustifyH("CENTER")
            return
        end
        local name=(request.header and request.header.displayName)or H.SocialShortName(UI.remoteHealthSender)
        local kicker=Keep(content,Font(content,"INSPECT HEALTH  •  "..tostring(name or"Player"),8,accent));kicker:SetPoint("TOPLEFT",8,-3)
        local holder=Keep(content,CreateFrame("Frame",nil,content));holder:SetSize(970,610);holder:SetPoint("TOPLEFT",0,-25)
        local view=RPAL.RenderInjuryReadOnly and RPAL.RenderInjuryReadOnly(holder,request.injuries or{items={},groups={},history={}},accent,UI.remoteHealthSender)
        if view then view:ClearAllPoints();view:SetPoint("TOPLEFT",holder,"TOPLEFT",0,0)end
    end)
end

function RPAL.RefreshRemoteHealthInspect(sender,requestID)
    if not UI.remoteHealthSender or SocialNormalizeName(sender)~=SocialNormalizeName(UI.remoteHealthSender) or requestID~=UI.remoteHealthRequestID then return end
    if UI.remoteHealthRefreshPending then return end
    UI.remoteHealthRefreshPending=true
    local function DoRefresh()
        UI.remoteHealthRefreshPending=nil
        if not UI.remoteHealthWindow or not UI.remoteHealthWindow:IsShown() then return end
        local ok,err=pcall(RPAL.RenderRemoteHealthInspect);if not ok then print("|cffff6666ODYN Health inspect error:|r "..tostring(err))end
    end
    if C_Timer and C_Timer.After then C_Timer.After(.05,DoRefresh)else DoRefresh()end
end

function RPAL.OpenRemoteHealthInspect(sender,requestID)
    if RPAL.NotifyHoverLogODYNInspectOpened then pcall(RPAL.NotifyHoverLogODYNInspectOpened) end
    if UI.remoteHealthWindow and UI.remoteHealthWindow:IsShown() then UI.remoteHealthWindow:Hide()end
    local shade,m=Modal("ODYN Health",1040,760);UI.remoteHealthWindow=shade;UI.remoteHealthSender=sender;UI.remoteHealthRequestID=requestID;shade.modal=m
    m.syncStatus=Font(m,"",8,MUTED);m.syncStatus:SetPoint("TOPLEFT",208,-22);m.syncStatus:SetWidth(610);m.syncStatus:SetJustifyH("LEFT")
    local content=CreateFrame("Frame",nil,m);content:SetSize(982,680);content:SetPoint("TOPLEFT",24,-58);m.healthContent=content
    shade:HookScript("OnHide",function()if UI.remoteHealthWindow==shade then UI.remoteHealthWindow=nil;UI.remoteHealthSender=nil;UI.remoteHealthRequestID=nil;UI.remoteHealthRefreshPending=nil end end)
    RPAL.RenderRemoteHealthInspect()
end

function RPAL.OpenRemoteInspect(sender,requestID)
    if RPAL.NotifyHoverLogODYNInspectOpened then pcall(RPAL.NotifyHoverLogODYNInspectOpened) end
    if remoteInspectWindow and remoteInspectWindow:IsShown() then
        remoteInspectWindow:Hide()
    end

    local shade,m=Modal("ODYN Profile",1040,760)
    m.remoteSkillExpanded={}
    remoteInspectWindow=shade
    remoteInspectSender=sender
    remoteInspectRequestID=requestID
    remoteInspectPage="statistics"
    m.syncStatus=Font(m,"",8,MUTED);m.syncStatus:SetPoint("TOPLEFT",200,-22);m.syncStatus:SetWidth(380);m.syncStatus:SetJustifyH("LEFT")

    -- Character-details column stays fixed while the statistics/achievement
    -- page scrolls independently to its right.
    local detailPanel=CreateFrame("Frame",nil,m,"BackdropTemplate")
    detailPanel:SetSize(198,474)
    detailPanel:SetPoint("TOPLEFT",18,-99)
    detailPanel:SetFrameLevel(m:GetFrameLevel()+35)
    detailPanel:SetBackdrop({
        bgFile="Interface\\Tooltips\\UI-Tooltip-Background",edgeFile="Interface\\Tooltips\\UI-Tooltip-Border",
        tile=false,tileSize=16,edgeSize=14,insets={left=4,right=4,top=4,bottom=4},
    })
    detailPanel:SetBackdropColor(PANEL2[1],PANEL2[2],PANEL2[3],0.96)
    detailPanel:SetBackdropBorderColor(GOLD[1],GOLD[2],GOLD[3],0.72)
    m.detailPanel=detailPanel

    local detailGlances=RPAL.CreatePublicGlanceStrip(m,32,5)
    detailGlances:SetPoint("BOTTOMLEFT",detailPanel,"TOPLEFT",9,7)
    detailGlances:SetFrameLevel(m:GetFrameLevel()+36)
    RPAL.UpdatePublicGlanceStrip(detailGlances,{},GOLD)
    m.detailGlances=detailGlances

    local scroll=CreateFrame("ScrollFrame",nil,m,"UIPanelScrollFrameTemplate")
    if RPAL.SkinScrollFrame then RPAL.SkinScrollFrame(scroll) end
    scroll:SetPoint("TOPLEFT",230,-44)
    scroll:SetPoint("BOTTOMRIGHT",-32,265)
    m.pageScroll=scroll;m._rpalScrollTop=-44;m._rpalScrollRight=-32;m._rpalNormalBottom=265;m._rpalInjuryBottom=18

    local content=CreateFrame("Frame",nil,scroll)
    content:SetSize(760,500)
    scroll:SetScrollChild(content)
    m.content=content

    -- Public page tabs: Statistics, Achievements and Skill Set are peers in one
    -- content area. Switching a tab never creates a second window.
    local pageBar=CreateFrame("Frame",nil,m)
    pageBar:SetPoint("TOPRIGHT",-42,-18)
    pageBar:SetSize(372,30)
    pageBar:SetFrameLevel(m:GetFrameLevel()+40)
    m.pageBar=pageBar
    m.pageTabs={}
    local tabs={{"statistics","View Statistics",108},{"achievements","Show Achievements",144},{"skills","Show Skill Set",104}}
    local x=0
    for _,def in ipairs(tabs) do
        local tab=Button(pageBar,def[2],def[3],29,false)
        tab:SetPoint("LEFT",x,0);m.pageTabs[def[1]]=tab
        tab:SetScript("OnEnter",function(self)
            local p=self._rpalRemotePalette or RPAL.GetThemePaletteByName("Obsidian Crown")
            local c=self._active and (p.accentHover or p.accent) or (p.buttonHover or p.button)
            self:SetBackdropColor(unpack(c))
            self:SetBackdropBorderColor(unpack(p.gold))
            if self._rpalHoverGlow then self._rpalHoverGlow:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],0.07) end
        end)
        tab:SetScript("OnLeave",function(self)
            local p=self._rpalRemotePalette or RPAL.GetThemePaletteByName("Obsidian Crown")
            self:SetBackdropColor(unpack(self._active and p.accent or p.button))
            self:SetBackdropBorderColor(unpack(self._active and p.gold or p.border))
            if self._rpalHoverGlow then self._rpalHoverGlow:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],0) end
        end)
        tab:SetScript("OnClick",function()
            local nextPage=def[1]
            remoteInspectPage=nextPage
            if m.pageScroll and m.pageScroll.SetVerticalScroll then m.pageScroll:SetVerticalScroll(0) end
            RenderRemoteInspect()
            SetPreviewShelfMode(m,false,false)
            local a=1
            if m.legacyHint then AnimatePreviewAlpha(m.legacyHint,a,.22) end
            if m.legacyHint2 then AnimatePreviewAlpha(m.legacyHint2,a,.22) end
        end)
        x=x+def[3]+4
    end

    -- When inspect was opened from the current target, retain that unit in a
    -- PlayerModel and apply the animation sent in the public cosmetics packet.
    -- The model is loaded once; later inspect packets only update its sequence.
    local inspectedUnitName=UnitExists("target") and SocialFullUnitName("target") or nil
    if inspectedUnitName and SocialNormalizeName(inspectedUnitName)==SocialNormalizeName(sender) then
        m._rpalUpdateRemotePlatform=function(name,accent)
            accent=accent or GOLD
            local signature=tostring(name or "Default")..":"..string.format("%.3f:%.3f:%.3f",accent[1] or 0,accent[2] or 0,accent[3] or 0)
            if m._rpalRemotePlatformSignature==signature then return end
            m._rpalRemotePlatformSignature=signature
            if m.profilePlatform then
                m.profilePlatform:SetScript("OnUpdate",nil)
                m.profilePlatform:Hide()
                m.profilePlatform:SetParent(nil)
            end
            m.profilePlatform=RPAL.RenderProfilePlatform(m,name or "Default",accent,330,74)
            m.profilePlatform:SetPoint("BOTTOMRIGHT",m,"BOTTOMLEFT",32,0)
            m.profilePlatform:SetFrameLevel((m:GetFrameLevel() or 1)+1)
        end
        m._rpalUpdateRemotePlatform("Default",GOLD)

        local profileModel=CreateFrame("PlayerModel",nil,m)
        m.profileModel=profileModel
        profileModel:SetSize(388,720)
        profileModel:SetPoint("BOTTOMRIGHT",m,"BOTTOMLEFT",46,-48)
        profileModel:SetFrameLevel((m:GetFrameLevel() or 1)+2)
        profileModel:SetAlpha(0.94)
        profileModel:EnableMouse(false)
        profileModel._rpalAnimationSelection={}
        local function SetupRemoteModel(self)
            if not self or not self:IsShown() then return end
            pcall(function() if self.SetPortraitZoom then self:SetPortraitZoom(0) end end)
            pcall(function() if self.SetCamDistanceScale then self:SetCamDistanceScale(0.84) end end)
            pcall(function() if self.SetFacing then self:SetFacing(0.24) elseif self.SetRotation then self:SetRotation(0.24) end end)
            self._rpalModelReady=true
            local selection=self._rpalAnimationSelection or {}
            self._rpalAppliedAnimationSignature=RPAL.AnimationSelectionSignature(selection)
            RPAL.ApplyProfileModelAnimation(self,selection)
        end
        profileModel:SetScript("OnModelLoaded",SetupRemoteModel)
        pcall(function() profileModel:SetUnit("target") end)
        if C_Timer and C_Timer.After then C_Timer.After(0.30,function()
            if profileModel and profileModel:IsShown() and not profileModel._rpalModelReady then SetupRemoteModel(profileModel) end
        end) end
    end

    -- Memoirs and Pinned Sessions share the modal's own fixed legacy shelf.
    -- They remain outside the scroll frame (so they cannot be clipped) without
    -- jumping to TOOLTIP strata or becoming a second, competing UI layer.
    local memoirDock=CreateFrame("Frame",nil,m,"BackdropTemplate")
    memoirDock:SetPoint("BOTTOMLEFT",230,104)
    memoirDock:SetPoint("BOTTOMRIGHT",-20,104)
    memoirDock:SetHeight(142)
    memoirDock:SetFrameLevel(m:GetFrameLevel()+24)
    m.memoirDock=memoirDock

    local momentDock=CreateFrame("Frame",nil,m,"BackdropTemplate")
    momentDock:SetPoint("BOTTOMLEFT",230,18)
    momentDock:SetSize(391,78)
    momentDock:SetFrameLevel(m:GetFrameLevel()+24)
    m.momentDock=momentDock

    local storyDock=CreateFrame("Frame",nil,m,"BackdropTemplate")
    storyDock:SetPoint("BOTTOMLEFT",629,18)
    storyDock:SetSize(391,78)
    storyDock:SetFrameLevel(m:GetFrameLevel()+24)
    m.storyDock=storyDock
    SetPreviewShelfMode(m,false,false)

    local inspectHint=Font(m,"RENOWN ATLAS LEGACY",9,MUTED,DEFAULT_FONT)
    inspectHint:SetPoint("BOTTOMLEFT",22,156);m.legacyHint=inspectHint
    local inspectHint2=Font(m,"Public Memoirs unlocked at the far edges of this character's Renown Atlas.",10,MUTED)
    inspectHint2:SetPoint("TOPLEFT",inspectHint,"BOTTOMLEFT",0,-8)
    inspectHint2:SetWidth(455)
    inspectHint2:SetWordWrap(true);m.legacyHint2=inspectHint2
    local function SetLegacyHints()
        local a=1
        if m.legacyHint then AnimatePreviewAlpha(m.legacyHint,a,.22) end
        if m.legacyHint2 then AnimatePreviewAlpha(m.legacyHint2,a,.22) end
    end
    SetLegacyHints()

    shade:SetScript("OnHide",function()
        if remoteInspectWindow==shade then
            remoteInspectWindow=nil
            remoteInspectSender=nil
            remoteInspectRequestID=nil
            remoteInspectPage="statistics"
        end
    end)

    RenderRemoteInspect()

    return shade,m
end

local function RenderProgression()
    if not UI.progression or not RPAL.GetCurrentRenownLevel then return end
    if RPAL.IsLiteKit and RPAL.IsLiteKit() then UI.progression:Hide();return end
    UI.progression:Show()
    local level,progress,needed=RPAL.GetCurrentRenownLevel()
    local missing=math.max(0,needed-progress)
    local characterEquivalent=math.max(1,math.ceil(missing/(RPAL.CHARACTERS_MET_SCORE or 7)))

    UI.levelText:SetText("LEVEL "..tostring(level))
    local lr,lg,lb=GetRenownLevelColor(level)
    UI.levelText:SetTextColor(lr,lg,lb)
    UI.renownBar:SetMinMaxValues(0,math.max(1,needed))
    UI.renownBar:SetValue(progress)
    UI.renownBar:SetStatusBarColor(GOLD[1],GOLD[2],GOLD[3],0.92)
    if UI.renownBar.bg and UI.renownBar.bg.SetVertexColor then
        UI.renownBar.bg:SetVertexColor(PAGE_BG_ALT[1],PAGE_BG_ALT[2],PAGE_BG_ALT[3],0.95)
    end
    UI.renownBarText:SetText(tostring(progress).." / "..tostring(needed))
    UI.nextLevelText:SetText("NEXT LEVEL: "..tostring(level+1))
    UI.nextLevelNeed:SetText("Need "..tostring(missing).." more Renown ("..tostring(characterEquivalent).." Characters)")
end
RPAL.RefreshProgressionStrip=RenderProgression

local function RenderStatistics()
    local host=UI.statisticsContent
    ClearChildren(host)

    local stats=RPAL.GetStatistics()
    if not stats then return end

    local title=Keep(host,Heading(host,"STATISTICS",24))
    title:SetPoint("TOPLEFT",24,-18)

    local subtitle=Keep(host,Font(host,"Targeted social emotes received by this character are recorded automatically.",10,MUTED))
    subtitle:SetPoint("TOPLEFT",24,-50)

    local liteKit=RPAL.IsLiteKit and RPAL.IsLiteKit()
    local trpStatus="UNKNOWN"
    if RPAL.GetTRPCharacterStatus then trpStatus=select(1,RPAL.GetTRPCharacterStatus()) end
    local scoringText,scoringColor
    if liteKit then
        scoringText="LITE KIT — roleplay statistics active · progression disabled";scoringColor=MUTED
    elseif trpStatus=="IC" then
        scoringText="SCORING ACTIVE — In Character";scoringColor={0.48,0.82,0.48}
    elseif trpStatus=="OOC" then
        scoringText="SCORING PAUSED — Out of Character";scoringColor={0.90,0.48,0.48}
    else
        scoringText="SCORING PAUSED — IC status required";scoringColor=MUTED
    end
    local scoring=Keep(host,Font(host,scoringText,9,scoringColor));scoring:SetPoint("TOPLEFT",24,-70)

    local trpPrivacy=RPAL.GetProfilePrivacy and RPAL.GetProfilePrivacy() or {}
    -- Keep the Overview privacy toggle compact.  The old full label was wider
    -- than this column at common UI scales and visually spilled into the
    -- calendar rail.  Settings keeps the descriptive long-form label.
    local trpPublish=Keep(host,Check(host,"Show ODYN in TRP"))
    trpPublish:SetSize(205,28);trpPublish:SetPoint("TOPRIGHT",-24,-58);trpPublish:SetChecked(trpPrivacy.showTRPAdditionalInfo==true)
    trpPublish:SetScript("OnClick",function(self)
        local enabled=not self:GetChecked();self:SetChecked(enabled);trpPrivacy.showTRPAdditionalInfo=enabled
        if RPAL.RefreshTRPCompanionSummary then RPAL.RefreshTRPCompanionSummary(true) end
    end)

    local rule=Keep(host,host:CreateTexture(nil,"ARTWORK"));rule:SetTexture("Interface\\Buttons\\WHITE8X8");rule:SetHeight(1);rule:SetPoint("TOPLEFT",24,-91);rule:SetPoint("TOPRIGHT",-24,-91);rule:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],0.28)

    local hostWidth=math.max(720,(host.GetWidth and host:GetWidth()) or 800)
    local innerWidth=hostWidth-48
    local heroGap=12
    local heroCount=liteKit and 2 or 3
    local heroW=math.floor((innerWidth-(heroGap*(heroCount-1)))/heroCount)
    local met=Keep(host,CreateFrame("Frame",nil,host,"BackdropTemplate"));met:SetPoint("TOPLEFT",24,-101);met:SetSize(heroW,90);Backdrop(met,PAGE_BG_ALT,GOLD);PolishPanel(met,"card");AttachHoverGrow(met,0.022)
    local metIcon=RPAL.CreateStatMedallion(met,STAT_ICON.characters_met,GOLD,44);if metIcon then Keep(host,metIcon);metIcon:SetPoint("LEFT",10,0) end
    local metLabel=Keep(host,Font(met,"CHARACTERS MET",9,GOLD));metLabel:SetPoint("TOPLEFT",62,-15)
    local metValue=Keep(host,Heading(met,tostring(stats.charactersMet or 0),28));metValue:SetPoint("BOTTOMLEFT",62,14)
    local metHint=Keep(host,Font(met,"Unique RP contacts",8,MUTED));metHint:SetPoint("BOTTOMRIGHT",-10,16)

    local campaigns=Keep(host,CreateFrame("Frame",nil,host,"BackdropTemplate"));campaigns:SetPoint("LEFT",met,"RIGHT",heroGap,0);campaigns:SetSize(heroW,90);Backdrop(campaigns,PAGE_BG_ALT,GOLD);PolishPanel(campaigns,"card");AttachHoverGrow(campaigns,0.022)
    local campaignIcon=RPAL.CreateStatMedallion(campaigns,STAT_ICON.campaigns_joined,GOLD,44);if campaignIcon then Keep(host,campaignIcon);campaignIcon:SetPoint("LEFT",10,0) end
    local campaignLabel=Keep(host,Font(campaigns,"CAMPAIGNS JOINED",9,GOLD));campaignLabel:SetPoint("TOPLEFT",62,-15)
    local campaignValue=Keep(host,Heading(campaigns,tostring(stats.campaignsJoined or 0),28));campaignValue:SetPoint("BOTTOMLEFT",62,14)
    local campaignHint=Keep(host,Font(campaigns,"Unique DM Campaigns joined",8,MUTED));campaignHint:SetPoint("BOTTOMRIGHT",-10,16)

    if not liteKit then
        local renown=Keep(host,CreateFrame("Frame",nil,host,"BackdropTemplate"));renown:SetPoint("LEFT",campaigns,"RIGHT",heroGap,0);renown:SetSize(heroW,90);Backdrop(renown,PAGE_BG_ALT,GOLD);PolishPanel(renown,"card");AttachHoverGrow(renown,0.022)
        local renownIcon=RPAL.CreateStatMedallion(renown,STAT_ICON.renown,GOLD,44);if renownIcon then Keep(host,renownIcon);renownIcon:SetPoint("LEFT",10,0) end
        local renownLabel=Keep(host,Font(renown,"RENOWN",9,GOLD));renownLabel:SetPoint("TOPLEFT",62,-15)
        local renownValue=Keep(host,Heading(renown,tostring(stats.score or 0),28));renownValue:SetPoint("BOTTOMLEFT",62,14)
        local renownHint=Keep(host,Font(renown,tostring(stats.scoredInteractions or 0).." recorded",8,MUTED));renownHint:SetPoint("BOTTOMRIGHT",-10,16)
    end

    local gridTitle=Keep(host,Font(host,"SOCIAL INTERACTIONS",10,GOLD));gridTitle:SetPoint("TOPLEFT",24,-205)
    local gridMeta=Keep(host,Font(host,"TOP 20 EMOTES RECEIVED FROM OTHER PLAYERS",8,MUTED));gridMeta:SetPoint("TOPRIGHT",-24,-206)
    local gridRule=Keep(host,host:CreateTexture(nil,"ARTWORK"));gridRule:SetTexture("Interface\\Buttons\\WHITE8X8");gridRule:SetHeight(1);gridRule:SetPoint("LEFT",gridTitle,"RIGHT",12,0);gridRule:SetPoint("RIGHT",host,-24,0);gridRule:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],0.22)
    gridRule:ClearAllPoints();gridRule:SetPoint("LEFT",gridTitle,"RIGHT",12,0);gridRule:SetPoint("RIGHT",gridMeta,"LEFT",-12,0)

    local keys=RPAL.GetSortedStatisticKeys and RPAL.GetSortedStatisticKeys(stats,20) or RPAL.STAT_ORDER or {}
    local columns=5
    local gap=8
    local available=innerWidth
    local cardW=math.floor((available-(gap*(columns-1)))/columns)
    local cardH=52
    local startX=24
    local startY=-227

    for index,key in ipairs(keys) do
        local def=RPAL.STAT_DEFINITIONS[key] or {}
        local count=tonumber((stats.counts or {})[key]) or 0
        local row=math.floor((index-1)/columns)
        local col=(index-1)%columns
        local card=Keep(host,CreateFrame("Frame",nil,host,"BackdropTemplate"));card:SetSize(cardW,cardH);card:SetPoint("TOPLEFT",startX+(col*(cardW+gap)),startY-(row*(cardH+gap)));Backdrop(card,PAGE_BG_ALT,BORDER);PolishPanel(card,"card");AttachHoverGrow(card,0.035)
        local med=RPAL.CreateStatMedallion(card,STAT_ICON[key] or STAT_ICON.renown,GOLD,34);if med then Keep(host,med);med:SetPoint("LEFT",7,0) end
        local command=tostring(def.command or ("/"..key))
        local label=Keep(host,Font(card,command,12,PALE));label:SetPoint("LEFT",47,0);label:SetWidth(cardW-82);label:SetJustifyH("LEFT");label:SetWordWrap(false)
        local value=Keep(host,Heading(card,tostring(count),18));value:SetPoint("RIGHT",-8,0)
    end

    local rows=math.ceil(#keys/columns)
    local footerY=startY-(rows*(cardH+gap))-4
    local note=Keep(host,Font(host,liteKit and "Lite Kit records your roleplay activity without Renown, levels or talent progression." or "Each eligible interaction grants Renown according to your active rules and Atlas talents.",9,MUTED));note:SetPoint("TOP",0,footerY);note:SetWidth(760);note:SetJustifyH("CENTER")
end

function RPAL.ApplyExperienceModeUI(skipChromeRefresh)
    if not UI or not UI.main then return end
    local lite=RPAL.IsLiteKit and RPAL.IsLiteKit()

    -- Customize and Talent Atlas already live in the left book navigation.
    -- Never resurrect their legacy header duplicates over the profile glances.
    if UI.customize then UI.customize:Hide() end
    if UI.talentAtlas then UI.talentAtlas:Hide() end
    if UI.crestCapsule then UI.crestCapsule:SetShown(not lite) end
    if UI.controlsLabel then UI.controlsLabel:SetShown(not lite) end
    if UI.progression then UI.progression:SetShown(not lite) end

    if UI.settings and UI.tools and UI.customize then
        UI.settings:ClearAllPoints()
        if lite then UI.settings:SetPoint("RIGHT",UI.tools,"LEFT",-8,0)
        else UI.settings:SetPoint("RIGHT",UI.tools,"LEFT",-8,0) end
    end

    if UI.sceneQuick and UI.chronicle and UI.header and UI.talentAtlas then
        UI.sceneQuick:ClearAllPoints()
        UI.chronicle:ClearAllPoints()
        if lite then
            UI.sceneQuick:SetPoint("TOPRIGHT",UI.header,"TOPRIGHT",-38,-73)
            UI.chronicle:SetPoint("RIGHT",UI.sceneQuick,"LEFT",-8,0)
        else
            UI.sceneQuick:SetPoint("RIGHT",UI.crestCapsule,"LEFT",-8,0)
            UI.chronicle:SetPoint("RIGHT",UI.sceneQuick,"LEFT",-8,0)
        end
    end

    -- The Adventurer's Desk owns the main content anchors. Experience Mode
    -- changes visibility only; BookChromeUI performs the compact Lite geometry
    -- so side navigation/right sidebar never get overlapped.
    if not skipChromeRefresh and RPAL.RefreshBookChrome then pcall(RPAL.RefreshBookChrome) end
end

local function RenderScopeTabs()
    if not UI.characterTab or not UI.accountTab then return end

    if RPAL.scope=="account" then
        Backdrop(UI.characterTab,BUTTON_BG,BORDER)
        UI.characterTab.label:SetTextColor(unpack(MUTED))
        Backdrop(UI.accountTab,PANEL2,GOLD)
        UI.accountTab.label:SetTextColor(unpack(PALE))
        if UI.scopeHint then UI.scopeHint:SetText("ACCOUNT LEGACY  •  shared across every ODYN character and TRP identity") end
    else
        Backdrop(UI.characterTab,PANEL2,GOLD)
        UI.characterTab.label:SetTextColor(unpack(PALE))
        Backdrop(UI.accountTab,BUTTON_BG,BORDER)
        UI.accountTab.label:SetTextColor(unpack(MUTED))
        if UI.scopeHint then UI.scopeHint:SetText("CHARACTER BOOK  •  this TRP identity only") end
    end
end

local function Render()
    if not UI.ready or not UI.main or not UI.edit or not UI.edit.label or not UI.preview or not UI.editor then
        return
    end

    ApplyCurrentTheme()
    if RPAL.ApplyExperienceModeUI then RPAL.ApplyExperienceModeUI(true) end

    RenderPreview()
    RenderProgression()
    RenderScopeTabs()
    if RPAL.UpdateSurvivalWidget then RPAL.UpdateSurvivalWidget(0) end

    if UI.crestText and RPAL.GetCrests then
        UI.crestText:SetText(tostring(RPAL.GetCrests()))
        UI.crestText:SetTextColor(GOLD[1],GOLD[2],GOLD[3])
    end

    UI.edit.label:SetText(editMode and "Done" or "Edit")
    UI.ledgerToggle.label:SetText(ledgerMode and "Close Achievements" or "Achievements")

    if skillSetMode then
        UI.ledger:Hide()
        UI.statistics:Hide()
        UI.editor:Hide()
        if UI.addCategory then UI.addCategory:Hide() end
        if RPAL.RenderSkillSet then RPAL.RenderSkillSet() end
    elseif ledgerMode then
        UI.ledger:Show()
        UI.statistics:Hide()
        UI.editor:Hide()
        UI.addCategory:SetShown(editMode)
        RenderAchievements()
    elseif editMode then
        UI.ledger:Hide()
        UI.statistics:Hide()
        UI.editor:Show()
        RenderEditor()
    else
        UI.ledger:Hide()
        UI.editor:Hide()
        UI.statistics:Show()
        RenderStatistics()
    end

    -- Skill Set owns the same central content area as the other primary pages.
    -- Never leave its frame visible underneath a newly selected page.
    if not skillSetMode and RPAL.SkillSetUI and RPAL.SkillSetUI.frame then
        RPAL.SkillSetUI.frame:Hide()
    end

    -- Chronicle is a primary page layered inside UI.content. A normal data
    -- refresh must not bring Overview back over it or leave sibling pages
    -- accepting mouse input while Chronicle is open.
    if RPAL.IsChronicleEmbeddedOpen and RPAL.IsChronicleEmbeddedOpen() then
        UI.ledger:Hide()
        UI.editor:Hide()
        UI.statistics:Hide()
    end

    Backdrop(UI.ledgerToggle,ledgerMode and ACCENT_BG or BUTTON_BG,ledgerMode and GOLD or BORDER)
    -- Dynamic cards are born from the already-active palette. A full recursive
    -- theme walk belongs only to RefreshThemeVisuals; doing it after every data
    -- refresh doubled navigation work and caused avoidable frame-time spikes.
    if RPAL.RefreshBookChrome then RPAL.RefreshBookChrome(true) end
end
RPAL.Refresh=Render

function RPAL.IsSkillSetOpen()
    return skillSetMode == true
end

function RPAL.SetMainContentMode(mode,skipRefresh)
    mode=tostring(mode or "overview"):lower()
    skillSetMode=mode=="skills" or mode=="skillset"
    ledgerMode=mode=="achievements" or mode=="achievement"
    editMode=mode=="edit" or mode=="editor"
    if not skillSetMode and RPAL.SkillSetUI and RPAL.SkillSetUI.frame then RPAL.SkillSetUI.frame:Hide() end
    if not skipRefresh and RPAL.Refresh then RPAL.Refresh() end
    return true
end

function RPAL.SetSkillSetMode(open,skipRefresh)
    local desired=open and true or false
    if skillSetMode==desired then
        -- Idempotent calls must still repair visibility.  Data/theme refreshes
        -- can rebuild Overview children while Skill Set remains the active
        -- mode, so merely returning here would leave two page trees visible.
        if desired then
            if UI.ledger then UI.ledger:Hide() end
            if UI.statistics then UI.statistics:Hide() end
            if UI.editor then UI.editor:Hide() end
            if UI.addCategory then UI.addCategory:Hide() end
        end
        return false,"unchanged"
    end
    skillSetMode = desired
    if skillSetMode then
        ledgerMode=false
        editMode=false
        -- Apply exclusive page ownership immediately, including callers that
        -- intentionally defer the expensive render with skipRefresh=true.
        if UI.ledger then UI.ledger:Hide() end
        if UI.statistics then UI.statistics:Hide() end
        if UI.editor then UI.editor:Hide() end
        if UI.addCategory then UI.addCategory:Hide() end
    elseif RPAL.SkillSetUI and RPAL.SkillSetUI.frame then
        RPAL.SkillSetUI.frame:Hide()
    end
    if not skipRefresh and RPAL.Refresh then RPAL.Refresh() end
    return true
end

RPAL.OnCrestsChanged=function()
    if UI.crestText and RPAL.GetCrests then
        UI.crestText:SetText(tostring(RPAL.GetCrests()))
        UI.crestText:SetTextColor(GOLD[1],GOLD[2],GOLD[3])
    end
end

RPAL.OnStatisticsChanged=function()
    if UI.ready and UI.main and UI.main:IsShown() and not ledgerMode and not editMode then
        RenderStatistics()
    end
end

RPAL.OnRenownLevelUp=function(newLevel,oldLevel,source,rewards)
    -- HOTFIX20: use ODYN's authored level-up cue. Volume/mute are isolated
    -- from WoW's global SFX controls through the custom sound layer.
    if RPAL.PlayODYNSound then RPAL.PlayODYNSound("levelup") end
    if UI.ready and UI.main and UI.main:IsShown() then
        RenderProgression()
        RenderPreview()
    end
end

Modal = function(title,w,h)
    modalSerial=modalSerial+1
    rpalModalSerial=rpalModalSerial+1
    local modalBase=(RPAL.UI_LAYER and RPAL.UI_LAYER.MODAL and RPAL.UI_LAYER.MODAL.level) or 8000
    local baseLevel=modalBase+(#rpalModalStack*120)
    local modalFrameName="RPAL_Modal_"..tostring(rpalModalSerial)

    if RPAL.CloseTransientUI then RPAL.CloseTransientUI() else
        if RPAL.HideCursorTooltip then RPAL.HideCursorTooltip() end
        if GameTooltip and GameTooltip.Hide then GameTooltip:Hide() end
    end

    local shade=CreateFrame("Frame",modalFrameName,UIParent,"BackdropTemplate")
    shade:SetAllPoints()
    shade:SetFrameStrata("FULLSCREEN_DIALOG")
    shade:SetFrameLevel(baseLevel)
    shade:SetToplevel(true)
    shade:EnableMouse(true)
    Backdrop(shade,{0,0,0,.68},{0,0,0,0})

    -- Register the shade itself as a WoW special frame. ESC therefore closes
    -- the visible modal before it reaches RPAL_MainFrame behind it.
    RPAL.RegisterEscapeFrame(shade)
    table.insert(rpalModalStack,shade)

    local m=CreateFrame("Frame",nil,shade,"BackdropTemplate")
    m:SetSize(w,h)
    m:SetPoint("CENTER")
    m:SetScale(GetUIScaleSetting())
    m:SetFrameLevel(baseLevel+10)
    m:SetToplevel(true)
    m:EnableMouse(true)
    Backdrop(m,PANEL2,GOLD)
    AddFrameShadow(m,12,0.70)
    local modalPaper=m:CreateTexture(nil,"BACKGROUND",nil,-2)
    modalPaper:SetTexture(TEX.."TravelBookTheme\\UI_TravelBook_Popup01a.tga")
    modalPaper:SetPoint("TOPLEFT",5,-5);modalPaper:SetPoint("BOTTOMRIGHT",-5,5)
    modalPaper:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],1);modalPaper:SetAlpha(0.15)
    m._rpalModalPaper=modalPaper
    AddPageDecoration(m)
    PolishPanel(m,"modal")

    local titleBand=m:CreateTexture(nil,"BACKGROUND",nil,1)
    titleBand:SetTexture("Interface\\Buttons\\WHITE8X8")
    titleBand:SetColorTexture(MAIN_BG[1],MAIN_BG[2],MAIN_BG[3],0.62)
    titleBand:SetPoint("TOPLEFT",7,-7)
    titleBand:SetPoint("TOPRIGHT",-7,-7)
    titleBand:SetHeight(35)
    m._rpalModalTitleBand=titleBand

    local t=Heading(m,title,18);t:SetPoint("TOPLEFT",18,-15)
    m._rpalModalTitle=t
    local modalRule=m:CreateTexture(nil,"ARTWORK")
    modalRule:SetTexture("Interface\\Buttons\\WHITE8X8")
    modalRule:SetColorTexture(1,1,1,0.28)
    modalRule:SetHeight(1)
    modalRule:SetPoint("TOPLEFT",14,-43);modalRule:SetPoint("TOPRIGHT",-14,-43)
    modalRule._rpalDecorRole="ACCENT"
    m._rpalModalRule=modalRule
    local close=CloseButton(m)
    close:SetPoint("TOPRIGHT",-10,-10)
    close:SetFrameLevel(m:GetFrameLevel()+20)
    close:SetScript("OnClick",function()shade:Hide()end)
    m._rpalModalClose=close

    shade:HookScript("OnHide",function(self)
        if RPAL.CloseOpenDropdown then RPAL.CloseOpenDropdown() end
        for i=#rpalModalStack,1,-1 do
            if rpalModalStack[i]==self then
                table.remove(rpalModalStack,i)
                break
            end
        end
    end)

    -- Soft entrance transition used by every RPAL modal. It only animates
    -- alpha, so anchors/layout and input behavior remain untouched.
    if RPAL.AnimateFrameIn then RPAL.AnimateFrameIn(shade,0.16,0) else shade:SetAlpha(1) end

    shade.modal=m
    return shade,m
end
RPAL.CreateModal=Modal

-- -------------------------------------------------------------------------
-- First-run Experience Setup
-- -------------------------------------------------------------------------
local function OpenMainAfterSetupFlow()
    local function Open()
        if RPAL.SyncTRPProfileScope then pcall(RPAL.SyncTRPProfileScope,true) end
        if RPAL.EnsureActiveTRPProfileImported then pcall(RPAL.EnsureActiveTRPProfileImported,true) end
        local settings=RPAL.GetSettings and RPAL.GetSettings() or nil
        if settings and settings.autoTRPUpdate and RPAL.StartTRPAutoUpdate then pcall(RPAL.StartTRPAutoUpdate,true) end
        if not (UI and UI.main) then return end
        UI.main:Show()
        if RPAL.Refresh then RPAL.Refresh() end
        if RPAL.RefreshBookChrome then RPAL.RefreshBookChrome() end
    end
    if C_Timer and C_Timer.After then C_Timer.After(0.08,Open) else Open() end
end

local function CloseSetupOverlay(frame)
    if not frame then return end
    if frame.EnableMouse then frame:EnableMouse(false) end
    if frame.SetScript then frame:SetScript("OnUpdate",nil) end
    if frame.SetAlpha then frame:SetAlpha(0) end
    if frame.Hide then frame:Hide() end
    if frame._odynDisposable and frame.SetParent then frame:SetParent(nil) end
end

local function InlineSetupDecision(parent,title,width,height)
    if not parent then return nil,nil end
    local blocker=CreateFrame("Button",nil,parent,"BackdropTemplate")
    blocker:SetAllPoints(parent)
    blocker._odynDisposable=true
    blocker:SetFrameLevel((parent:GetFrameLevel() or 1)+60)
    blocker:EnableMouse(true)
    blocker:RegisterForClicks("LeftButtonUp")
    blocker:SetScript("OnClick",function() end)
    if blocker.SetBackdrop then
        blocker:SetBackdrop({bgFile="Interface\\Buttons\\WHITE8X8"})
        blocker:SetBackdropColor(0,0,0,0.72)
    end

    local box=CreateFrame("Frame",nil,blocker,"BackdropTemplate")
    box:SetSize(width or 520,height or 250)
    box:SetPoint("CENTER",parent,"CENTER",0,0)
    box:SetFrameLevel(blocker:GetFrameLevel()+2)
    Backdrop(box,PANEL2,GOLD)
    PolishPanel(box,"card")
    AddFrameShadow(box,10,0.55)

    local heading=Heading(box,title or "CONFIRM",16)
    heading:SetPoint("TOP",0,-22)
    local rule=box:CreateTexture(nil,"ARTWORK")
    rule:SetTexture("Interface\\Buttons\\WHITE8X8")
    rule:SetHeight(1)
    rule:SetPoint("TOPLEFT",20,-50)
    rule:SetPoint("TOPRIGHT",-20,-50)
    rule:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],0.35)
    return blocker,box
end

function RPAL.CloseSetupFlowOverlays()
    CloseSetupOverlay(RPAL._experienceLiteConfirmShade)
    CloseSetupOverlay(RPAL._activeLegacyChoiceShade)
    CloseSetupOverlay(RPAL._experienceSetupShade)
    CloseSetupOverlay(RPAL._activeLegacyCalibrationShade)
    RPAL._experienceLiteConfirmShade=nil
    RPAL._activeLegacyChoiceShade=nil
end

local function ExperienceSetupComplete()
    local settings=RPAL.GetSettings and RPAL.GetSettings() or {}
    return (tonumber(settings.experienceSetupVersion) or 0)>=(tonumber(RPAL.EXPERIENCE_SETUP_VERSION) or 1)
end

local function FinishExperienceSetup(mode,setupShade)
    local ok=RPAL.SetExperienceMode and select(1,RPAL.SetExperienceMode(mode))
    if not ok then return false end
    CloseSetupOverlay(RPAL._experienceLiteConfirmShade);RPAL._experienceLiteConfirmShade=nil
    if setupShade then CloseSetupOverlay(setupShade) end
    if mode=="full" then
        RPAL._openMainAfterCalibration=true
        local function Continue()
            local opened=RPAL.MaybePromptLegacyCalibration and RPAL.MaybePromptLegacyCalibration()
            if not opened then
                RPAL._openMainAfterCalibration=nil
                OpenMainAfterSetupFlow()
            end
        end
        if C_Timer and C_Timer.After then C_Timer.After(0.28,Continue) else Continue() end
    else
        RPAL._openMainAfterCalibration=nil
        OpenMainAfterSetupFlow()
    end
    return true
end

function RPAL.OpenExperienceSetup(force)
    CloseSetupOverlay(RPAL._experienceLiteConfirmShade);RPAL._experienceLiteConfirmShade=nil
    CloseSetupOverlay(RPAL._activeLegacyChoiceShade);RPAL._activeLegacyChoiceShade=nil
    if RPAL._experienceSetupShade and RPAL._experienceSetupShade:IsShown() then
        if RPAL._experienceSetupShade.Raise then RPAL._experienceSetupShade:Raise() end
        return RPAL._experienceSetupShade,RPAL._experienceSetupShade.modal
    end
    if not force and ExperienceSetupComplete() then return false,"already configured" end

    local shade,m=Modal("CUSTOMIZE YOUR ODYN EXPERIENCE",920,590)
    RPAL._experienceSetupShade=shade

    -- This is a required setup gate, not an informational popup. Remove both
    -- close-button and native ESC registration so an explicit kit choice is
    -- always made before the screen can disappear.
    if m._rpalModalClose then m._rpalModalClose:Hide();m._rpalModalClose:Disable() end
    local shadeName=shade.GetName and shade:GetName()
    if shadeName and UISpecialFrames then
        for i=#UISpecialFrames,1,-1 do
            if UISpecialFrames[i]==shadeName then table.remove(UISpecialFrames,i) end
        end
    end
    shade._rpalEscapeRegistered=nil
    shade:HookScript("OnHide",function(self)
        if RPAL._experienceLiteConfirmShade then CloseSetupOverlay(RPAL._experienceLiteConfirmShade) end
        if RPAL._experienceSetupShade==self then RPAL._experienceSetupShade=nil end
    end)

    local intro=Font(m,"Choose the version of ODYN that fits how you roleplay. You can change this later from Settings → Re-run Setup.",10.5,PALE)
    intro:SetPoint("TOPLEFT",24,-58);intro:SetPoint("TOPRIGHT",-24,-58);intro:SetWordWrap(true);intro:SetJustifyH("CENTER")
    local sub=Font(m,"No character data is deleted when switching kits.",9,GREEN,DEFAULT_FONT)
    sub:SetPoint("TOP",intro,"BOTTOM",0,-8)

    local configured=ExperienceSetupComplete()
    local current=RPAL.GetExperienceMode and RPAL.GetExperienceMode() or "full"

    local liteFeatures={
        "Roleplay statistics & social interaction tracking",
        "Sessions, Saved Sessions & Chronicle memories",
        "Achievements, Skill Set, Calendar & Injuries",
        "DM Kit, Campaigns and shared RP tools",
        "Hearthstone, Chess and Checkers — online tables and bot practice",
        "Profile, TRP integration, Bonds & Story Arcs",
        "Progression, Talent Atlas and customization disabled",
    }
    local fullFeatures={
        "Everything included in Lite Kit",
        "Renown, levels and progression rewards",
        "Talent Atlas — 1 Talent Point every 2 levels",
        "Full customization, cosmetics and unlock collection",
        "Tokens, level badges and progression presentation",
        "The complete standard ODYN experience",
    }

    local function MakeFeatureCard(x,w,number,title,subtitle,body,features,recommended,mode)
        local card=CreateFrame("Button",nil,m,"BackdropTemplate")
        card:SetSize(w,410);card:SetPoint("TOPLEFT",x,-126);card:RegisterForClicks("LeftButtonUp")
        Backdrop(card,PANEL2,recommended and GOLD or BORDER);PolishPanel(card,"card")
        AddFrameShadow(card,recommended and 10 or 7,recommended and 0.60 or 0.40)
        if AttachGentleFloat then AttachGentleFloat(card,recommended and 2 or 1,recommended) end

        local n=Font(card,number,8,MUTED,DEFAULT_FONT);n:SetPoint("TOPLEFT",15,-14)
        local heading=Heading(card,title,20);heading:SetPoint("TOPLEFT",15,-37)
        local st=Font(card,subtitle,9,recommended and GOLD or MUTED,DEFAULT_FONT);st:SetPoint("TOPLEFT",15,-68);st:SetWidth(w-30);st:SetWordWrap(false)
        local rule=card:CreateTexture(nil,"ARTWORK");rule:SetTexture("Interface\\Buttons\\WHITE8X8");rule:SetHeight(1);rule:SetPoint("TOPLEFT",15,-94);rule:SetPoint("TOPRIGHT",-15,-94);rule:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],recommended and 0.48 or 0.24)

        local bodyFS=Font(card,body,9.5,recommended and PALE or MUTED)
        bodyFS:SetPoint("TOPLEFT",15,-108);bodyFS:SetWidth(w-30);bodyFS:SetWordWrap(true);bodyFS:SetJustifyH("LEFT")

        local included=Font(card,"WHAT YOU GET",8.5,recommended and GOLD or MUTED,DEFAULT_FONT)
        included:SetPoint("TOPLEFT",15,-183)
        -- Keep the feature list inside a dedicated vertical band.  The old
        -- 26px step pushed the sixth entry into the status/button block at
        -- the bottom of the setup card, causing the two FontStrings to draw
        -- on top of one another.  22px still reads comfortably while leaving
        -- a guaranteed gap above the selection controls.
        local y=-204
        local FEATURE_STEP=(#features>6) and 20 or 22
        for _,line in ipairs(features) do
            local bullet=Font(card,"•",10,recommended and GOLD or MUTED,DEFAULT_FONT);bullet:SetPoint("TOPLEFT",18,y)
            local txt=Font(card,line,8.7,(line:find("disabled",1,true) and {0.82,0.52,0.52}) or PALE,DEFAULT_FONT)
            txt:SetPoint("TOPLEFT",32,y);txt:SetWidth(w-50);txt:SetWordWrap(false)
            y=y-FEATURE_STEP
        end

        if recommended then
            local tag=CreateFrame("Frame",nil,card,"BackdropTemplate");tag:SetSize(110,22);tag:SetPoint("TOPRIGHT",-13,-13);Backdrop(tag,MAIN_BG,GOLD)
            local tagText=Font(tag,"RECOMMENDED",8,GOLD,DEFAULT_FONT);tagText:SetPoint("CENTER",0,0)
            local halo=card:CreateTexture(nil,"BACKGROUND",nil,-1);halo:SetTexture(TEX.."Talent\\sparkle.tga");halo:SetBlendMode("ADD");halo:SetPoint("TOPLEFT",3,-3);halo:SetPoint("BOTTOMRIGHT",-3,3);halo:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],0.11)
            local ag=halo:CreateAnimationGroup();ag:SetLooping("BOUNCE");local a=ag:CreateAnimation("Alpha");a:SetFromAlpha(0.07);a:SetToAlpha(0.18);a:SetDuration(1.35);a:SetSmoothing("IN_OUT");ag:Play();card._rpalSetupGlow=ag
        end

        local isCurrent=configured and current==mode
        local choose=Button(card,isCurrent and ("Keep "..title) or ("Choose "..title),recommended and 184 or 172,34,recommended)
        choose:SetPoint("BOTTOM",0,20)
        local status=Font(card,isCurrent and "CURRENT EXPERIENCE" or (recommended and "STANDARD EXPERIENCE" or "COMPACT EXPERIENCE"),8,isCurrent and GOLD or MUTED,DEFAULT_FONT)
        status:SetPoint("BOTTOM",choose,"TOP",0,9)
        return card,choose
    end

    local lite,liteButton=MakeFeatureCard(24,414,"01","Lite Kit","COMPACT ROLEPLAY TOOLKIT",
        "Removes leveling, talents and customization from your experience, offering a much more compact RP tool oriented way of use. Only recommended for players who do not give a rat about the whole progress aspect!",liteFeatures,false,"lite")
    local full,fullButton=MakeFeatureCard(482,414,"02","Full Kit","THE COMPLETE ODYN EXPERIENCE",
        "Includes every single function that comes with ODYN, the standard and RECOMMENDED experience.",fullFeatures,true,"full")

    local function ConfirmLite()
        if RPAL._experienceLiteConfirmShade and RPAL._experienceLiteConfirmShade:IsShown() then
            return
        end
        -- Keep the confirmation inside the setup panel. A second full-screen
        -- modal used to leave an invisible mouse-capturing shade behind on
        -- some clients after choosing Lite Kit. The inline blocker cannot
        -- outlive the parent setup frame and therefore cannot lock the UI.
        local confirmShade,confirm=InlineSetupDecision(m,"ARE YOU SURE?",570,250)
        if not confirmShade then return end
        RPAL._experienceLiteConfirmShade=confirmShade
        confirmShade:HookScript("OnHide",function(self)
            if self.EnableMouse then self:EnableMouse(false) end
            if RPAL._experienceLiteConfirmShade==self then RPAL._experienceLiteConfirmShade=nil end
        end)
        local warning=Font(confirm,"Are you sure? I worked very hard on progression and you not using it would break my heart :(",13,PALE,DEFAULT_FONT)
        warning:SetPoint("TOPLEFT",26,-72);warning:SetPoint("TOPRIGHT",-26,-72);warning:SetWordWrap(true);warning:SetJustifyH("CENTER")
        local back=Button(confirm,"Go Back",150,34,false);back:SetPoint("BOTTOMLEFT",58,24);back:SetScript("OnClick",function()CloseSetupOverlay(confirmShade)end)
        local use=Button(confirm,"Use Lite Kit",170,34,true);use:SetPoint("BOTTOMRIGHT",-58,24);use:SetScript("OnClick",function(self)
            if self._odynApplying then return end
            self._odynApplying=true;self:Disable()
            CloseSetupOverlay(confirmShade)
            FinishExperienceSetup("lite",shade)
        end)
    end

    lite:SetScript("OnClick",ConfirmLite);liteButton:SetScript("OnClick",ConfirmLite)
    local function ChooseFull()FinishExperienceSetup("full",shade)end
    full:SetScript("OnClick",ChooseFull);fullButton:SetScript("OnClick",ChooseFull)

    local footer=Font(m,"Lite Kit never deletes your Renown, talents or customization. Returning to Full Kit restores them exactly as you left them.",8.8,MUTED)
    footer:SetPoint("BOTTOMLEFT",24,16);footer:SetPoint("BOTTOMRIGHT",-24,16);footer:SetJustifyH("CENTER");footer:SetWordWrap(true)
    return shade,m
end

function RPAL.MaybeOpenExperienceSetup()
    if ExperienceSetupComplete() then return false end
    return RPAL.OpenExperienceSetup(false)
end


function RPAL.ShowSeasonStartedReloadNotification(season)
    if RPAL._seasonStartedReloadNotice and RPAL._seasonStartedReloadNotice:IsShown() then return RPAL._seasonStartedReloadNotice end
    local f=RPAL._seasonStartedReloadNotice
    if not f then
        f=CreateFrame("Frame","ODYN_SeasonStartedReloadNotice",UIParent,"BackdropTemplate")
        RPAL._seasonStartedReloadNotice=f
        f:SetSize(342,142);f:SetFrameStrata("DIALOG");f:SetFrameLevel(920);f:SetClampedToScreen(true)
        f:SetPoint("RIGHT",UIParent,"RIGHT",-34,64)
        Backdrop(f,{0.055,0.028,0.014,0.985},GOLD);PolishPanel(f,"card")
        local icon=f:CreateTexture(nil,"ARTWORK");icon:SetTexture(RPAL.ICONS.star);icon:SetSize(30,30);icon:SetPoint("TOPLEFT",16,-17);icon:SetVertexColor(1.00,0.72,0.24,1)
        local eyebrow=Font(f,"SEASON STARTED",8.5,GOLD,DEFAULT_FONT);eyebrow:SetPoint("TOPLEFT",55,-15)
        local title=Heading(f,"Maplefall is now live",14);title:SetPoint("TOPLEFT",55,-31);title:SetPoint("RIGHT",-18,0)
        f.title=title
        local body=Font(f,"Run /reload to activate the new Season state, quests and rewards.",9,PALE);body:SetPoint("TOPLEFT",17,-66);body:SetPoint("RIGHT",-17,0);body:SetWordWrap(true);f.body=body
        local reload=Button(f,"Reload UI",112,30,true);reload:SetPoint("BOTTOMRIGHT",-15,12);reload:SetScript("OnClick",function()if ReloadUI then ReloadUI() end end)
        local later=Button(f,"Later",82,30,false);later:SetPoint("RIGHT",reload,"LEFT",-8,0);later:SetScript("OnClick",function()f:Hide()end)
        f.reload=reload;f.later=later
    end
    local name=season and season.name or "Maplefall"
    if f.title then f.title:SetText(tostring(name).." is now live") end
    f:Show();f:Raise()
    if RPAL.AnimateFrameIn then RPAL.AnimateFrameIn(f,0.16,0) end
    return f
end

-- In-addon release summary shown once after an update.
RPAL.PATCH_NOTES_VERSION="1.8.1-hotfix10"

function RPAL.OpenPatchNotes()
    if RPAL._activePatchNotesShade and RPAL._activePatchNotesShade:IsShown() then
        if RPAL._activePatchNotesShade.Raise then RPAL._activePatchNotesShade:Raise() end
        return RPAL._activePatchNotesShade,RPAL._activePatchNotesShade.modal
    end

    local shade,m=Modal("WHAT'S NEW IN ODYN 1.8.1",790,548)
    RPAL._activePatchNotesShade=shade
    shade:HookScript("OnHide",function(self)
        if RPAL._activePatchNotesShade==self then RPAL._activePatchNotesShade=nil end
    end)

    local intro=Font(m,"ODYN's Eye, Platforms, Effects and Atmospheres receive a ground-up visual and motion overhaul.",10,PALE)
    intro:SetPoint("TOPLEFT",24,-58);intro:SetPoint("TOPRIGHT",-24,-58);intro:SetWordWrap(true)

    local function Section(y,title,lines)
        local card=CreateFrame("Frame",nil,m,"BackdropTemplate")
        card:SetPoint("TOPLEFT",22,y);card:SetPoint("TOPRIGHT",-22,y);card:SetHeight(110)
        Backdrop(card,PANEL2,BORDER);PolishPanel(card,"card")
        local heading=Font(card,title,10,GOLD,DEFAULT_FONT);heading:SetPoint("TOPLEFT",14,-12)
        local rule=card:CreateTexture(nil,"ARTWORK");rule:SetTexture("Interface\\Buttons\\WHITE8X8");rule:SetHeight(1);rule:SetPoint("TOPLEFT",14,-31);rule:SetPoint("TOPRIGHT",-14,-31);rule:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],0.30)
        for index,line in ipairs(lines) do
            local row=Font(card,"•  "..line,8.8,index==1 and PALE or MUTED)
            row:SetPoint("TOPLEFT",16,-42-((index-1)*20));row:SetPoint("RIGHT",-14,0);row:SetWordWrap(false)
        end
        return card
    end

    Section(-96,"ODYN'S EYE",{
        "Focused Gaze is retired; Northstar Eye is now Sunwell's Eye.",
        "Hover any Wardrobe Eye to replay its unique motion, including locked rewards.",
        "Both ODYN HUD orbs grow on hover and perform the equipped Eye's animation when clicked.",
    })
    Section(-214,"PLATFORMS + EFFECTS",{
        "Seven Platforms now use separate authored silhouettes and individual motion profiles.",
        "Fourteen new particle sprites replace the shared dots, diamonds and square construction pieces.",
        "Rain, frost, flame, fireflies, petals, wisps, crescents and maple leaves now read distinctly.",
    })
    Section(-332,"ATMOSPHERES + MIGRATION",{
        "Six new authored scenes join the collection, from Rainwashed Window to Scarlet Eclipse.",
        "Legacy Eye purchases are refunded once at their previous visible Token price.",
        "Achievement hover cards now list every cosmetic they unlock.",
    })

    local hint=Font(m,"ODYN 1.8.1 • Persona Beta 0.6.4 • Maplefall",8.6,MUTED)
    hint:SetPoint("BOTTOMLEFT",24,55);hint:SetPoint("BOTTOMRIGHT",-180,55);hint:SetWordWrap(false)
    local done=Button(m,"Got it",118,32,true);done:SetPoint("BOTTOMRIGHT",-22,18);done:SetScript("OnClick",function()shade:Hide()end)

    local settings=RPAL.GetSettings and RPAL.GetSettings() or nil
    if settings then settings.patchNotesSeenVersion=RPAL.PATCH_NOTES_VERSION end
    return shade,m
end

function RPAL.MaybeShowPatchNotes(force)
    if not (UI and UI.main and UI.main:IsShown()) then return false,"main_closed" end
    local settings=RPAL.GetSettings and RPAL.GetSettings() or {}
    if not force and tostring(settings.patchNotesSeenVersion or "")==tostring(RPAL.PATCH_NOTES_VERSION) then return false,"already_seen" end

    -- Do not stack the release summary over setup/calibration or another active modal.
    -- Retry while the main frame stays open so the notice is not lost merely
    -- because another first-run surface had presentation priority.
    if #rpalModalStack>0 or (RPAL._experienceSetupShade and RPAL._experienceSetupShade:IsShown())
        or (RPAL._activeLegacyCalibrationShade and RPAL._activeLegacyCalibrationShade:IsShown()) then
        if not RPAL._patchNotesRetryQueued and C_Timer and C_Timer.After then
            RPAL._patchNotesRetryQueued=true
            C_Timer.After(0.50,function()
                RPAL._patchNotesRetryQueued=nil
                if UI and UI.main and UI.main:IsShown() and RPAL.MaybeShowPatchNotes then RPAL.MaybeShowPatchNotes(force) end
            end)
        end
        return false,"modal_busy"
    end
    return RPAL.OpenPatchNotes()
end

function RPAL.OpenLegacyCalibration()
    local calibration=RPAL.GetLegacyCalibration and RPAL.GetLegacyCalibration()
    if not calibration then return false,"Character data is not ready." end
    if calibration.completed then
        if RPAL.ShowSessionStatusToast then RPAL.ShowSessionStatusToast("Legacy Calibration is already complete for this TRP profile.","PAUSED") end
        return false,"already complete"
    end
    if RPAL._activeLegacyCalibrationShade and RPAL._activeLegacyCalibrationShade:IsShown() then
        if RPAL._activeLegacyCalibrationShade.Raise then RPAL._activeLegacyCalibrationShade:Raise() end
        return RPAL._activeLegacyCalibrationShade,RPAL._activeLegacyCalibrationShade.modal
    end

    local shade,m=Modal("LEGACY CALIBRATION",880,530)
    RPAL._activeLegacyCalibrationShade=shade
    local requiredFirstRun=RPAL._openMainAfterCalibration and true or false
    if requiredFirstRun then
        -- During the first-run chain this is a required second step. Do not let
        -- ESC/X tear down half the flow; Settings-opened calibration remains
        -- normally closable.
        if m._rpalModalClose then m._rpalModalClose:Hide();m._rpalModalClose:Disable() end
        local shadeName=shade.GetName and shade:GetName()
        if shadeName and UISpecialFrames then
            for i=#UISpecialFrames,1,-1 do
                if UISpecialFrames[i]==shadeName then table.remove(UISpecialFrames,i) end
            end
        end
        shade._rpalEscapeRegistered=nil
    end
    shade:HookScript("OnHide",function(self)
        if RPAL._activeLegacyChoiceShade then CloseSetupOverlay(RPAL._activeLegacyChoiceShade) end
        RPAL._legacyCalibrationApplying=nil
        if self.EnableMouse then self:EnableMouse(false) end
        if RPAL._activeLegacyCalibrationShade==self then RPAL._activeLegacyCalibrationShade=nil end
        if RPAL._openMainAfterCalibration then
            RPAL._openMainAfterCalibration=nil
            OpenMainAfterSetupFlow()
        end
    end)

    local intro=Font(m,"Choose the honest starting point for this character. This can be done only once for the active TRP profile.",10.5,PALE)
    intro:SetPoint("TOPLEFT",22,-58);intro:SetPoint("TOPRIGHT",-22,-58);intro:SetWordWrap(true);intro:SetJustifyH("LEFT")
    local promise=Font(m,"Recorded emotes, characters met, Bonds and Session history remain untouched.",9,GREEN,DEFAULT_FONT)
    promise:SetPoint("TOPLEFT",22,-92);promise:SetPoint("RIGHT",-22,0);promise:SetWordWrap(true)

    local definitions={
        {key="fresh",title="FRESH",subtitle="A new character",level="No baseline boost",catchup="No catch-up modifier",body="Begin exactly where this profile is now. Best for a newly created character."},
        {key="established",title="ESTABLISHED",subtitle="An ongoing character",level="Minimum Renown Level 5",catchup="+10% for 8 earned Sessions",body="Recognizes prior roleplay without inventing interactions. Includes the Legacy Chronicle Badge."},
        {key="veteran",title="VETERAN",subtitle="A long-running character",level="Minimum Renown Level 10",catchup="+15% for 12 earned Sessions",body="A capped veteran baseline, limited catch-up and the Legacy Chronicle Badge."},
    }

    local legacyChooseButtons={}
    local function SetLegacyChoiceButtonsEnabled(enabled)
        for _,button in ipairs(legacyChooseButtons) do
            if enabled then button:Enable() else button:Disable() end
        end
    end

    local function ConfirmChoice(definition)
        if RPAL._activeLegacyChoiceShade and RPAL._activeLegacyChoiceShade:IsShown() then return end
        if RPAL._legacyCalibrationApplying then return end
        SetLegacyChoiceButtonsEnabled(false)
        -- Inline confirmation avoids stacking a second fullscreen modal over
        -- calibration. This removes the duplicate/stranded popup path reported
        -- by first-run players.
        local confirmShade,confirm=InlineSetupDecision(m,"CONFIRM "..definition.title,500,250)
        if not confirmShade then SetLegacyChoiceButtonsEnabled(true);return end
        RPAL._activeLegacyChoiceShade=confirmShade
        confirmShade:HookScript("OnHide",function(self)
            if self.EnableMouse then self:EnableMouse(false) end
            if RPAL._activeLegacyChoiceShade==self then RPAL._activeLegacyChoiceShade=nil end
            if not RPAL._legacyCalibrationApplying then SetLegacyChoiceButtonsEnabled(true) end
        end)
        local warning=Font(confirm,"This choice is permanent for the active TRP profile.",11.5,PALE)
        warning:SetPoint("TOPLEFT",20,-72);warning:SetPoint("RIGHT",-20,0);warning:SetWordWrap(true)
        local selected=Font(confirm,definition.title.."  •  "..definition.level.."\n"..definition.catchup,9.5,GOLD,DEFAULT_FONT)
        selected:SetPoint("TOPLEFT",20,-112);selected:SetPoint("RIGHT",-20,0);selected:SetWordWrap(true);selected:SetJustifyH("LEFT")
        local cancel=Button(confirm,"Back",92,31,false);cancel:SetPoint("BOTTOMRIGHT",-124,18);cancel:SetScript("OnClick",function()CloseSetupOverlay(confirmShade)end)
        local apply=Button(confirm,"Apply Once",102,31,true);apply:SetPoint("BOTTOMRIGHT",-18,18)
        apply:SetScript("OnClick",function(self)
            if RPAL._legacyCalibrationApplying then return end
            RPAL._legacyCalibrationApplying=true;self:Disable();cancel:Disable()
            local ok,result=RPAL.ApplyLegacyCalibration and RPAL.ApplyLegacyCalibration(definition.key)
            if not ok then
                RPAL._legacyCalibrationApplying=nil;self:Enable();cancel:Enable();SetLegacyChoiceButtonsEnabled(true)
                print("|cffff7777ODYN:|r "..tostring(result or "Calibration could not be applied."));return
            end
            CloseSetupOverlay(confirmShade)
            CloseSetupOverlay(shade)
            RPAL._legacyCalibrationApplying=nil
            if UI.customizationFrame and UI.customizationFrame:IsShown() then RPAL.OpenCustomization("Badges") end
        end)
    end

    for index,definition in ipairs(definitions) do
        local card=CreateFrame("Frame",nil,m,"BackdropTemplate")
        card:SetSize(262,330);card:SetPoint("TOPLEFT",22+((index-1)*280),-132)
        Backdrop(card,PANEL2,index==3 and GOLD or BORDER);PolishPanel(card,"card");AttachGentleFloat(card,3,true)
        local number=Font(card,"0"..index,8,MUTED,DEFAULT_FONT);number:SetPoint("TOPLEFT",14,-13)
        local title=Heading(card,definition.title,15);title:SetPoint("TOPLEFT",14,-38)
        local subtitle=Font(card,definition.subtitle,9,MUTED);subtitle:SetPoint("TOPLEFT",14,-66);subtitle:SetWidth(232);subtitle:SetWordWrap(false)
        local rule=card:CreateTexture(nil,"ARTWORK");rule:SetTexture("Interface\\Buttons\\WHITE8X8");rule:SetHeight(1);rule:SetPoint("TOPLEFT",14,-91);rule:SetPoint("TOPRIGHT",-14,-91);rule:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],0.28)
        local level=Font(card,definition.level,9.5,GOLD,DEFAULT_FONT);level:SetPoint("TOPLEFT",14,-111);level:SetWidth(232);level:SetWordWrap(true)
        local catchup=Font(card,definition.catchup,8.5,GREEN,DEFAULT_FONT);catchup:SetPoint("TOPLEFT",14,-145);catchup:SetWidth(232);catchup:SetWordWrap(true)
        local body=Font(card,definition.body,9,MUTED);body:SetPoint("TOPLEFT",14,-190);body:SetWidth(232);body:SetWordWrap(true);body:SetJustifyH("LEFT")
        local choose=Button(card,"Choose "..definition.title:sub(1,1)..definition.title:sub(2):lower(),150,31,index==3)
        legacyChooseButtons[#legacyChooseButtons+1]=choose
        local choice=definition
        choose:SetPoint("BOTTOM",0,16);choose:SetScript("OnClick",function()ConfirmChoice(choice)end)
    end
    local footer=Font(m,"Legacy Renown does not award retroactive Tokens and cannot be repeated across reloads.",8.5,MUTED)
    footer:SetPoint("BOTTOMLEFT",22,19);footer:SetPoint("RIGHT",-22,0);footer:SetWordWrap(true)
    return shade,m
end

function RPAL.MaybePromptLegacyCalibration()
    local settings=RPAL.GetSettings and RPAL.GetSettings() or {}
    if (tonumber(settings.experienceSetupVersion) or 0)<(tonumber(RPAL.EXPERIENCE_SETUP_VERSION) or 1) then return false end
    if RPAL.IsLiteKit and RPAL.IsLiteKit() then return false end
    local calibration=RPAL.GetLegacyCalibration and RPAL.GetLegacyCalibration()
    if not calibration or calibration.completed or calibration.promptedAt then return false end
    local opened,detail=RPAL.OpenLegacyCalibration()
    if opened then calibration.promptedAt=time() end
    return opened,detail
end

function RPAL.ShowMainCharacterCeremony(name)
    if RPAL._activeMainCeremonyShade and RPAL._activeMainCeremonyShade:IsShown() then RPAL._activeMainCeremonyShade:Hide() end
    name=tostring(name or (RPAL.FullName and RPAL.FullName()) or "This character")
    local shade,m=Modal("MAIN CHARACTER",900,540)
    RPAL._activeMainCeremonyShade=shade
    shade:HookScript("OnHide",function(self)if RPAL._activeMainCeremonyShade==self then RPAL._activeMainCeremonyShade=nil end end)
    if PlaySound then pcall(PlaySound,888,"SFX") end

    local stage=CreateFrame("Frame",nil,m,"BackdropTemplate");stage:SetPoint("TOPLEFT",22,-58);stage:SetPoint("BOTTOMLEFT",22,58);stage:SetWidth(430)
    Backdrop(stage,MAIN_BG,GOLD);PolishPanel(stage,"card");stage:SetClipsChildren(true)
    local glow=stage:CreateTexture(nil,"BACKGROUND");glow:SetTexture(TEX.."Cosmetics\\aura_soft_ring.tga");glow:SetBlendMode("ADD");glow:SetSize(390,390);glow:SetPoint("CENTER",0,-42);glow:SetVertexColor(1,0.76,0.24,0.24)
    local model=CreateFrame("PlayerModel",nil,stage)
    model:SetPoint("TOPLEFT",4,-4);model:SetPoint("BOTTOMRIGHT",-4,4)
    local function PrepareModel()
        if model.SetUnit then pcall(model.SetUnit,model,"player") end
        if model.SetPortraitZoom then pcall(model.SetPortraitZoom,model,0.03) end
        if model.SetCamDistanceScale then pcall(model.SetCamDistanceScale,model,0.86) end
        if model.SetPosition then pcall(model.SetPosition,model,0,0,-0.05) end
        if model.SetAnimation then pcall(model.SetAnimation,model,68) end
    end
    model:SetScript("OnModelLoaded",PrepareModel);PrepareModel()
    if C_Timer and C_Timer.NewTicker then
        local cheerTicker=C_Timer.NewTicker(3.1,function()if shade:IsShown() and model.SetAnimation then pcall(model.SetAnimation,model,68) end end)
        shade:HookScript("OnHide",function()if cheerTicker and cheerTicker.Cancel then cheerTicker:Cancel() end end)
    end

    local badgeAnchor=CreateFrame("Frame",nil,m);badgeAnchor:SetSize(118,118);badgeAnchor:SetPoint("TOP",m,"TOP",226,-82)
    RenderLevelBadge(m,badgeAnchor,"Captain's Radiance",112,60,1)
    local captain=Font(m,"THE CAPTAIN",9,GOLD,DEFAULT_FONT);captain:SetPoint("TOP",badgeAnchor,"BOTTOM",0,-10)
    local announcement=Heading(m,name.." is the captain now!",26)
    announcement:SetPoint("TOPLEFT",486,-248);announcement:SetPoint("TOPRIGHT",-30,-248);announcement:SetWordWrap(true);announcement:SetJustifyH("CENTER")
    local sub=Font(m,"Captain's Radiance is now exclusive to this Main character. Main can be changed again after 30 days.",10,MUTED)
    sub:SetPoint("TOPLEFT",492,-340);sub:SetPoint("TOPRIGHT",-36,-340);sub:SetWordWrap(true);sub:SetJustifyH("CENTER")
    local done=Button(m,"Take the Lead",132,34,true);done:SetPoint("BOTTOMRIGHT",-36,24);done:SetScript("OnClick",function()shade:Hide()end)
    return shade,m
end

RPAL.OnMainCharacterSelected=function(info)
    if RPAL.ShowMainCharacterCeremony then RPAL.ShowMainCharacterCeremony(info and info.displayName) end
    if RPAL.Refresh then RPAL.Refresh() end
end

RPAL.OnLegacyCalibrationApplied=function(calibration,tier)
    if RPAL.ShowSessionStatusToast then
        RPAL.ShowSessionStatusToast(tostring(tier and tier.label or "Legacy").." calibration applied to this TRP profile.","IC")
    end
    if RPAL.Refresh then RPAL.Refresh() end
end

function RPAL.OpenTextPrompt(title,label,callback)
    local shade,m=Modal(title,430,150)
    MakeLabel(m,label,16,-52)
    local e=EditBox(m,395,30);e:SetPoint("TOPLEFT",16,-67)
    local ok=Button(m,"Save",80,30,true);ok:SetPoint("BOTTOMRIGHT",-16,14)
    ok:SetScript("OnClick",function()callback(e:GetText());shade:Hide()end)
    e:SetFocus()
end

function RPAL.OpenCategoryEditor(id)
    if not editMode then return end
    local category=id and RPAL.FindCategory(id)
    local shade,m=Modal(category and "Edit Category" or "New Category",440,190)
    MakeLabel(m,"Category Name",16,-54)
    local name=EditBox(m,405,30);name:SetPoint("TOPLEFT",16,-70);name:SetText(category and category.name or "")
    local save=Button(m,"Save",80,30,true);save:SetPoint("BOTTOMRIGHT",-16,14)
    save:SetScript("OnClick",function()
        local v=name:GetText()
        if v=="" then return end
        if category then
            category.name=v
        else
            local library=RPAL.GetLibrary()
            if type(library)~="table" then
                library={}
                RPAL.SetLibrary(library)
            end
            table.insert(library,{id=RPAL.NewID("cat"),name=v,collapsed=false,badges={}})
        end
        shade:Hide();RPAL.Refresh()
    end)
    if category then
        local del=Button(m,"Delete",80,30,false);del:SetPoint("BOTTOMLEFT",16,14);del.label:SetTextColor(.9,.35,.35)
        del:SetScript("OnClick",function()
            for i,c in ipairs(RPAL.GetLibrary())do if c==category then table.remove(RPAL.GetLibrary(),i)break end end
            shade:Hide();RPAL.Refresh()
        end)
    end
end


local function BuildGameIconPool()
    local icons, seen = {}, {}

    local function AddIcon(icon)
        if icon and icon ~= "" and not seen[icon] then
            seen[icon] = true
            table.insert(icons, icon)
        end
    end

    -- Include RPAL's own named icon library so the search box can find
    -- bundled assets by filename (for example "emote_glance" / "emote_angry").
    for _,icon in pairs(RPAL.ICONS or {}) do
        AddIcon(icon)
    end

    -- Retail exposes the same icon pools used by the Macro UI.
    if GetMacroIcons then
        local ok, result = pcall(GetMacroIcons)
        if ok and type(result) == "table" then
            for _, icon in ipairs(result) do AddIcon(icon) end
        end
    end

    if GetMacroItemIcons then
        local ok, result = pcall(GetMacroItemIcons)
        if ok and type(result) == "table" then
            for _, icon in ipairs(result) do AddIcon(icon) end
        end
    end

    -- Compatibility with builds where the functions fill a supplied table.
    if #icons == 0 then
        if GetMacroIcons then
            local t={}
            pcall(GetMacroIcons,t)
            for _,icon in ipairs(t) do AddIcon(icon) end
        end
        if GetMacroItemIcons then
            local t={}
            pcall(GetMacroItemIcons,t)
            for _,icon in ipairs(t) do AddIcon(icon) end
        end
    end

    -- Always retain a fallback.
    if #icons == 0 then
        AddIcon(RPAL.ICONS.question)
    end

    return icons
end

function RPAL.OpenIconPicker(currentIcon, callback)
    local shade,m=Modal("Choose WoW Icon",760,620)
    shade:SetFrameStrata("FULLSCREEN_DIALOG")
    shade:SetFrameLevel(7600)
    m:SetFrameLevel(7610)
    local icons=BuildGameIconPool()
    local page=1
    local perPage=70
    local columns=10
    local buttons={}
    local pageText=Font(m,"",11,PALE);pageText:SetPoint("TOP",0,-45)
    local search=EditBox(m,340,30);search:SetPoint("TOP",0,-12);search:SetText("");search:SetTextInsets(30,9,0,0)
    local searchIcon=m:CreateTexture(nil,"ARTWORK");searchIcon:SetSize(16,16);searchIcon:SetPoint("LEFT",search, "LEFT",9,0);searchIcon:SetTexture(RPAL.ICONS.search)

    local grid=CreateFrame("Frame",nil,m)
    grid:SetPoint("TOPLEFT",22,-72)
    grid:SetPoint("BOTTOMRIGHT",-22,58)

    local function Draw()
        for _,b in ipairs(buttons) do b:Hide();b:SetParent(nil) end
        wipe(buttons)

        local query=tostring(search:GetText() or ""):lower():gsub("%s+"," "):match("^%s*(.-)%s*$")
        local filtered={}
        if query=="" then
            for i,icon in ipairs(icons) do filtered[i]=icon end
        else
            local scored={}
            local q=RPAL.NormalizeIconSearch and RPAL.NormalizeIconSearch(query) or query:gsub("[^%w]","")
            for _,icon in ipairs(icons) do
                local text=tostring(icon):lower()
                local norm=text:gsub("[^%w]","")
                local score=999999
                local pos=text:find(query,1,true)
                if pos then score=pos end
                local npos=norm:find(q,1,true)
                if npos then score=math.min(score,100+npos) end
                -- Character-by-character fuzzy proximity for filenames/asset names.
                if score==999999 and q~="" then
                    local qi=1
                    for i=1,#norm do
                        if norm:sub(i,i)==q:sub(qi,qi) then qi=qi+1; if qi>#q then break end end
                    end
                    if qi>#q then score=500+#norm end
                end
                if score<999999 then scored[#scored+1]={icon=icon,score=score,text=text} end
            end
            table.sort(scored,function(a,b)
                if a.score~=b.score then return a.score<b.score end
                return a.text<b.text
            end)
            for i,item in ipairs(scored) do filtered[i]=item.icon end
        end
        local visibleCount=#filtered
        local pages=math.max(1,math.ceil(visibleCount/perPage))
        if page<1 then page=pages elseif page>pages then page=1 end
        pageText:SetText(("Page %d / %d   •   %d results"):format(page,pages,visibleCount))

        local start=(page-1)*perPage+1
        for n=1,perPage do
            local index=start+n-1
            local icon=filtered[index]
            if not icon then break end

            local row=math.floor((n-1)/columns)
            local col=(n-1)%columns
            local b=CreateFrame("Button",nil,grid,"BackdropTemplate")
            b:SetSize(58,58)
            b:SetPoint("TOPLEFT",col*68,-row*68)
            Backdrop(b,BUTTON_BG,icon==currentIcon and GOLD or BORDER)

            local tex=b:CreateTexture(nil,"ARTWORK")
            tex:SetPoint("TOPLEFT",3,-3)
            tex:SetPoint("BOTTOMRIGHT",-3,3)
            tex:SetTexture(icon)

            b:SetScript("OnEnter",function(self)
                self:SetBackdropBorderColor(unpack(GOLD))
                if RPAL.ShowCursorTooltip then RPAL.ShowCursorTooltip({
                    eyebrow="ICON PICKER",title="WoW Icon",
                    body=(type(icon)=="number") and ("FileID: "..tostring(icon)) or tostring(icon),
                    accent=GOLD,width=340,
                }) end
            end)
            b:SetScript("OnLeave",function(self)
                if icon==currentIcon then self:SetBackdropBorderColor(unpack(GOLD))
                else self:SetBackdropBorderColor(unpack(BORDER)) end
                if RPAL.HideCursorTooltip then RPAL.HideCursorTooltip() end
            end)
            b:SetScript("OnClick",function()
                callback(icon)
                shade:Hide()
            end)
            table.insert(buttons,b)
        end
    end
    search:SetScript("OnTextChanged",function() page=1;Draw() end)

    local prev=Button(m,"Previous",90,30,false);prev:SetPoint("BOTTOMLEFT",22,16)
    local nextButton=Button(m,"Next",90,30,false);nextButton:SetPoint("LEFT",prev,"RIGHT",8,0)
    local close=Button(m,"Cancel",90,30,false);close:SetPoint("BOTTOMRIGHT",-22,16)

    prev:SetScript("OnClick",function()page=page-1;Draw()end)
    nextButton:SetScript("OnClick",function()page=page+1;Draw()end)
    close:SetScript("OnClick",function()shade:Hide()end)

    Draw()
end

function RPAL.OpenBadgeEditor(catID,badgeID)
    if not editMode then return end

    if activeBadgeEditorShade and activeBadgeEditorShade:IsShown() then
        activeBadgeEditorShade:Raise()
        return
    end
    local category=RPAL.FindCategory(catID);if not category then return end
    local badge=badgeID and RPAL.FindBadge(catID,badgeID)
    local shade,m=Modal(badge and "Edit Achievement" or "New Achievement",610,410)
    activeBadgeEditorShade=shade
    shade:SetScript("OnHide",function()
        if activeBadgeEditorShade==shade then activeBadgeEditorShade=nil end
    end)
    local function field(label,y,text)
        MakeLabel(m,label,16,y)
        local e=EditBox(m,575,30);e:SetPoint("TOPLEFT",16,y-15);e:SetText(text or "");return e
    end
    local name=field("Name",-52,badge and badge.name)
    local desc=field("Description",-105,badge and badge.description)
    MakeLabel(m,"WoW Icon",-158,-158)
    local selectedIcon=(badge and badge.icon and badge.icon~="") and badge.icon or RPAL.ICONS.question
    local iconButton=Button(m,"Choose Icon...",250,30,false);iconButton:SetPoint("TOPLEFT",16,-173)
    local add=field("Additional Info",-221,badge and badge.additional)
    local preview=m:CreateTexture(nil,"ARTWORK");preview:SetSize(76,76);preview:SetPoint("TOPLEFT",16,-285);preview:SetTexture(selectedIcon)
    iconButton:SetScript("OnClick",function()
        RPAL.OpenIconPicker(selectedIcon,function(icon)
            selectedIcon=icon
            preview:SetTexture(icon)
        end)
    end)
    if not badge then
        local forgeInfo=Font(m,"FORGE COST  200 TOKENS    •    REWARD  +10 RENOWN",9,GOLD,DEFAULT_FONT)
        forgeInfo:SetPoint("BOTTOM",0,24)
    end

    local save=Button(m,badge and "Save" or "Forge",86,30,true);save:SetPoint("BOTTOMRIGHT",-16,14)
    save:SetScript("OnClick",function()
        if not badge then
            local spend=RPAL.SpendStones or RPAL.SpendCrests
            if not spend or not spend(200) then
                if RPAL.ShowSimpleCursorTooltip then
                    RPAL.ShowSimpleCursorTooltip("Not enough Tokens","Forging a new Achievement costs 200 Tokens.",GOLD,"ACHIEVEMENT FORGE")
                    C_Timer.After(1.5,function() if RPAL.HideCursorTooltip then RPAL.HideCursorTooltip() end end)
                end
                print("|cffff6666ODYN:|r Forging a new Achievement requires 200 Tokens.")
                return
            end
        end

        local b=badge or {id=RPAL.NewID("ach")}
        b.name=name:GetText();b.description=desc:GetText();b.icon=selectedIcon;b.additional=add:GetText()
        if not badge then
            table.insert(category.badges,b)
            local gained=RPAL.AddRenown and RPAL.AddRenown(10,"achievement_created") or 10
            if RPAL.ShowRewardToast then RPAL.ShowRewardToast("Achievement Forged",gained or 10,"PERSONAL MILESTONE") end
        end
        shade:Hide();RPAL.Refresh()
    end)
    if badge then
        local del=Button(m,"Delete",80,30,false);del:SetPoint("BOTTOMLEFT",16,14);del.label:SetTextColor(.9,.35,.35)
        del:SetScript("OnClick",function()
            for i,b in ipairs(category.badges) do
                if b==badge then
                    table.remove(category.badges,i)
                    break
                end
            end

            local progress=RPAL.GetScopedProgressTable()
            if progress then
                progress[badge.id]=nil
            end

            local favourites=RPAL.GetScopedFavourites()
            if favourites then
                for i=#favourites,1,-1 do
                    if favourites[i]==badge.id then
                        table.remove(favourites,i)
                    end
                end
            end

            shade:Hide()
            RPAL.Refresh()
        end)
    end
end

function RPAL.OpenFavouritePicker()
    if not editMode then return end
    local shade,m=Modal("Favourite Achievements",650,500)
    local hint=Font(m,"Choose up to 3 completed achievements.",10,MUTED);hint:SetPoint("TOPLEFT",16,-45)
    local scroll=CreateFrame("ScrollFrame",nil,m,"UIPanelScrollFrameTemplate");scroll:SetPoint("TOPLEFT",16,-68);scroll:SetPoint("BOTTOMRIGHT",-32,50)
    if RPAL.SkinScrollFrame then RPAL.SkinScrollFrame(scroll) end
    local content=CreateFrame("Frame",nil,scroll);content:SetSize(580,1);scroll:SetScrollChild(content)
    local y=0
    for _,entry in ipairs(RPAL.CompletedBadges())do
        local b=entry.badge
        local row=Button(content,RPAL.ReplaceName(b.name),560,38,false);row:SetPoint("TOPLEFT",0,-y);row.label:ClearAllPoints();row.label:SetPoint("LEFT",12,0)
        local function selected()
            for _,id in ipairs(RPAL.char.profile.favourites)do if id==b.id then return true end end
        end
        if selected()then row.label:SetTextColor(unpack(GOLD))end
        row:SetScript("OnClick",function()
            local fav=RPAL.GetScopedFavourites()
            for i,id in ipairs(fav)do if id==b.id then table.remove(fav,i);RPAL.OpenFavouritePicker();shade:Hide();return end end
            if #fav<3 then table.insert(fav,b.id)end
            shade:Hide();RPAL.OpenFavouritePicker()
        end)
        y=y+42
    end
    content:SetHeight(math.max(1,y))
end

local function OpenTextTransfer(title,text,onImport)
    local shade,m=Modal(title,760,540)

    local hint=Font(m,onImport and "Paste the exported data below." or "Copy the complete export.",10,MUTED)
    hint:SetPoint("TOPLEFT",16,-50)

    -- Actions live at the TOP so UI scaling can never push them below the screen.
    local cancel=Button(m,"Cancel",90,32,false)
    cancel:SetPoint("TOPRIGHT",-16,-42)
    cancel:SetFrameLevel(m:GetFrameLevel()+40)
    cancel:SetScript("OnClick",function()shade:Hide()end)

    if onImport then
        local import=Button(m,"Import",110,32,true)
        import:SetPoint("RIGHT",cancel,"LEFT",-8,0)
        import:SetFrameLevel(m:GetFrameLevel()+40)
        import:SetScript("OnClick",function()
            local payload=m.transferEditBox and m.transferEditBox:GetText() or ""
            if payload=="" then
                print("|cffff6666ODYN:|r Paste Character/Template data into the box first.")
                return
            end

            local ok,err=onImport(payload)
            if ok then
                shade:Hide()
                if RPAL.Refresh then RPAL.Refresh() end
                print("|cffd9a644ODYN:|r Import successful.")
            else
                print("|cffff6666ODYN:|r "..(err or "Import failed."))
            end
        end)
    else
        local selectAll=Button(m,"Select All",110,32,true)
        selectAll:SetPoint("RIGHT",cancel,"LEFT",-8,0)
        selectAll:SetFrameLevel(m:GetFrameLevel()+40)
        selectAll:SetScript("OnClick",function()
            if m.transferEditBox then
                m.transferEditBox:SetFocus()
                m.transferEditBox:HighlightText()
            end
        end)
    end

    local boxFrame=CreateFrame("Frame",nil,m,"BackdropTemplate")
    boxFrame:SetPoint("TOPLEFT",16,-88)
    boxFrame:SetPoint("BOTTOMRIGHT",-16,18)
    boxFrame:SetFrameLevel(m:GetFrameLevel()+5)
    Backdrop(boxFrame,{.01,.02,.025,1},BORDER)

    local scroll=CreateFrame("ScrollFrame",nil,boxFrame,"UIPanelScrollFrameTemplate")
    if RPAL.SkinScrollFrame then RPAL.SkinScrollFrame(scroll) end
    scroll:SetPoint("TOPLEFT",8,-8)
    scroll:SetPoint("BOTTOMRIGHT",-28,8)
    scroll:SetFrameLevel(boxFrame:GetFrameLevel()+2)

    local eb=CreateFrame("EditBox",nil,scroll)
    m.transferEditBox=eb
    eb:SetMultiLine(true)
    eb:SetAutoFocus(false)
    eb:SetFont(DEFAULT_FONT,11,"")
    eb:SetTextColor(unpack(PALE))
    eb:SetWidth(680)
    eb:SetTextInsets(4,4,4,4)
    eb:SetText(text or "")
    eb:SetCursorPosition(0)
    scroll:SetScrollChild(eb)

    eb:SetScript("OnTextChanged",function(self)
        self:SetHeight(math.max(390,self:GetStringHeight()+24))
    end)
    eb:SetHeight(math.max(390,eb:GetStringHeight()+24))
end

function RPAL.ResetData(scope)
    local wasShown=UI.main and UI.main:IsShown() or false
    local ok,err=RPAL.ResetDataState(scope)
    if not ok then return false,err end
    if RPAL.ApplyTalentRewards then pcall(RPAL.ApplyTalentRewards) end

    if RPAL.HideCursorTooltip then RPAL.HideCursorTooltip() end
    if GameTooltip and GameTooltip.Hide then GameTooltip:Hide() end
    RPAL._lastStatisticMessage=nil
    editMode=false
    ledgerMode=false
    skillSetMode=false
    if RPAL.CloseCombatUI then pcall(RPAL.CloseCombatUI) end
    if RPAL.HardRebuildUI then
        RPAL.HardRebuildUI(wasShown)
    else
        ApplyCurrentTheme()
        if RPAL.Refresh then RPAL.Refresh() end
    end
    if UI.main then UI.main:SetScale(GetUIScaleSetting()) end
    if RPAL.StartTRPAutoUpdate then RPAL.StartTRPAutoUpdate(true) end
    if RPAL.RefreshThemeVisuals then RPAL.RefreshThemeVisuals() end
    return true
end

function RPAL.OpenResetData()
    local shade,m=Modal("Reset Data",560,285)

    local warning=Heading(m,"Which data would you like to wipe?",16)
    warning:SetPoint("TOP",0,-58)

    local detail=Font(m,"This cannot be undone. Character resets the current character; Account resets account-wide ODYN data; All resets both. Other characters keep their own per-character data until they are reset separately or their SavedVariables files are deleted.",9,MUTED)
    detail:SetPoint("TOP",warning,"BOTTOM",0,-12)
    detail:SetWidth(500);detail:SetJustifyH("CENTER");detail:SetWordWrap(true)

    local character=Button(m,"Character",135,38,false)
    character:SetPoint("BOTTOMLEFT",38,28)

    local account=Button(m,"Account",135,38,false)
    account:SetPoint("BOTTOM",0,28)

    local all=Button(m,"All",135,38,false)
    all:SetPoint("BOTTOMRIGHT",-38,28)
    all.label:SetTextColor(1,.55,.55)

    character:SetScript("OnClick",function()
        shade:Hide()
        local ok,err=RPAL.ResetData("character")
        print(ok and "|cffd9a644ODYN:|r Character data wiped." or ("|cffff6666ODYN:|r "..tostring(err)))
    end)

    account:SetScript("OnClick",function()
        shade:Hide()
        local ok,err=RPAL.ResetData("account")
        print(ok and "|cffd9a644ODYN:|r Account data wiped." or ("|cffff6666ODYN:|r "..tostring(err)))
    end)

    all:SetScript("OnClick",function()
        shade:Hide()
        local ok,err=RPAL.ResetData("all")
        print(ok and "|cffd9a644ODYN:|r All ODYN data wiped." or ("|cffff6666ODYN:|r "..tostring(err)))
    end)
end

local function ApplyThemeToRegion(region)
    if not region then return end

    -- WoW's UI object types do not all expose the same methods. The role tags
    -- are ours, but method availability belongs to Blizzard's object type.
    -- Use guarded calls so one unusual region can never break the whole UI.
    if region._rpalColorRole then
        local c=ThemeColorByRole(region._rpalColorRole)
        if c then
            if type(region.SetTextColor)=="function" then
                pcall(region.SetTextColor,region,unpack(c))
            elseif type(region.SetColorTexture)=="function" then
                pcall(region.SetColorTexture,region,c[1],c[2],c[3],c[4] or 1)
            end
        end
    end

    if region._rpalDecorRole and type(region.SetVertexColor)=="function" then
        if region._rpalDecorRole=="MAIN" then
            pcall(region.SetVertexColor,region,
                math.min(1,MAIN_BG[1]*3.6+0.18),
                math.min(1,MAIN_BG[2]*3.6+0.18),
                math.min(1,MAIN_BG[3]*3.6+0.18)
            )
        elseif region._rpalDecorRole=="PAGE" then
            pcall(region.SetVertexColor,region,
                math.min(1,PAGE_BG[1]*3.0+0.15),
                math.min(1,PAGE_BG[2]*3.0+0.15),
                math.min(1,PAGE_BG[3]*3.0+0.15)
            )
        elseif region._rpalDecorRole=="ACCENT" then
            pcall(region.SetVertexColor,region,GOLD[1],GOLD[2],GOLD[3])
        end
    end
end

ForceThemeFrame=function(frame,seen)
    if not frame then return end
    seen=seen or {}
    if seen[frame] then return end
    seen[frame]=true

    if frame._rpalBgRole and type(frame.SetBackdropColor)=="function" then
        local c=ThemeColorByRole(frame._rpalBgRole)
        if c then pcall(frame.SetBackdropColor,frame,unpack(c)) end
    end

    if frame._rpalBorderRole and type(frame.SetBackdropBorderColor)=="function" then
        local c=ThemeColorByRole(frame._rpalBorderRole)
        if c then pcall(frame.SetBackdropBorderColor,frame,unpack(c)) end
    end

    if type(frame.GetRegions)=="function" then
        local ok,regions=pcall(function() return {frame:GetRegions()} end)
        if ok and regions then
            for _,region in ipairs(regions) do
                ApplyThemeToRegion(region)
            end
        end
    end

    if type(frame.GetChildren)=="function" then
        local ok,children=pcall(function() return {frame:GetChildren()} end)
        if ok and children then
            for _,child in ipairs(children) do
                ForceThemeFrame(child,seen)
            end
        end
    end
end

local function RefreshStaticTheme()
    if not UI.main then return end

    Backdrop(UI.main,MAIN_BG,BORDER)
    if UI.header then Backdrop(UI.header,PAGE_BG_ALT,BORDER) end
    if UI.content then Backdrop(UI.content,PAGE_BG,BORDER) end
    -- UI.ledger is a plain content holder, not a BackdropTemplate frame.
    if UI.progression then Backdrop(UI.progression,PANEL2,GOLD) end

    if UI.settings then Backdrop(UI.settings,BUTTON_BG,BORDER) end
    if UI.customize then Backdrop(UI.customize,BUTTON_BG,BORDER) end
    if UI.edit then Backdrop(UI.edit,BUTTON_BG,BORDER) end
    if UI.tools then Backdrop(UI.tools,ACCENT_BG,GOLD) end
    if UI.ledgerToggle then
        Backdrop(UI.ledgerToggle,ledgerMode and ACCENT_BG or BUTTON_BG,ledgerMode and GOLD or BORDER)
    end
    if UI.addCategory then Backdrop(UI.addCategory,ACCENT_BG,GOLD) end

    if UI.renownBar then
        UI.renownBar:SetStatusBarColor(GOLD[1],GOLD[2],GOLD[3],0.92)
        if UI.renownBar.bg then
            if UI.renownBar.bg.SetVertexColor then
                UI.renownBar.bg:SetVertexColor(PAGE_BG_ALT[1],PAGE_BG_ALT[2],PAGE_BG_ALT[3],0.95)
            end
        end
    end

    if UI.mainOrnament and UI.mainOrnament._rpalApplyTheme then
        UI.mainOrnament:_rpalApplyTheme(GOLD)
    end

    ForceThemeFrame(UI.main)

    -- Theme roles are applied recursively above; rebuilding statistics,
    -- preview, Chronicle and every card here is unnecessary and caused a
    -- visible freeze.  Only small independent chrome/widgets need a retint.
    if RPAL.RefreshBookChrome then RPAL.RefreshBookChrome(true) end
    if RPAL.UpdateMinimapWidget then RPAL.UpdateMinimapWidget() end
    if RPAL.UpdateTargetBadge then RPAL.UpdateTargetBadge() end
end

-- Re-apply the active theme to every live RPAL surface immediately.  Theme
-- selection is a visual-only operation, so no reload/relog should be required.
-- Several widgets live outside UI.main and therefore cannot be reached by the
-- recursive main-frame retint alone; refresh them explicitly here.
function RPAL.RefreshThemeVisuals()
    ApplyCurrentTheme()

    local function Safe(fn, ...)
        if type(fn) ~= "function" then return end
        pcall(fn, ...)
    end

    -- Main RPAL tree + navigation/book chrome.
    Safe(RefreshStaticTheme)
    Safe(RPAL.Refresh)

    -- Standalone / overlay widgets.
    Safe(RPAL.UpdatePlayerFrameWidget)
    Safe(RPAL.UpdateMinimapWidget)
    Safe(RPAL.UpdateSurvivalWidget, 0)
    Safe(RPAL.UpdateTargetBadge)
    Safe(RPAL.UpdateSessionWidget)
    Safe(RPAL.UpdateCurrencyWidget)
    Safe(RPAL.RefreshProfileAppearanceSurfaces)

    -- Secondary pages/widgets whose palette is cached locally.
    Safe(RPAL.RefreshCalendar)
    Safe(RPAL.RefreshSceneButtons)
    Safe(RPAL.RefreshChronicleTheme)
    Safe(RPAL.RefreshSkillSet)
    Safe(RPAL.RefreshInjuryUI)
    Safe(RPAL.RefreshDMKitTheme)
    Safe(RPAL.RefreshChatQoLTheme)

    -- Combat owns its own live theme state and may currently be displaying the
    -- winning combatant's customization, so let CombatUI resolve that itself.
    Safe(RPAL.RefreshCombatUI)
end


function RPAL.SetTheme(name)
    if name=="Class Specific" then
        local appearance=RPAL.GetCharacterThemeSelection()
        appearance.themeName="Class Specific"
        appearance.themeManualOverride=false
        ApplyAutomaticClassTheme()
        RPAL.RefreshThemeVisuals()
        return true
    end

    if not THEMES[name] then return false,"unknown" end
    if RPAL.IsThemeUnlocked and not RPAL.IsThemeUnlocked(name) then
        return false,"locked"
    end

    local appearance=RPAL.GetCharacterThemeSelection()
    appearance.themeName=name
    appearance.themeManualOverride=true

    ApplyThemePalette(name)
    RPAL.RefreshThemeVisuals()
    return true
end



function RPAL.OpenInterfaceLayoutSettings()
    local shade,m=Modal("Interface Layout & Navigation",720,735)
    local title=Heading(m,"WINDOW LAYOUT",15);title:SetPoint("TOPLEFT",22,-56)
    local desc=Font(m,"Choose a presentation preset, then arrange or hide each main destination. Hidden destinations stay available through configured shortcuts.",9,MUTED);desc:SetPoint("TOPLEFT",22,-82);desc:SetWidth(670);desc:SetWordWrap(true)
    local current=RPAL.GetDeskLayout and RPAL.GetDeskLayout() or "desk"
    local layouts={
        {"desk","Desk"},{"focus","Focus"},{"compact","Compact"},{"wide","Wide"},
    }
    local x=24
    for _,entry in ipairs(layouts) do
        local id,label=entry[1],entry[2]
        local b=Button(m,label,154,32,current==id);b:SetPoint("TOPLEFT",x,-125);x=x+164
        b:SetScript("OnClick",function()if RPAL.SetDeskLayout then RPAL.SetDeskLayout(id) end;shade:Hide();RPAL.OpenInterfaceLayoutSettings()end)
    end
    local navTitle=Heading(m,"NAVIGATION",15);navTitle:SetPoint("TOPLEFT",22,-202)
    local navHint=Font(m,"UP / DWN changes order. SHOWN / HIDDEN controls which destinations appear on the left side of the main ODYN desk.",8.7,MUTED);navHint:SetPoint("TOPLEFT",22,-229);navHint:SetWidth(670);navHint:SetWordWrap(true)
    local order=RPAL.GetDeskNavigationOrder and RPAL.GetDeskNavigationOrder() or {}
    local y=-274
    for i,key in ipairs(order) do
        local rowKey=key
        local row=CreateFrame("Frame",nil,m,"BackdropTemplate");row:SetSize(650,26);row:SetPoint("TOPLEFT",24,y);Backdrop(row,PANEL2,BORDER);PolishPanel(row,"card")
        local label=Font(row,("%02d   %s"):format(i,RPAL.GetDeskNavigationLabel and RPAL.GetDeskNavigationLabel(rowKey) or rowKey),9.2,PALE);label:SetPoint("LEFT",10,0)
        local shown=(not RPAL.IsDeskNavigationVisible) or RPAL.IsDeskNavigationVisible(rowKey)
        local vis=Button(row,shown and "SHOWN" or "HIDDEN",72,20,shown);vis:SetPoint("RIGHT",-98,0)
        vis:SetScript("OnClick",function()if RPAL.SetDeskNavigationVisible then RPAL.SetDeskNavigationVisible(rowKey,not shown) end;shade:Hide();RPAL.OpenInterfaceLayoutSettings()end)
        local up=Button(row,"UP",38,20,false);up:SetPoint("RIGHT",-55,0);up:SetEnabled(i>1);up:SetScript("OnClick",function()if RPAL.MoveDeskNavigationItem then RPAL.MoveDeskNavigationItem(rowKey,-1) end;shade:Hide();RPAL.OpenInterfaceLayoutSettings()end)
        local down=Button(row,"DWN",42,20,false);down:SetPoint("RIGHT",-9,0);down:SetEnabled(i<#order);down:SetScript("OnClick",function()if RPAL.MoveDeskNavigationItem then RPAL.MoveDeskNavigationItem(rowKey,1) end;shade:Hide();RPAL.OpenInterfaceLayoutSettings()end)
        y=y-29
    end
    local reset=Button(m,"Reset Navigation",132,28,false);reset:SetPoint("BOTTOMLEFT",24,18);reset:SetScript("OnClick",function()if RPAL.ResetDeskNavigationOrder then RPAL.ResetDeskNavigationOrder() end;if RPAL.ResetDeskNavigationVisibility then RPAL.ResetDeskNavigationVisibility() end;shade:Hide();RPAL.OpenInterfaceLayoutSettings()end)
    local done=Button(m,"Done",90,28,true);done:SetPoint("BOTTOMRIGHT",-24,18);done:SetScript("OnClick",function()shade:Hide()end)
    return shade,m
end
function RPAL.OpenPlayerWidgetMenuSettings()
    local shade,m=Modal("Player Frame Shortcuts",620,650)
    local settings=RPAL.GetSettings and RPAL.GetSettings() or {}
    settings.playerWidgetMenuVisible=type(settings.playerWidgetMenuVisible)=="table" and settings.playerWidgetMenuVisible or {main=true,injury=true,currencies=true}
    local title=Heading(m,"CLICK MENU",15);title:SetPoint("TOPLEFT",22,-56)
    local desc=Font(m,"Choose exactly which destinations appear when the Player Frame ODYN orb is clicked. The default keeps only ODYN, Health and Currencies.",9,MUTED);desc:SetPoint("TOPLEFT",22,-82);desc:SetWidth(570);desc:SetWordWrap(true)
    local defs=RPAL.PLAYER_WIDGET_MENU_DEFS or {}
    local y=-126
    for _,def in ipairs(defs) do
        local current=def
        local row=CreateFrame("Frame",nil,m,"BackdropTemplate");row:SetSize(570,27);row:SetPoint("TOPLEFT",24,y);Backdrop(row,PANEL2,BORDER);PolishPanel(row,"card")
        local label=Font(row,current.label,9.3,PALE);label:SetPoint("LEFT",10,0)
        local shown=settings.playerWidgetMenuVisible[current.key]==true
        local toggle=Button(row,shown and "SHOWN" or "HIDDEN",82,21,shown);toggle:SetPoint("RIGHT",-8,0)
        toggle:SetScript("OnClick",function()
            settings.playerWidgetMenuVisible[current.key]=not shown
            shade:Hide();RPAL.OpenPlayerWidgetMenuSettings()
        end)
        y=y-32
    end
    local reset=Button(m,"Reset Defaults",120,28,false);reset:SetPoint("BOTTOMLEFT",24,18);reset:SetScript("OnClick",function()settings.playerWidgetMenuVisible={main=true,injury=true,currencies=true};shade:Hide();RPAL.OpenPlayerWidgetMenuSettings()end)
    local done=Button(m,"Done",90,28,true);done:SetPoint("BOTTOMRIGHT",-24,18);done:SetScript("OnClick",function()shade:Hide()end)
    return shade,m
end

function RPAL.OpenContactCopyBox(label,value)
    label=tostring(label or "Contact")
    value=tostring(value or "")
    local shade,m=Modal("Copy "..label,540,180)
    local hint=Font(m,"The text below is selected and ready to copy. Press Ctrl+C.",9.5,MUTED)
    hint:SetPoint("TOPLEFT",22,-58);hint:SetWidth(496);hint:SetWordWrap(true)
    local edit=EditBox(m,496,34)
    edit:SetPoint("TOPLEFT",22,-92)
    edit:SetText(value)
    edit:SetFocus()
    edit:HighlightText()
    edit:SetScript("OnEscapePressed",function()shade:Hide()end)
    edit:SetScript("OnEnterPressed",function(self)self:HighlightText()end)
    local done=Button(m,"Done",90,28,true)
    done:SetPoint("BOTTOMRIGHT",-22,18)
    done:SetScript("OnClick",function()shade:Hide()end)
    return shade,m
end

function RPAL.BuildContactSettingsPage(page)
    if not page then return end
    local intro=Font(page,"For bug reports, feedback, release issues or anything ODYN-related, you can reach me through any of the contacts below.",9.5,MUTED)
    intro:SetPoint("TOPLEFT",4,-74);intro:SetWidth(552);intro:SetWordWrap(true)

    local card=CreateFrame("Frame",nil,page,"BackdropTemplate")
    card:SetPoint("TOPLEFT",4,-122);card:SetPoint("TOPRIGHT",-4,-122);card:SetHeight(264)
    Backdrop(card,PANEL2,BORDER);PolishPanel(card,"card")

    local title=Heading(card,"CONTACT",14);title:SetPoint("TOPLEFT",18,-18)
    local rule=card:CreateTexture(nil,"ARTWORK")
    rule:SetTexture("Interface\\Buttons\\WHITE8X8");rule:SetColorTexture(GOLD[1],GOLD[2],GOLD[3],0.28)
    rule:SetHeight(1);rule:SetPoint("TOPLEFT",18,-44);rule:SetPoint("TOPRIGHT",-18,-44)

    local function AddRow(label,value,y)
        local row=CreateFrame("Frame",nil,card,"BackdropTemplate")
        row:SetPoint("TOPLEFT",18,y);row:SetPoint("TOPRIGHT",-18,y);row:SetHeight(42)
        Backdrop(row,{0.022,0.030,0.034,0.96},BORDER);PolishPanel(row,"card")
        local lab=Font(row,label,8.7,GOLD);lab:SetPoint("LEFT",12,8)
        local val=Font(row,value,10.5,PALE,DEFAULT_FONT);val:SetPoint("LEFT",12,-8);val:SetPoint("RIGHT",-88,-8);val:SetWordWrap(false)
        local copy=Button(row,"COPY",66,24,false);copy:SetPoint("RIGHT",-8,0)
        copy:SetScript("OnClick",function()RPAL.OpenContactCopyBox(label,value)end)
    end

    AddRow("Discord","devour.me.colossus",-58)
    AddRow("Email","zerasft13@gmail.com",-106)
    AddRow("TRP","Russ Cruw",-154)
    AddRow("OOC Name","Rûzz",-202)

    local note=Font(page,"Each COPY button opens a pre-selected field so WoW can hand the text to Ctrl+C without relying on protected clipboard functions.",8.7,MUTED)
    note:SetPoint("TOPLEFT",4,-405);note:SetWidth(552);note:SetWordWrap(true)
end

function RPAL.OpenSettings(initialPage)
    local shade,m=Modal("Settings",780,650)
    local settings=RPAL.GetSettings()
    local profilePrivacy=RPAL.GetProfilePrivacy and RPAL.GetProfilePrivacy() or {hidePublicProfile=false,hideTRPCompanion=false}
    local activePage=initialPage or RPAL._settingsPage or "general"

    local rail=CreateFrame("Frame",nil,m,"BackdropTemplate")
    rail:SetPoint("TOPLEFT",16,-48)
    rail:SetPoint("BOTTOMLEFT",16,18)
    rail:SetWidth(158)
    Backdrop(rail,{0.025,0.034,0.038,0.96},BORDER)
    PolishPanel(rail,"card")

    local railTitle=Font(rail,"SETTINGS",9,GOLD)
    railTitle:SetPoint("TOPLEFT",14,-14)
    local railHint=Font(rail,"Organized by system",8,MUTED)
    railHint:SetPoint("TOPLEFT",14,-31)

    local pageHost=CreateFrame("Frame",nil,m)
    pageHost:SetPoint("TOPLEFT",188,-48)
    pageHost:SetPoint("BOTTOMRIGHT",-18,18)

    local pages={}
    local navButtons={}
    local defs={
        {"general","General"},
        {"privacy","Profile & TRP"},
        {"trpprofiles","TRP Profiles"},
        {"widgets","Widgets"},
        {"hoverlog","Hover Log"},
        {"chatqol","Chat QoL"},
        {"keybinds","Keybinds"},
        {"survival","Survival"},
        {"features","Features"},
        {"sounds","Sounds"},
        {"thanks","Special Thanks"},
        {"contact","Contact"},
    }

    local function MakePage(key,title,desc)
        local page=CreateFrame("Frame",nil,pageHost)
        page:SetAllPoints(pageHost)
        page:Hide()
        pages[key]=page
        local h=Heading(page,title,18);h:SetPoint("TOPLEFT",4,-2)
        local d=Font(page,desc or "",9.5,MUTED);d:SetPoint("TOPLEFT",4,-29);d:SetWidth(552);d:SetWordWrap(true)
        return page
    end

    local function Section(page,text,y)
        local h=Heading(page,text,14);h:SetPoint("TOPLEFT",4,y);AddSectionRule(page,h,4);return h
    end

    local function SelectPage(key)
        if not pages[key] then key="general" end
        activePage=key;RPAL._settingsPage=key
        for pageKey,page in pairs(pages) do page:SetShown(pageKey==key) end
        for navKey,button in pairs(navButtons) do
            local selected=(navKey==key)
            if button.SetBackdropColor then
                local c=selected and ACCENT_BG or BUTTON_BG
                button:SetBackdropColor(c[1],c[2],c[3],c[4] or 1)
            end
            if button.SetBackdropBorderColor then
                local c=selected and GOLD or BORDER
                button:SetBackdropBorderColor(c[1],c[2],c[3],selected and 0.95 or (c[4] or 1))
            end
            if button.label then button.label:SetTextColor(unpack(selected and GOLD or PALE)) end
        end
    end

    for index,def in ipairs(defs) do
        local key,label=def[1],def[2]
        local b=Button(rail,label,132,34,false)
        b:SetPoint("TOPLEFT",13,-54-((index-1)*38))
        if b.label then b.label:ClearAllPoints();b.label:SetPoint("LEFT",12,0) end
        b:SetScript("OnClick",function()SelectPage(key)end)
        navButtons[key]=b
    end

    local kit=Font(rail,(RPAL.IsLiteKit and RPAL.IsLiteKit()) and "LITE KIT" or "FULL KIT",8.5,GOLD)
    kit:SetPoint("BOTTOMLEFT",14,17)
    local privacyState=Font(rail,profilePrivacy.hidePublicProfile and "PROFILE: HIDDEN" or "PROFILE: PUBLIC",8,profilePrivacy.hidePublicProfile and {0.90,0.58,0.42} or {0.48,0.82,0.48})
    privacyState:SetPoint("BOTTOMLEFT",14,34)

    -- GENERAL ---------------------------------------------------------------
    local general=MakePage("general","General","Core ODYN setup, desk layout and interface sizing. Theme selection lives only in Customize now.")
    Section(general,"Setup & Layout",-74)
    local rerunSetup=Button(general,"Re-run Setup",142,30,false);rerunSetup:SetPoint("TOPLEFT",4,-100)
    rerunSetup:SetScript("OnClick",function()
        shade:Hide()
        if C_Timer and C_Timer.After then C_Timer.After(0,function()RPAL.OpenExperienceSetup(true)end) else RPAL.OpenExperienceSetup(true) end
    end)
    local layoutButton=Button(general,"Layout & Navigation",170,30,false);layoutButton:SetPoint("LEFT",rerunSetup,"RIGHT",10,0)
    layoutButton:SetScript("OnClick",function()shade:Hide();if RPAL.OpenInterfaceLayoutSettings then RPAL.OpenInterfaceLayoutSettings() end end)
    local setupHint=Font(general,"Re-run Setup switches Full/Lite Kit. Layout & Navigation changes the Adventurer's Desk composition and left-menu order.",9,MUTED)
    setupHint:SetPoint("TOPLEFT",4,-139);setupHint:SetWidth(552);setupHint:SetWordWrap(true)

    Section(general,"Interface & Text",-190)
    local fontLabel=Font(general,"Font size",10,PALE);fontLabel:SetPoint("TOPLEFT",4,-219)
    local fontValue=Font(general,("%d%%"):format(math.floor((RPAL.GetFontScale and RPAL.GetFontScale() or 1.20)*100+0.5)),10,GOLD);fontValue:SetPoint("TOPLEFT",236,-219)
    local fontSlider=CreateFrame("Slider",nil,general,"OptionsSliderTemplate")
    fontSlider:SetPoint("TOPLEFT",4,-244);fontSlider:SetSize(278,16);fontSlider:SetMinMaxValues(0.90,1.35);fontSlider:SetValueStep(0.01)
    if fontSlider.SetObeyStepOnDrag then fontSlider:SetObeyStepOnDrag(true) end
    fontSlider._settingInitial=true;fontSlider:SetValue(RPAL.GetFontScale and RPAL.GetFontScale() or 1.20);fontSlider._settingInitial=false
    fontSlider:SetScript("OnValueChanged",function(self,value)
        value=math.max(0.90,math.min(1.35,tonumber(value) or 1.20));settings.fontScale=value
        fontValue:SetText(("%d%%"):format(math.floor(value*100+0.5)))
        if not self._settingInitial and RPAL.ApplyFontScale then RPAL.ApplyFontScale() end
    end)
    local scaleText=Font(general,("ODYN UI scale: %d%%"):format(math.floor(GetUIScaleSetting()*100+0.5)),10,PALE);scaleText:SetPoint("TOPLEFT",4,-292)
    local resetScale=Button(general,"Reset UI Scale",124,28,false);resetScale:SetPoint("TOPLEFT",4,-316)
    resetScale:SetScript("OnClick",function()
        settings.uiScale=1;if UI.main then UI.main:SetScale(1) end;shade:Hide();RPAL.OpenSettings("general")
    end)
    local interfaceHint=Font(general,"Theme controls were removed from Settings to keep appearance changes in one place: Customize > Themes.",9,MUTED)
    interfaceHint:SetPoint("TOPLEFT",4,-358);interfaceHint:SetWidth(552);interfaceHint:SetWordWrap(true)

    -- PROFILE & TRP ---------------------------------------------------------
    local privacy=MakePage("privacy","Profile & Total RP 3","Control what other players can see through ODYN and Total RP 3. ODYN now extends TRP's normal hover tooltip when the viewer has ODYN, and publishes a clean TRP profile card for TRP-only viewers.")
    Section(privacy,"Privacy",-74)
    local hideProfile=Check(privacy,"Hide my ODYN public profile")
    hideProfile:SetSize(552,36);hideProfile:SetPoint("TOPLEFT",4,-101);hideProfile:SetChecked(profilePrivacy.hidePublicProfile)
    hideProfile:SetScript("OnClick",function(self)
        local enabled=not self:GetChecked();self:SetChecked(enabled);profilePrivacy.hidePublicProfile=enabled
        if RPAL.RefreshTRPCompanionSummary then RPAL.RefreshTRPCompanionSummary(true) end
        if not enabled then BroadcastPresence() end
        if privacy._rpalRefreshPrivacyLabels then privacy:_rpalRefreshPrivacyLabels() end
    end)
    local hideProfileHint=Font(privacy,"When enabled, ODYN inspect requests are refused, nearby ODYN presence/profile discovery stops, and your ODYN Companion HUD statistics are not shared either.",9,MUTED)
    hideProfileHint:SetPoint("TOPLEFT",32,-143);hideProfileHint:SetWidth(520);hideProfileHint:SetWordWrap(true)

    local hideCompanion=Check(privacy,"Hide ODYN Companion from public tooltip/profile surfaces")
    hideCompanion:SetSize(552,36);hideCompanion:SetPoint("TOPLEFT",4,-190);hideCompanion:SetChecked(profilePrivacy.hideTRPCompanion)
    hideCompanion:SetScript("OnClick",function(self)
        local enabled=not self:GetChecked();self:SetChecked(enabled);profilePrivacy.hideTRPCompanion=enabled
        if RPAL.RefreshTRPCompanionSummary then RPAL.RefreshTRPCompanionSummary(true) end
        if privacy._rpalRefreshPrivacyLabels then privacy:_rpalRefreshPrivacyLabels() end
    end)
    local companionHint=Font(privacy,"Hides Level / Sessions / Characters Met / Renown from ODYN's public tooltip surfaces. TRP Additional Information publishing is controlled separately below.",9,MUTED)
    companionHint:SetPoint("TOPLEFT",32,-232);companionHint:SetWidth(520);companionHint:SetWordWrap(true)

    local publishTRP=Check(privacy,"Show ODYN in TRP Additional Information")
    publishTRP:SetSize(552,36);publishTRP:SetPoint("TOPLEFT",4,-274);publishTRP:SetChecked(profilePrivacy.showTRPAdditionalInfo==true)
    publishTRP:SetScript("OnClick",function(self)
        local enabled=not self:GetChecked();self:SetChecked(enabled);profilePrivacy.showTRPAdditionalInfo=enabled
        if RPAL.RefreshTRPCompanionSummary then RPAL.RefreshTRPCompanionSummary(true) end
        if privacy._rpalRefreshPrivacyLabels then privacy:_rpalRefreshPrivacyLabels() end
    end)
    local publishHint=Font(privacy,"Opt-in. When enabled, ODYN publishes its compact Level / Renown / Sessions / Characters Met summary into your active TRP profile's Additional Information. Turning it off removes ODYN's generated rows.",9,MUTED)
    publishHint:SetPoint("TOPLEFT",32,-316);publishHint:SetWidth(520);publishHint:SetWordWrap(true)

    Section(privacy,"Total RP 3 Sync",-378)
    local auto=Check(privacy,"Automatically sync active TRP3 profile")
    auto:SetSize(552,36);auto:SetPoint("TOPLEFT",4,-405);auto:SetChecked(settings.autoTRPUpdate)
    auto:SetScript("OnClick",function(self)
        local enabled=not self:GetChecked();self:SetChecked(enabled);settings.autoTRPUpdate=enabled
        if RPAL.StartTRPAutoUpdate then RPAL.StartTRPAutoUpdate(true) end
    end)
    local syncHint=Font(privacy,"New TRP profiles still import automatically. Live sync watches later TRP edits and keeps ODYN's character information aligned with the active TRP profile.",9,MUTED)
    syncHint:SetPoint("TOPLEFT",32,-447);syncHint:SetWidth(520);syncHint:SetWordWrap(true)
    local visibility=Font(privacy,"",9.5,PALE)
    visibility:SetPoint("TOPLEFT",4,-507)
    local function RefreshPrivacyLabels()
        local hidden=profilePrivacy.hidePublicProfile or profilePrivacy.hideTRPCompanion
        local adVisible=(not hidden) and profilePrivacy.showTRPAdditionalInfo==true
        visibility:SetText("ODYN HUD: "..(hidden and "HIDDEN" or "VISIBLE").."  •  TRP Additional Information: "..(adVisible and "SHOWN" or "HIDDEN"))
        visibility:SetTextColor(unpack(hidden and {0.90,0.58,0.42} or {0.48,0.82,0.48}))
        privacyState:SetText(profilePrivacy.hidePublicProfile and "PROFILE: HIDDEN" or "PROFILE: PUBLIC")
        privacyState:SetTextColor(unpack(profilePrivacy.hidePublicProfile and {0.90,0.58,0.42} or {0.48,0.82,0.48}))
    end
    privacy._rpalRefreshPrivacyLabels=RefreshPrivacyLabels
    RefreshPrivacyLabels()

    -- TRP PROFILE LINKS ------------------------------------------------------
    local trpProfiles=MakePage("trpprofiles","TRP Profile Links","Link several Total RP 3 profiles on this WoW character to one ODYN progression/history profile, or unlink them later without deleting either side's stored data.")
    Section(trpProfiles,"Profile Mapping",-74)
    local activeTRP=(RPAL.GetActiveTRPProfileID and RPAL.GetActiveTRPProfileID()) or nil
    local _,activeScope=RPAL.GetCurrentTRPProfileScope and RPAL.GetCurrentTRPProfileScope()
    local activeLine=Font(trpProfiles,"Active TRP: "..tostring(activeTRP or "Unavailable").."  •  ODYN scope: "..tostring(activeScope or "Unavailable"),9.5,GOLD)
    activeLine:SetPoint("TOPLEFT",4,-101);activeLine:SetWidth(552);activeLine:SetWordWrap(false)
    local linkHint=Font(trpProfiles,"Linking shares ODYN progression, Sessions, Chronicle, customization and other profile-scoped data. Unlinking never deletes a saved scope; a previously independent profile returns to its own preserved ODYN data.",9,MUTED)
    linkHint:SetPoint("TOPLEFT",4,-128);linkHint:SetWidth(552);linkHint:SetWordWrap(true)

    local function TRPProfileDisplayName(id,profile)
        local c=type(profile)=="table" and (profile.characteristics or (profile.player and profile.player.characteristics)) or nil
        local first=type(c)=="table" and (c.FN or c.firstName) or nil
        local last=type(c)=="table" and (c.LN or c.lastName) or nil
        local name=((type(first)=="string" and first~="") and first or "")
        if type(last)=="string" and last~="" then name=(name~="" and (name.." "..last) or last) end
        if name=="" and type(profile)=="table" then
            local candidate=profile.profileName or profile.name
            if type(candidate)=="string" and candidate~="" then name=candidate end
        end
        return name~="" and name or ("TRP Profile "..tostring(id):sub(1,10))
    end

    local function CollectTRPProfilesForSettings()
        local out,seen={},{}
        local function Add(id,profile)
            if id==nil then return end
            id=tostring(id):gsub("^%s+",""):gsub("%s+$","")
            if id=="" or seen[id] then return end
            seen[id]=true
            out[#out+1]={id=id,name=TRPProfileDisplayName(id,profile)}
        end
        if type(_G.TRP3_Profiles)=="table" then
            for id,profile in pairs(_G.TRP3_Profiles) do if type(profile)=="table" then Add(id,profile) end end
        end
        local api=TRP3_API and TRP3_API.profile
        if type(api)=="table" then
            for _,fnName in ipairs({"getProfiles","getPlayerProfiles","getAllProfiles"}) do
                if type(api[fnName])=="function" then
                    local ok,profiles=pcall(api[fnName])
                    if ok and type(profiles)=="table" then
                        for id,profile in pairs(profiles) do if type(profile)=="table" then Add(id,profile) end end
                    end
                end
            end
            if activeTRP and type(api.getProfileByID)=="function" then
                local ok,profile=pcall(api.getProfileByID,activeTRP)
                Add(activeTRP,ok and profile or nil)
            end
        end
        local scopes=type(RPAL_CHAR_DB)=="table" and RPAL_CHAR_DB.trpProfileScopes or nil
        if type(scopes)=="table" then
            for id in pairs(scopes) do if id~=(RPAL.TRP_FALLBACK_PROFILE_ID or "__wow_character__") then Add(id,nil) end end
        end
        table.sort(out,function(a,b)
            if a.id==activeTRP then return true end
            if b.id==activeTRP then return false end
            return tostring(a.name):lower()<tostring(b.name):lower()
        end)
        return out
    end

    local scroll=CreateFrame("ScrollFrame",nil,trpProfiles,"UIPanelScrollFrameTemplate")
    if RPAL.SkinScrollFrame then RPAL.SkinScrollFrame(scroll) end
    scroll:SetPoint("TOPLEFT",4,-190);scroll:SetPoint("BOTTOMRIGHT",-26,18)
    local list=CreateFrame("Frame",nil,scroll);list:SetWidth(520);scroll:SetScrollChild(list)
    local profiles=CollectTRPProfilesForSettings();list:SetHeight(math.max(320,#profiles*68+8))
    if #profiles==0 then
        local none=Font(list,"No Total RP 3 profiles were detected yet. Open TRP once, then reopen this page.",10,MUTED);none:SetPoint("TOPLEFT",4,-12);none:SetWidth(480);none:SetWordWrap(true)
    end
    local bindings=RPAL.GetTRPProfileBindings and RPAL.GetTRPProfileBindings() or {}
    for index,item in ipairs(profiles) do
        local itemID=item.id
        local row=CreateFrame("Frame",nil,list,"BackdropTemplate");row:SetSize(500,58);row:SetPoint("TOPLEFT",2,-((index-1)*68));Backdrop(row,PANEL2,BORDER)
        local nm=Font(row,item.name,10.5,item.id==activeTRP and GOLD or PALE);nm:SetPoint("TOPLEFT",10,-9);nm:SetWidth(270);nm:SetWordWrap(false)
        local shortID=Font(row,item.id,7.8,MUTED);shortID:SetPoint("TOPLEFT",10,-30);shortID:SetWidth(270);shortID:SetWordWrap(false)
        local bound=bindings[itemID]
        local statusText=bound and ("LINKED → "..tostring(bound):sub(1,14)) or "Independent ODYN profile"
        local status=Font(row,statusText,8,bound and GREEN or MUTED);status:SetPoint("TOPRIGHT",-12,-9);status:SetWidth(190);status:SetJustifyH("RIGHT");status:SetWordWrap(false)
        if bound then
            local unlink=Button(row,"Unlink",84,24,false);unlink:SetPoint("BOTTOMRIGHT",-10,7);unlink:SetScript("OnClick",function()
                local ok,err=RPAL.UnlinkTRPProfile and RPAL.UnlinkTRPProfile(itemID)
                if not ok and err then print("|cffff7777ODYN:|r "..tostring(err)) end
                shade:Hide();RPAL.OpenSettings("trpprofiles")
            end)
        elseif activeScope and activeScope~=(RPAL.TRP_FALLBACK_PROFILE_ID or "__wow_character__") and itemID~=activeTRP then
            local link=Button(row,"Link to Current",112,24,true);link:SetPoint("BOTTOMRIGHT",-10,7);link:SetScript("OnClick",function()
                local ok,err=RPAL.LinkTRPProfileToCurrentScope and RPAL.LinkTRPProfileToCurrentScope(itemID)
                if not ok and err then print("|cffff7777ODYN:|r "..tostring(err)) end
                shade:Hide();RPAL.OpenSettings("trpprofiles")
            end)
        end
    end

    -- WIDGETS ---------------------------------------------------------------
    local widgets=MakePage("widgets","Widgets","Choose which lightweight ODYN HUD elements are visible, and place transient notifications where they suit your interface.")
    Section(widgets,"Player Frame",-74)
    local widgetToggle=Check(widgets,"Show ODYN player-frame widget")
    widgetToggle:SetSize(360,34);widgetToggle:SetPoint("TOPLEFT",4,-101);widgetToggle:SetChecked(settings.playerFrameWidgetEnabled)
    widgetToggle:SetScript("OnClick",function(self)local enabled=not self:GetChecked();self:SetChecked(enabled);settings.playerFrameWidgetEnabled=enabled;if RPAL.UpdatePlayerFrameWidget then RPAL.UpdatePlayerFrameWidget() end end)
    local resetWidget=Button(widgets,"Reset Position",120,28,false);resetWidget:SetPoint("TOPRIGHT",-4,-104)
    resetWidget:SetScript("OnClick",function()settings.playerFrameWidgetAngle=215;settings.playerFrameWidgetRadius=62;if RPAL.UpdatePlayerFrameWidget then RPAL.UpdatePlayerFrameWidget() end end)
    local customizeWidget=Button(widgets,"Customize Player Menu",176,28,true);customizeWidget:SetPoint("TOPLEFT",4,-145)
    customizeWidget:SetScript("OnClick",function()shade:Hide();RPAL.OpenPlayerWidgetMenuSettings()end)

    Section(widgets,"Minimap",-198)
    local minimapToggle=Check(widgets,"Show minimap widget")
    minimapToggle:SetSize(360,34);minimapToggle:SetPoint("TOPLEFT",4,-225);minimapToggle:SetChecked(settings.minimapWidgetEnabled)
    minimapToggle:SetScript("OnClick",function(self)local enabled=not self:GetChecked();self:SetChecked(enabled);settings.minimapWidgetEnabled=enabled;if RPAL.UpdateMinimapWidget then RPAL.UpdateMinimapWidget() end end)
    local resetMinimap=Button(widgets,"Reset Position",120,28,false);resetMinimap:SetPoint("TOPRIGHT",-4,-228)
    resetMinimap:SetScript("OnClick",function()settings.minimapWidgetAngle=225;if RPAL.UpdateMinimapWidget then RPAL.UpdateMinimapWidget() end end)

    Section(widgets,"Session",-288)
    local sessionWidgetToggle=Check(widgets,"Show Start Session widget")
    sessionWidgetToggle:SetSize(552,34);sessionWidgetToggle:SetPoint("TOPLEFT",4,-315);sessionWidgetToggle:SetChecked(settings.sessionWidgetEnabled)
    sessionWidgetToggle:SetScript("OnClick",function(self)
        local enabled=not self:GetChecked();self:SetChecked(enabled)
        if RPAL.SetSessionWidgetShown then RPAL.SetSessionWidgetShown(enabled) else settings.sessionWidgetEnabled=enabled end
    end)
    local widgetHint=Font(widgets,"All three widgets are independent from the main ODYN window. Position resets affect only the selected widget.",9,MUTED)
    widgetHint:SetPoint("TOPLEFT",4,-365);widgetHint:SetWidth(552);widgetHint:SetWordWrap(true)

    Section(widgets,"Toast Notifications",-420)
    local arrangeToasts=Button(widgets,"Move & Resize Toasts",176,30,true);arrangeToasts:SetPoint("TOPLEFT",4,-447)
    arrangeToasts:SetScript("OnClick",function()
        shade:Hide()
        local function OpenEditor()
            if RPAL.OpenToastLayoutEditor then RPAL.OpenToastLayoutEditor()
            else print("|cffff7777ODYN:|r Toast layout editor is unavailable.") end
        end
        if C_Timer and C_Timer.After then C_Timer.After(0,OpenEditor) else OpenEditor() end
    end)
    local toastHint=Font(widgets,"Drag a live preview and choose its scale. The setting is shared by level, calendar, session, DM, Talent and mini-game notifications.",9,MUTED)
    toastHint:SetPoint("TOPLEFT",4,-490);toastHint:SetWidth(552);toastHint:SetWordWrap(true)

    -- HOVER LOG -------------------------------------------------------------
    local hoverlog=MakePage("hoverlog","Hover Log","A compact, TRP-aware nearby roleplay log that appears beside the cursor only when you ask for it.")
    Section(hoverlog,"Behavior",-74)
    local hoverToggle=Check(hoverlog,"Enable Hover Log")
    hoverToggle:SetSize(300,34);hoverToggle:SetPoint("TOPLEFT",4,-101);hoverToggle:SetChecked(settings.hoverLogEnabled~=false)
    hoverToggle:SetScript("OnClick",function(self)
        local enabled=not self:GetChecked();self:SetChecked(enabled);settings.hoverLogEnabled=enabled
        if not enabled then
            if RPAL.SetHoverLogArmed then RPAL.SetHoverLogArmed(false,true) end
            if RPAL.ClearLendEar then RPAL.ClearLendEar() end
        elseif RPAL.RefreshHoverLogState then
            RPAL.RefreshHoverLogState()
        end
    end)
    local identityToggle=Check(hoverlog,"Show Currently on mouse over")
    identityToggle:SetSize(552,30);identityToggle:SetPoint("TOPLEFT",4,-137);identityToggle:SetChecked(settings.hoverIdentityCardEnabled~=false)
    identityToggle:SetScript("OnClick",function(self)
        local enabled=not self:GetChecked();self:SetChecked(enabled);settings.hoverIdentityCardEnabled=enabled
        if RPAL.RefreshHoverLogPrompt then RPAL.RefreshHoverLogPrompt() end
    end)

    local hotkeyHint=Font(hoverlog,"Hold the cursor on the same player for 0.4 seconds to reveal the Action Menu prompt. This option only controls the TRP Currently text; Quick Actions remain available either way.",8.8,MUTED)
    hotkeyHint:SetPoint("TOPLEFT",4,-174);hotkeyHint:SetWidth(552);hotkeyHint:SetWordWrap(true)

    Section(hoverlog,"Appearance",-220)
    local function HoverSlider(label,y,minValue,maxValue,step,current,formatValue,onChange)
        local name=Font(hoverlog,label,9.5,PALE);name:SetPoint("TOPLEFT",4,y)
        local valueText=Font(hoverlog,formatValue(current),9.5,GOLD);valueText:SetPoint("TOPLEFT",202,y)
        local slider=CreateFrame("Slider",nil,hoverlog,"OptionsSliderTemplate")
        slider:SetPoint("TOPLEFT",4,y-22);slider:SetSize(250,16);slider:SetMinMaxValues(minValue,maxValue);slider:SetValueStep(step)
        if slider.SetObeyStepOnDrag then slider:SetObeyStepOnDrag(true) end
        slider:SetScript("OnValueChanged",function(self,value)
            local rounded=math.floor((value/step)+0.5)*step
            valueText:SetText(formatValue(rounded))
            if not self._initial then onChange(rounded) end
        end)
        slider._initial=true;slider:SetValue(current);slider._initial=false
        return slider
    end
    HoverSlider("Text size",-248,11,21,0.5,tonumber(settings.hoverLogFontSize) or 16,function(v)return ("%.1f"):format(v)end,function(v)settings.hoverLogFontSize=v;if RPAL.RefreshHoverLogAppearance then RPAL.RefreshHoverLogAppearance() end end)
    HoverSlider("Background opacity",-304,0.25,0.85,0.01,tonumber(settings.hoverLogOpacity) or 0.52,function(v)return ("%d%%"):format(math.floor(v*100+0.5))end,function(v)settings.hoverLogOpacity=v;if RPAL.RefreshHoverLogAppearance then RPAL.RefreshHoverLogAppearance() end end)
    HoverSlider("Panel width",-360,300,900,10,tonumber(settings.hoverLogWidth) or 400,function(v)return tostring(math.floor(v+0.5)).." px" end,function(v)settings.hoverLogWidth=v;if RPAL.RefreshHoverLogAppearance then RPAL.RefreshHoverLogAppearance() end end)
    HoverSlider("Panel height",-416,180,650,10,tonumber(settings.hoverLogHeight) or 250,function(v)return tostring(math.floor(v+0.5)).." px" end,function(v)settings.hoverLogHeight=v;if RPAL.RefreshHoverLogAppearance then RPAL.RefreshHoverLogAppearance() end end)

    Section(hoverlog,"Alerts",-472)
    local soundChoices=RPAL.HOVER_LOG_SOUND_CHOICES or {
        {key="odyn_chime",label="ODYN Chime"},{key="emote_tap",label="Emote Tap"},{key="soft_tick",label="Soft Tick"},{key="none",label="None"},
    }
    local soundLabels,soundKeyByLabel={},{}
    for _,choice in ipairs(soundChoices) do soundLabels[#soundLabels+1]=choice.label;soundKeyByLabel[choice.label]=choice.key end
    local mentionLabel=Font(hoverlog,"NAME / NICKNAME MENTION",8.5,MUTED);mentionLabel:SetPoint("TOPLEFT",4,-500)
    local targetLabel=Font(hoverlog,"DIRECTED EMOTE / INTERACTION",8.5,MUTED);targetLabel:SetPoint("TOPLEFT",288,-500)
    local mentionChoice=RPAL.NormalizeHoverLogSoundChoice and RPAL.NormalizeHoverLogSoundChoice(settings.hoverLogMentionSoundChoice or "odyn_chime","mention") or tostring(settings.hoverLogMentionSoundChoice or "odyn_chime")
    local targetChoice=RPAL.NormalizeHoverLogSoundChoice and RPAL.NormalizeHoverLogSoundChoice(settings.hoverLogTargetSoundChoice or "emote_tap","target") or tostring(settings.hoverLogTargetSoundChoice or "emote_tap")
    settings.hoverLogMentionSoundChoice=mentionChoice;settings.hoverLogTargetSoundChoice=targetChoice
    local mentionDrop=Button(hoverlog,(RPAL.GetHoverLogSoundChoiceLabel and RPAL.GetHoverLogSoundChoiceLabel(mentionChoice)) or mentionChoice,268,30,false)
    mentionDrop:SetPoint("TOPLEFT",4,-517)
    mentionDrop:SetScript("OnClick",function(self)
        local selected=(RPAL.GetHoverLogSoundChoiceLabel and RPAL.GetHoverLogSoundChoiceLabel(settings.hoverLogMentionSoundChoice or "odyn_chime")) or tostring(settings.hoverLogMentionSoundChoice or "odyn_chime")
        PopupDropdown(hoverlog,self,soundLabels,selected,function(label)
            local key=soundKeyByLabel[label] or "odyn_chime";settings.hoverLogMentionSoundChoice=key;settings.hoverLogMentionSound=key~="none";self.label:SetText(label)
            if key~="none" and RPAL.PreviewHoverLogSoundChoice then RPAL.PreviewHoverLogSoundChoice(key,"mention") end
        end)
    end)
    local targetDrop=Button(hoverlog,(RPAL.GetHoverLogSoundChoiceLabel and RPAL.GetHoverLogSoundChoiceLabel(targetChoice)) or targetChoice,268,30,false)
    targetDrop:SetPoint("TOPLEFT",288,-517)
    targetDrop:SetScript("OnClick",function(self)
        local selected=(RPAL.GetHoverLogSoundChoiceLabel and RPAL.GetHoverLogSoundChoiceLabel(settings.hoverLogTargetSoundChoice or "emote_tap")) or tostring(settings.hoverLogTargetSoundChoice or "emote_tap")
        PopupDropdown(hoverlog,self,soundLabels,selected,function(label)
            local key=soundKeyByLabel[label] or "emote_tap";settings.hoverLogTargetSoundChoice=key;settings.hoverLogTargetSound=key~="none";self.label:SetText(label)
            if key~="none" and RPAL.PreviewHoverLogSoundChoice then RPAL.PreviewHoverLogSoundChoice(key,"target") end
        end)
    end)
    local soundHint=Font(hoverlog,"Mention listens for your TRP roleplay name plus imported Nickname / Alias / AKA fields. Directed interaction uses WoW's actual emote target, so ordinary speech from your selected target does not trigger it.",8.4,MUTED)
    soundHint:SetPoint("TOPLEFT",4,-555);soundHint:SetWidth(552);soundHint:SetWordWrap(true)

    -- CHAT QOL --------------------------------------------------------------
    local chatqol=MakePage("chatqol","Chat QoL","Speaker switching, TRP Companion/NPC speech and the RP-aware long-message composer can be turned off as one system.")
    Section(chatqol,"Chat Composer",-74)
    local chatQoLToggle=Check(chatqol,"Enable Chat QoL")
    chatQoLToggle:SetSize(552,34);chatQoLToggle:SetPoint("TOPLEFT",4,-101);chatQoLToggle:SetChecked(settings.chatQoLEnabled~=false)
    chatQoLToggle:SetScript("OnClick",function(self)
        local enabled=not self:GetChecked();self:SetChecked(enabled)
        settings.chatQoLEnabled=enabled
        if RPAL.SetChatQoLEnabled then RPAL.SetChatQoLEnabled(enabled) end
    end)
    local chatQoLHint=Font(chatqol,"Disabling this returns the WoW chat box to native behavior: the speaker portrait, TAB/context speaker cycle, Companion/NPC Speak helpers and ODYN long-message interception are disabled. Your keybind choices are kept for later.",9,MUTED)
    chatQoLHint:SetPoint("TOPLEFT",32,-143);chatQoLHint:SetWidth(520);chatQoLHint:SetWordWrap(true)

    Section(chatqol,"Long Messages",-218)
    local splitterToggle=Check(chatqol,"Enable Smart RP Splitter")
    splitterToggle:SetSize(552,34);splitterToggle:SetPoint("TOPLEFT",4,-245);splitterToggle:SetChecked(settings.chatSplitterEnabled~=false)
    splitterToggle:SetScript("OnClick",function(self)
        local enabled=not self:GetChecked();self:SetChecked(enabled);settings.chatSplitterEnabled=enabled
        if not enabled and RPAL.ChatQoL and RPAL.ChatQoL.CancelQueue then RPAL.ChatQoL.CancelQueue() end
    end)
    local splitterHint=Font(chatqol,"Lets you compose beyond WoW's normal chat limit and splits RP text safely while preserving dialogue/action formatting. For safety, one Enter always sends exactly one part; later parts wait for [ENTER] Continue.",9,MUTED)
    splitterHint:SetPoint("TOPLEFT",32,-287);splitterHint:SetWidth(520);splitterHint:SetWordWrap(true)

    Section(chatqol,"Speaker Controls",-370)
    local speakerHint=Font(chatqol,"Left-click the portrait beside chat, or use the binding under Settings > Keybinds, to cycle Self → active linked TRP pet(s) → active linked TRP mount → Self. Right-click opens one-shot NPC Speak. TAB is the default and overrides an existing WoW TAB bind only while chat is active.",9,MUTED)
    speakerHint:SetPoint("TOPLEFT",4,-398);speakerHint:SetWidth(552);speakerHint:SetWordWrap(true)

    -- KEYBINDS --------------------------------------------------------------
    local keybinds=MakePage("keybinds","Keybinds","Quick interactions stay close to the cursor; menu navigation shortcuts remain optional and easy to scan.")
    do
        local bindDefs=(RPAL.GetODYNKeybindDefs and RPAL.GetODYNKeybindDefs()) or RPAL.ODYN_KEYBIND_DEFS or {}
        local grouped={quick={},navigation={},roleplay={}}
        for _,def in ipairs(bindDefs) do
            local bucket=grouped[def.category or "navigation"] or grouped.navigation
            bucket[#bucket+1]=def
        end

        local function BindCard(def,x,y,width,labelWidth,buttonWidth)
            width=width or 270;labelWidth=labelWidth or 148;buttonWidth=buttonWidth or 102
            local card=CreateFrame("Frame",nil,keybinds,"BackdropTemplate")
            card:SetSize(width,24);card:SetPoint("TOPLEFT",x,y);Backdrop(card,PANEL2,BORDER)
            local label=Font(card,def.label or def.id,width<200 and 8.0 or 8.55,PALE);label:SetPoint("LEFT",8,0);label:SetWidth(labelWidth);label:SetWordWrap(false)
            local key=(RPAL.GetODYNKeybind and RPAL.GetODYNKeybind(def.id)) or ""
            local display=(RPAL.FormatODYNKey and RPAL.FormatODYNKey(key)) or (key~="" and key or "Unbound")
            local bindButton=Button(card,display,buttonWidth,19,key~="")
            bindButton:SetPoint("RIGHT",-3,0)
            bindButton:SetScript("OnClick",function()
                if RPAL.BeginODYNKeyCapture then RPAL.BeginODYNKeyCapture(def.id,bindButton,def.allowModifierOnly==true) end
            end)
        end

        Section(keybinds,"Quick Actions",-74)
        local bindHint=Font(keybinds,"Interact opens the hover Quick Action Menu. Other players: Q Inspect Profile, E Chat Log, R Whisper, F Open TRP. Self uses contextual IC/session actions. Context keys may overlap WoW binds. The Interact key also closes Chat Log.",8.35,MUTED)
        bindHint:SetPoint("TOPLEFT",4,-100);bindHint:SetWidth(552);bindHint:SetWordWrap(true)
        for index,def in ipairs(grouped.quick) do
            local column=(index-1)%2
            local rowIndex=math.floor((index-1)/2)
            BindCard(def,column==0 and 4 or 282,-140-(rowIndex*27))
        end

        Section(keybinds,"Navigation",-224)
        for index,def in ipairs(grouped.navigation) do
            local column=(index-1)%2
            local rowIndex=math.floor((index-1)/2)
            BindCard(def,column==0 and 4 or 282,-246-(rowIndex*25))
        end

        Section(keybinds,"Roleplay & Chat",-500)
        for index,def in ipairs(grouped.roleplay) do
            local column=(index-1)%2
            local rowIndex=math.floor((index-1)/2)
            BindCard(def,column==0 and 4 or 282,-524-(rowIndex*27))
        end
    end

    -- SURVIVAL --------------------------------------------------------------
    local survival=MakePage("survival","Survival Needs","Configure the optional hunger and thirst system without mixing it into unrelated interface settings.")
    Section(survival,"Hunger & Thirst",-74)
    local survivalToggle=Check(survival,"Enable hunger & thirst")
    survivalToggle:SetSize(552,34);survivalToggle:SetPoint("TOPLEFT",4,-101);survivalToggle:SetChecked(settings.survivalEnabled)
    survivalToggle:SetScript("OnClick",function(self)
        local enabled=not self:GetChecked();self:SetChecked(enabled);RPAL.SetSurvivalEnabled(enabled)
        if RPAL.UpdateSurvivalWidget then RPAL.UpdateSurvivalWidget(0) end;if RPAL.Refresh then RPAL.Refresh() end
    end)
    local hudHint=Font(survival,"The Survival HUD is independent from the main window. Drag its body to reposition it; the Consume plaque remains interactive.",9,MUTED)
    hudHint:SetPoint("TOPLEFT",32,-143);hudHint:SetWidth(520);hudHint:SetWordWrap(true)

    local pauseOOC=Check(survival,"Pause Survival while OOC")
    pauseOOC:SetSize(552,34);pauseOOC:SetPoint("TOPLEFT",4,-179);pauseOOC:SetChecked(settings.survivalPauseWhileOOC)
    pauseOOC:SetScript("OnClick",function(self)
        local enabled=not self:GetChecked();self:SetChecked(enabled)
        if RPAL.SetSurvivalPauseWhileOOC then RPAL.SetSurvivalPauseWhileOOC(enabled) else settings.survivalPauseWhileOOC=enabled end
        if RPAL.UpdateSurvivalWidget then RPAL.UpdateSurvivalWidget(0) end
    end)
    local pauseHint=Font(survival,"Stops Food and Water decay whenever your TRP status is Out of Character. Returning IC resumes from the same values.",9,MUTED)
    pauseHint:SetPoint("TOPLEFT",32,-221);pauseHint:SetWidth(520);pauseHint:SetWordWrap(true)

    local function SurvivalHoursSlider(label,y,minValue,maxValue,step,kind,current)
        local labelFS=Font(survival,label,10,PALE);labelFS:SetPoint("TOPLEFT",4,y)
        local valueFS=Font(survival,"",10,GOLD);valueFS:SetPoint("TOPLEFT",260,y)
        local slider=CreateFrame("Slider",nil,survival,"OptionsSliderTemplate");slider:SetPoint("TOPLEFT",4,y-24);slider:SetSize(300,16);slider:SetMinMaxValues(minValue,maxValue);slider:SetValueStep(step)
        if slider.SetObeyStepOnDrag then slider:SetObeyStepOnDrag(true) end
        local function FormatHours(value)return ("%.2f h"):format(value)end
        slider._settingInitial=true;slider:SetValue(current);slider._settingInitial=false;valueFS:SetText(FormatHours(current))
        slider:SetScript("OnValueChanged",function(self,value)
            local rounded=math.floor((value/step)+0.5)*step;valueFS:SetText(FormatHours(rounded));if not self._settingInitial then RPAL.SetSurvivalDecayHours(kind,rounded) end
        end)
        return slider
    end
    SurvivalHoursSlider("Food: full → empty",-276,1,8,0.25,"food",tonumber(settings.foodDecayHours) or 3.5)
    SurvivalHoursSlider("Water: full → empty",-353,0.5,4,0.25,"water",tonumber(settings.waterDecayHours) or 1.75)
    local survivalHint=Font(survival,"Higher hours mean slower decay. Defaults: Food 3.5h, Water 1.75h. Decay runs only while logged in and pauses completely when disabled.",9,MUTED)
    survivalHint:SetPoint("TOPLEFT",4,-437);survivalHint:SetWidth(552);survivalHint:SetWordWrap(true)

    -- FEATURES --------------------------------------------------------------
    local features=MakePage("features","Features","Optional ODYN systems and interaction safeguards live here instead of sharing space with visual settings.")
    Section(features,"Persona Beta",-74)
    local personaToggle=Check(features,"Enable Persona Beta")
    personaToggle:SetSize(552,34);personaToggle:SetPoint("TOPLEFT",4,-101);personaToggle:SetChecked(settings.personaEnabled~=false)
    personaToggle:SetScript("OnClick",function(self)
        local enabled=not self:GetChecked();self:SetChecked(enabled);settings.personaEnabled=enabled
        if RPAL.SetPersonaSystemEnabled then RPAL.SetPersonaSystemEnabled(enabled) end
    end)
    local personaHint=Font(features,"When disabled, ODYN stops analyzing /say and /emote for Persona Beta, stops social reads and autonomous thoughts, and does not inspect new chat for this system. Existing learned Persona data is kept so you can re-enable it later.",9,MUTED)
    personaHint:SetPoint("TOPLEFT",32,-143);personaHint:SetWidth(520);personaHint:SetWordWrap(true)

    Section(features,"DM Kit",-202)
    local dmKitToggle=Check(features,"Enable DM Kit")
    dmKitToggle:SetSize(552,34);dmKitToggle:SetPoint("TOPLEFT",4,-229);dmKitToggle:SetChecked(settings.dmKitEnabled)
    dmKitToggle:SetScript("OnClick",function(self)
        local enabled=not self:GetChecked();self:SetChecked(enabled)
        if RPAL.DMKit and RPAL.DMKit.SetEnabled then RPAL.DMKit.SetEnabled(enabled) else settings.dmKitEnabled=enabled end
        if RPAL.RefreshBookChrome then RPAL.RefreshBookChrome() end
    end)
    local dmHint=Font(features,"Adds the campaign/encounter toolkit and its dedicated navigation entry. Disabling it does not delete saved campaign data.",9,MUTED)
    dmHint:SetPoint("TOPLEFT",32,-271);dmHint:SetWidth(520);dmHint:SetWordWrap(true)

    Section(features,"Online Exchange",-330)
    local autoDecline=Check(features,"Auto-decline Exchanges")
    autoDecline:SetSize(552,34);autoDecline:SetPoint("TOPLEFT",4,-357);autoDecline:SetChecked(settings.autoDeclineExchanges)
    autoDecline:SetScript("OnClick",function(self)local enabled=not self:GetChecked();self:SetChecked(enabled);settings.autoDeclineExchanges=enabled end)
    local exchangeHint=Font(features,"Rejects incoming ODYN Exchange invitations before a preview is downloaded. Useful if someone is spamming requests.",9,MUTED)
    exchangeHint:SetPoint("TOPLEFT",32,-399);exchangeHint:SetWidth(520);exchangeHint:SetWordWrap(true)

    Section(features,"Activity Tracking",-458)
    local liteKit=RPAL.IsLiteKit and RPAL.IsLiteKit()
    local status,statusSource="UNKNOWN","none";if RPAL.GetTRPCharacterStatus then status,statusSource=RPAL.GetTRPCharacterStatus() end
    local statusColor=status=="IC" and {0.48,0.82,0.48} or (status=="OOC" and {0.90,0.48,0.48} or MUTED)
    local statusText=Font(features,liteKit and "Lite Kit active — progression rewards are disabled." or ("TRP Status: "..tostring(status).."  ("..tostring(statusSource)..")"),10,liteKit and MUTED or statusColor)
    statusText:SetPoint("TOPLEFT",4,-486)
    local rules=Font(features,liteKit and "Social interaction and Campaign statistics remain active, but Renown, levels, talents and progression rewards are disabled." or "Valid IC interactions always increase statistics. Renown rewards keep a per-player 30 second cooldown and a maximum of 5 points per rolling hour; LFC/OOC do not count.",9,MUTED)
    rules:SetPoint("TOPLEFT",4,-512);rules:SetWidth(552);rules:SetWordWrap(true)


    -- SOUNDS ----------------------------------------------------------------
    if RPAL.BuildSoundSettingsPage then
        RPAL.BuildSoundSettingsPage(MakePage("sounds","Sounds","Control ODYN notification and progression audio without changing World of Warcraft's global sound mix."),{
            Font=Font,Check=Check,Button=Button,Section=Section,MUTED=MUTED,PALE=PALE,GOLD=GOLD,
        })
    else
        MakePage("sounds","Sounds","ODYN sound settings are unavailable in this build.")
    end

    -- SPECIAL THANKS ---------------------------------------------------------
    local thanks=MakePage("thanks","Special Thanks","The people who helped shape ODYN during its earliest steps.")
    local plaque=CreateFrame("Frame",nil,thanks,"BackdropTemplate");plaque:SetPoint("TOPLEFT",4,-78);plaque:SetPoint("TOPRIGHT",-4,-78);plaque:SetHeight(260);Backdrop(plaque,PANEL2,GOLD);PolishPanel(plaque,"card")
    local crest=Heading(plaque,"WITH GRATITUDE",16);crest:SetPoint("TOP",0,-30)
    local thankText=Font(plaque,"Zylvera, Meadra, Daz aka Cactus, Dauby, Lyrianna and Melody Starcrown, thank you so much for your feedback and input throughout ODYN's early days. Your support shall be remembered! <3",12,PALE,DEFAULT_FONT)
    thankText:SetPoint("TOPLEFT",34,-82);thankText:SetPoint("TOPRIGHT",-34,-82);thankText:SetJustifyH("CENTER");thankText:SetWordWrap(true)
    local sig=Font(plaque,"— ODYN",10,GOLD,DEFAULT_FONT);sig:SetPoint("BOTTOM",0,28)

    -- CONTACT ---------------------------------------------------------------
    RPAL.BuildContactSettingsPage(MakePage("contact","Contact","Direct ways to reach the ODYN developer."))

    SelectPage(activePage)
    return shade,m
end


function RPAL.OpenSelfPublicPreview(mode)
    local page=(mode=="skills" or mode=="skillset" or mode=="skill set") and "skills" or ((mode=="injuries" or mode=="injury") and "injuries" or ((mode==true or mode=="achievements") and "achievements" or "statistics"))
    local skillMode=(page=="skills")
    local achMode=(page=="achievements")
    local injuryMode=(page=="injuries")
    local snapshot=RPAL.GetSelfPublicPreviewSnapshot and RPAL.GetSelfPublicPreviewSnapshot() or {}
    local profile=RPAL.GetProfile and RPAL.GetProfile() or {}
    local stats=RPAL.GetStatistics and RPAL.GetStatistics() or {score=0,charactersMet=0,counts={}}
    stats.counts=stats.counts or {}
    local cosmetics=RPAL.GetCosmeticSelection and RPAL.GetCosmeticSelection() or {}

    local displayName=RPAL.DisplayName and RPAL.DisplayName() or UnitName("player") or "Unknown"
    local titleText=profile.title or ""
    local guildText=RPAL.GuildDisplay and RPAL.GuildDisplay() or ""
    local longest=math.max(#displayName,#titleText,#guildText)
    local extraWidth=math.max(0,math.min(260,(longest-24)*7))
    local previewWidth=math.max(900,680+extraWidth)

    local shade,m=Modal("ODYN Profile Preview",previewWidth,720)

    RenderAtmosphere(m,cosmetics.background or "None",GOLD,0.72)
    RenderUIOrnament(m,cosmetics.uiOrnament or "Classic",GOLD,9)
    if RPAL.RenderEffectPack then RPAL.RenderEffectPack(m,cosmetics.effectPack or "None",GOLD,{frameLevelOffset=3}) end

    local note=Font(m,"This is a preview of how others see you.",10,GOLD)
    note:SetPoint("TOPLEFT",18,-48)

    local line=m:CreateTexture(nil,"ARTWORK")
    line:SetColorTexture(GOLD[1],GOLD[2],GOLD[3],0.35)
    line:SetHeight(1);line:SetPoint("TOPLEFT",18,-72);line:SetPoint("TOPRIGHT",-18,-72)

    local deco=RenderHeaderCosmetic(m,cosmetics.headerDecoration or "None",GOLD,390,90)
    if deco then
        deco:SetPoint("TOPLEFT",74,-82)
    end

    local portraitFrame=CreateFrame("Frame",nil,m)
    portraitFrame:SetSize(72,72)
    portraitFrame:SetPoint("TOPLEFT",18,-90)

    local pt=portraitFrame:CreateTexture(nil,"ARTWORK")
    pt:SetAllPoints()
    SetPortraitTexture(pt,"player")
    if MaskCircle then MaskCircle(pt) end

    RenderProfileAura(m,portraitFrame,cosmetics.profileAura or "None",GOLD,104)
    RenderPortraitCosmetic(m,portraitFrame,cosmetics.portraitFrame or "Default",GOLD,92)

    local currentLevel=RPAL.GetCurrentRenownLevel and select(1,RPAL.GetCurrentRenownLevel()) or 1
    local liteKit=RPAL.IsLiteKit and RPAL.IsLiteKit()
    if not liteKit then
        -- Keep the level badge visually attached to the identity portrait
        -- instead of floating in the statistics/header area.  This leaves the
        -- right side of Profile Preview clean for Renown and public stat cards.
        local previewBadgeAnchor=CreateFrame("Frame",nil,m)
        previewBadgeAnchor:SetSize(48,48);previewBadgeAnchor:SetPoint("BOTTOMRIGHT",portraitFrame,"BOTTOMRIGHT",14,-10)
        previewBadgeAnchor:SetFrameLevel((portraitFrame:GetFrameLevel() or m:GetFrameLevel())+16)
        RenderLevelBadge(m,previewBadgeAnchor,cosmetics.levelBadge or "Initiate Seal",46,currentLevel,0.38)
    end

    local nm=Heading(m,displayName,18)
    nm:SetPoint("TOPLEFT",portraitFrame,"TOPRIGHT",48,-1)
    nm:SetWidth(math.max(240,previewWidth-470))
    nm:SetWordWrap(false)
    nm:SetJustifyH("LEFT")

    local lr,lg,lb=GetRenownLevelColor(currentLevel)

    nm:ClearAllPoints()
    if liteKit then
        nm:SetPoint("TOPLEFT",portraitFrame,"TOPRIGHT",12,-1)
    else
        local previewLevelText=Font(m,"Lv. "..tostring(currentLevel),12,{lr,lg,lb})
        previewLevelText._rpalColorRole=nil
        previewLevelText:SetTextColor(lr,lg,lb,1)
        previewLevelText:SetPoint("TOPLEFT",portraitFrame,"TOPRIGHT",10,-2)
        previewLevelText:SetWidth(previewLevelText:GetStringWidth()+1)
        previewLevelText:SetWordWrap(false)
        nm:SetPoint("LEFT",previewLevelText,"RIGHT",4,0)
    end


    if titleText~="" then
        local tf=Font(m,titleText,10,MUTED);tf:SetPoint("TOPLEFT",nm,"BOTTOMLEFT",0,-5)
    end

    local identity={}
    if profile.race and profile.race~="" then identity[#identity+1]=profile.race end
    if profile.className and profile.className~="" then identity[#identity+1]=profile.className end
    if #identity>0 then
        local idf=Font(m,table.concat(identity," • "),10,PALE);idf:SetPoint("TOPLEFT",nm,"BOTTOMLEFT",0,-24)
    end

    local guild=guildText
    if guild~="" then
        local gf=Font(m,guild,9,MUTED);gf:SetPoint("TOPLEFT",nm,"BOTTOMLEFT",0,-43)
    end

    local statCardW=138
    local statCardGap=8
    local renown=CreateFrame("Frame",nil,m,"BackdropTemplate")
    renown:SetSize(statCardW,72);renown:SetPoint("TOPRIGHT",-18,-90);Backdrop(renown,PANEL2,GOLD)
    PolishPanel(renown,"card")
    AttachGentleFloat(renown,4,true)
    local rlab=Font(renown,"RENOWN",9,MUTED);rlab:SetPoint("TOPLEFT",11,-10)
    local rval=Heading(renown,tostring(snapshot.score or stats.score or 0),24);rval:SetPoint("BOTTOMLEFT",11,10)

    local campaigns=CreateFrame("Frame",nil,m,"BackdropTemplate")
    campaigns:SetSize(statCardW,72);campaigns:SetPoint("RIGHT",renown,"LEFT",-statCardGap,0);Backdrop(campaigns,PANEL2,GOLD)
    PolishPanel(campaigns,"card")
    AttachGentleFloat(campaigns,4,true)
    local clab=Font(campaigns,"CAMPAIGNS JOINED",8,MUTED);clab:SetPoint("TOPLEFT",10,-10);clab:SetWidth(statCardW-20);clab:SetWordWrap(false)
    local cval=Heading(campaigns,tostring(snapshot.campaignsJoined or stats.campaignsJoined or 0),24);cval:SetPoint("BOTTOMLEFT",10,10)

    local met=CreateFrame("Frame",nil,m,"BackdropTemplate")
    met:SetSize(statCardW,72);met:SetPoint("RIGHT",campaigns,"LEFT",-statCardGap,0);Backdrop(met,PANEL2,GOLD)
    PolishPanel(met,"card")
    AttachGentleFloat(met,4,true)
    local mlab=Font(met,"CHARACTERS MET",8,MUTED);mlab:SetPoint("TOPLEFT",10,-10);mlab:SetWidth(statCardW-20);mlab:SetWordWrap(false)
    local mval=Heading(met,tostring(snapshot.charactersMet or stats.charactersMet or 0),24);mval:SetPoint("BOTTOMLEFT",10,10)

    -- Self preview no longer duplicates the main Adventurer's Desk navigation.
    -- Statistics, Achievements, Skill Set and Injuries are already first-class
    -- pages in the left navigation; showing the same four buttons here made the
    -- preview feel like a second menu stacked inside the first one. Remote
    -- Inspect keeps its own tabs because those pages belong to the inspected
    -- player, not the local navigation.
    local memoirDock,pinnedDock

    local heading=Heading(m,page=="achievements" and "Completed Achievements" or (page=="skills" and "Skill Set" or (page=="injuries" and "Health" or "Statistics")),15)
    heading:SetPoint("TOPLEFT",230,-185)

    -- The self preview mirrors the public inspect layout so Character Details
    -- and Memoirs can be verified locally without requiring a second client.
    local detailPanel=CreateFrame("Frame",nil,m,"BackdropTemplate")
    detailPanel:SetPoint("TOPLEFT",18,-218)
    detailPanel:SetPoint("BOTTOMLEFT",18,18)
    detailPanel:SetWidth(194)
    detailPanel:SetBackdrop({
        bgFile="Interface\\Tooltips\\UI-Tooltip-Background",edgeFile="Interface\\Tooltips\\UI-Tooltip-Border",
        tile=false,tileSize=16,edgeSize=14,insets={left=4,right=4,top=4,bottom=4},
    })
    detailPanel:SetBackdropColor(PANEL2[1],PANEL2[2],PANEL2[3],0.90)
    detailPanel:SetBackdropBorderColor(GOLD[1],GOLD[2],GOLD[3],0.68)
    detailPanel:SetFrameLevel((m:GetFrameLevel() or 1)+18)

    local detailGlances=RPAL.CreatePublicGlanceStrip(m,32,5)
    detailGlances:SetPoint("BOTTOMLEFT",detailPanel,"TOPLEFT",7,7)
    detailGlances:SetFrameLevel((m:GetFrameLevel() or 1)+19)
    RPAL.UpdatePublicGlanceStrip(detailGlances,snapshot.glances or {},GOLD)
    RPAL.RenderPublicDetailPanel(detailPanel,snapshot.information or {},GOLD,false,snapshot.glances or {},snapshot.statuses or {})

    local platform=RPAL.RenderProfilePlatform(m,cosmetics.platformStyle or "Default",GOLD,330,74)
    platform:SetPoint("BOTTOMRIGHT",m,"BOTTOMLEFT",32,0)
    platform:SetFrameLevel((m:GetFrameLevel() or 1)+1)

    -- Render the player's live 3D model as a tall, non-interactive figure
    -- beside the left edge of the profile window.
    local model=CreateFrame("PlayerModel",nil,m)
    model:SetSize(388,720)
    -- Lower the viewport and tighten the camera together: the character grows
    -- slightly while the soles land on the illuminated centre of the plinth.
    model:SetPoint("BOTTOMRIGHT",m,"BOTTOMLEFT",46,-48)
    model:SetFrameLevel((m:GetFrameLevel() or 1)+2)
    model:SetAlpha(0.94)
    model:EnableMouse(false)
    model._rpalAnimationSelection=cosmetics.characterAnimations or {[cosmetics.characterAnimation or "Stand"]=true}
    local function SetupPreviewModel(self)
        self=self or model
        if not self or not self:IsShown() then return end
        pcall(function() if self.SetPortraitZoom then self:SetPortraitZoom(0) end end)
        pcall(function() if self.SetCamDistanceScale then self:SetCamDistanceScale(0.84) end end)
        pcall(function() if self.SetFacing then self:SetFacing(0.24) elseif self.SetRotation then self:SetRotation(0.24) end end)
        self._rpalModelReady=true
        self._rpalAppliedAnimationSignature=RPAL.AnimationSelectionSignature(self._rpalAnimationSelection or {})
        RPAL.ApplyProfileModelAnimation(self,self._rpalAnimationSelection or {})
    end
    model:SetScript("OnModelLoaded",SetupPreviewModel)
    pcall(function() model:SetUnit("player") end)
    if C_Timer and C_Timer.After then C_Timer.After(0.30,function()
        if model and model:IsShown() and not model._rpalModelReady then SetupPreviewModel(model) end
    end) end

    local scroll=CreateFrame("ScrollFrame",nil,m,"UIPanelScrollFrameTemplate")
    if RPAL.SkinScrollFrame then RPAL.SkinScrollFrame(scroll) end
    scroll:SetPoint("TOPLEFT",230,-218);scroll:SetPoint("BOTTOMRIGHT",-34,265)
    m.pageScroll=scroll;m._rpalScrollTop=-218;m._rpalScrollRight=-34;m._rpalNormalBottom=265;m._rpalInjuryBottom=18
    local content=CreateFrame("Frame",nil,scroll);content:SetSize(math.max(610,previewWidth-282),1);scroll:SetScrollChild(content)

    memoirDock=CreateFrame("Frame",nil,m,"BackdropTemplate")
    memoirDock:SetPoint("BOTTOMLEFT",230,104);memoirDock:SetPoint("BOTTOMRIGHT",-20,104);memoirDock:SetHeight(142)
    memoirDock:SetFrameLevel(m:GetFrameLevel()+24)
    if RPAL.ApplyTalentRewards then RPAL.ApplyTalentRewards() end
    local selfMemoirs=RPAL.GetUnlockedMemoirs and RPAL.GetUnlockedMemoirs() or {}
    memoirDock._expected=#selfMemoirs;memoirDock._finished=true
    if RPAL.RenderMemoirDock then RPAL.RenderMemoirDock(memoirDock,selfMemoirs,GOLD) end

    local publicShelfWidth=math.max(260,math.floor(((previewWidth-250)-8)/2))
    pinnedDock=CreateFrame("Frame",nil,m,"BackdropTemplate")
    pinnedDock:SetPoint("BOTTOMLEFT",230,18);pinnedDock:SetSize(publicShelfWidth,78)
    pinnedDock:SetFrameLevel(m:GetFrameLevel()+24)
    RenderPinnedMomentsDock(pinnedDock,snapshot.pinnedMoments or {},GOLD,"PINNED MEMORIES","No pinned memories selected.")
    local storyDock=CreateFrame("Frame",nil,m,"BackdropTemplate")
    storyDock:SetPoint("BOTTOMLEFT",238+publicShelfWidth,18);storyDock:SetSize(publicShelfWidth,78)
    storyDock:SetFrameLevel(m:GetFrameLevel()+24)
    if RPAL.RenderPinnedStoryDock then RPAL.RenderPinnedStoryDock(storyDock,snapshot.pinnedStories or {},GOLD,"PINNED STORIES","No pinned stories selected.") end
    m.memoirDock=memoirDock;m.momentDock=pinnedDock;m.storyDock=storyDock
    SetPreviewShelfMode(m,injuryMode,false)
    memoirDock:SetAlpha(1);pinnedDock:SetAlpha(1);storyDock:SetAlpha(1)

    local totalHeight=1
    if injuryMode then
        local injuryData=RPAL.GetPublicInjurySnapshot and RPAL.GetPublicInjurySnapshot() or {items={},groups={}}
        RPAL.RenderInjuryReadOnly(content,injuryData,GOLD)
        totalHeight=math.max(510,math.floor((m:GetHeight() or 720)-236))
    elseif skillMode then
        local values=(RPAL.GetSkillSetValues and RPAL.GetSkillSetValues()) or {}
        local categories=RPAL.SKILL_SET_CATEGORIES or {}
        local yoff=0
        local width=math.max(610,previewWidth-282)
        local gap=12
        local colW=math.floor((width-gap)/2)
        local levelNames={"UNTRAINED","NOVICE","COMPETENT","SKILLED","EXPERT","MASTER"}
        local iconNames={one_handed="badge.tga",two_handed="badge.tga",magic="wand.tga",skill="eye.tga",work="tools.tga"}
        for _,category in ipairs(categories) do
            local box=CreateFrame("Frame",nil,content,"BackdropTemplate");box:SetSize(width,46);box:SetPoint("TOPLEFT",0,-yoff);Backdrop(box,PANEL2,GOLD);PolishPanel(box,"card")
            local icon=box:CreateTexture(nil,"ARTWORK");icon:SetTexture(TEX.."IconPack\\"..(iconNames[category.id] or "badge.tga"));icon:SetSize(22,22);icon:SetPoint("LEFT",12,0);icon:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],0.92)
            local title=Font(box,category.name,12.5,GOLD);title:SetPoint("LEFT",icon,"RIGHT",10,5)
            local desc=Font(box,category.description or "",8.5,MUTED);desc:SetPoint("LEFT",icon,"RIGHT",10,-13);desc:SetPoint("RIGHT",-12,-13);desc:SetWordWrap(false)
            yoff=yoff+54
            for i,skill in ipairs(category.skills or {}) do
                local col=(i-1)%2;local row=math.floor((i-1)/2)
                local card=CreateFrame("Frame",nil,content,"BackdropTemplate");card:SetSize(colW,58);card:SetPoint("TOPLEFT",col*(colW+gap),-(yoff+row*68));Backdrop(card,PANEL2,BORDER);PolishPanel(card,"card");AttachHoverGrow(card,0.018)
                local name=Font(card,skill,11.5,PALE);name:SetPoint("TOPLEFT",12,-10);name:SetWidth(colW-145);name:SetWordWrap(false)
                local value=math.max(0,math.min(5,math.floor(tonumber(values[skill]) or 0)))
                local level=Font(card,levelNames[value+1] or levelNames[1],8.5,GOLD);level:SetPoint("TOPLEFT",12,-34)
                for pip=1,5 do
                    local t=card:CreateTexture(nil,"ARTWORK");t:SetTexture("Interface\\Buttons\\WHITE8X8");t:SetSize(13,5);t:SetPoint("RIGHT",-10-(5-pip)*16,0);t:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],pip<=value and 0.95 or 0.20)
                end
            end
            yoff=yoff+math.ceil(#(category.skills or {})/2)*68+12
        end
        totalHeight=yoff+20
    elseif achMode then
        local completed=snapshot.completedAchievements or {}
        if #completed==0 then
            local empty=Font(content,"No completed achievements to display.",10,MUTED);empty:SetPoint("TOPLEFT",0,0);totalHeight=30
        else
            local yoff=0
            for _,item in ipairs(completed) do
                local row=CreateFrame("Frame",nil,content,"BackdropTemplate");row:SetPoint("TOPLEFT",0,-yoff);row:SetPoint("TOPRIGHT",0,-yoff);row:SetHeight(50);Backdrop(row,PANEL2,BORDER);PolishPanel(row,"card");AttachHoverGrow(row,0.025)
                local icon=row:CreateTexture(nil,"ARTWORK");icon:SetSize(34,34);icon:SetPoint("LEFT",8,0);icon:SetTexture((item.icon and item.icon~="") and item.icon or RPAL.ICONS.question)
                local titleText=Font(row,item.name or "Achievement",10,PALE);titleText:SetPoint("TOPLEFT",50,-8)
                local desc=Font(row,RPAL.ReplaceName and RPAL.ReplaceName(item.description or "") or (item.description or ""),9,MUTED);desc:SetPoint("TOPLEFT",50,-25);desc:SetPoint("RIGHT",-8,0);desc:SetWordWrap(false)
                yoff=yoff+56
            end
            totalHeight=yoff
        end
    else
        local keys=RPAL.STAT_ORDER or {}
        local cols=3;local cw=190;local ch=60;local gap=10
        for i,key in ipairs(keys) do
            local row=math.floor((i-1)/cols);local col=(i-1)%cols
            local card=CreateFrame("Frame",nil,content,"BackdropTemplate");card:SetSize(cw,ch);card:SetPoint("TOPLEFT",col*(cw+gap),-(row*(ch+gap)));Backdrop(card,PANEL2,BORDER);PolishPanel(card,"card");AttachHoverGrow(card,0.028)
            local def=RPAL.STAT_DEFINITIONS and RPAL.STAT_DEFINITIONS[key]
            local med=RPAL.CreateStatMedallion and RPAL.CreateStatMedallion(card,STAT_ICON[key] or STAT_ICON.renown,GOLD,34);if med then med:SetPoint("LEFT",8,0) end
            local label=Font(card,(def and def.command) or ("/"..key),12,PALE);label:SetPoint("LEFT",48,0);label:SetWidth(cw-82);label:SetJustifyH("LEFT");label:SetWordWrap(false)
            local value=(snapshot.counts and snapshot.counts[key]) or stats.counts[key] or 0
            local val=Heading(card,tostring(value),20);val:SetPoint("RIGHT",-10,0)
            totalHeight=math.max(totalHeight,(row+1)*(ch+gap))
        end
    end
    content:SetHeight(math.max(1,totalHeight))
end


function RPAL.CustomizationThemePalette(name)
    if name=="Class Specific" then
        local r,g,b=ThemeSwatch(name)
        return {r*0.12,g*0.12,b*0.12,1},{r*0.32,g*0.32,b*0.32,1},{r,g,b,1}
    end
    local t=THEMES[name] or THEMES["Obsidian Crown"]
    return t.main or MAIN_BG,t.panel2 or PANEL2,t.gold or GOLD
end

function RPAL.DrawCustomizationThemeSwatch(parent,name,w,h,active,desaturated)
    local width=math.max(32,tonumber(w) or 70)
    local height=math.max(30,tonumber(h) or 66)
    local stage=RPAL.CustomizationPreviewStage(parent,width,height)
    stage:SetClipsChildren(true)

    local mainColor,panelColor,accentColor=RPAL.CustomizationThemePalette(name)
    local function ResolveColor(c)
        local r,g,b=tonumber(c and c[1]) or 0,tonumber(c and c[2]) or 0,tonumber(c and c[3]) or 0
        if desaturated then
            local lum=(r*.299)+(g*.587)+(b*.114)
            r,g,b=lum,lum,lum
        end
        return r,g,b
    end

    local innerW=math.max(18,width-10)
    local firstW=math.max(6,math.floor(innerW*0.32))
    local secondW=math.max(6,math.floor(innerW*0.32))
    local thirdX=5+firstW+1+secondW+1

    local band1=stage:CreateTexture(nil,"ARTWORK")
    band1:SetTexture("Interface\\Buttons\\WHITE8X8")
    band1:SetPoint("TOPLEFT",5,-5);band1:SetPoint("BOTTOMLEFT",5,5);band1:SetWidth(firstW)
    do local r,g,b=ResolveColor(mainColor);band1:SetVertexColor(r,g,b,desaturated and .62 or 1) end

    local band2=stage:CreateTexture(nil,"ARTWORK")
    band2:SetTexture("Interface\\Buttons\\WHITE8X8")
    band2:SetPoint("TOPLEFT",6+firstW,-5);band2:SetPoint("BOTTOMLEFT",6+firstW,5);band2:SetWidth(secondW)
    do local r,g,b=ResolveColor(panelColor);band2:SetVertexColor(r,g,b,desaturated and .62 or 1) end

    local band3=stage:CreateTexture(nil,"ARTWORK")
    band3:SetTexture("Interface\\Buttons\\WHITE8X8")
    band3:SetPoint("TOPLEFT",thirdX,-5);band3:SetPoint("BOTTOMRIGHT",-5,5)
    do local r,g,b=ResolveColor(accentColor);band3:SetVertexColor(r,g,b,desaturated and .62 or 1) end

    local halo=stage:CreateTexture(nil,"OVERLAY",nil,2)
    halo:SetTexture("Interface\\Buttons\\UI-ActionButton-Border")
    halo:SetBlendMode("ADD")
    halo:SetAllPoints(stage)
    do
        local r,g,b=ResolveColor(accentColor)
        halo:SetVertexColor(r,g,b,active and .48 or (desaturated and .08 or .16))
    end
    if active and not desaturated and RPAL.AddCustomizationShine then RPAL.AddCustomizationShine(stage,accentColor) end
    return stage
end

function RPAL.AddCustomizationGlow(frame,active,color)
    if not frame or frame._rpalCustomizationGlow then return end
    frame._rpalCustomizationGlow=true
    frame:EnableMouse(true)

    color=color or GOLD
    local r,g,b=color[1] or GOLD[1],color[2] or GOLD[2],color[3] or GOLD[3]

    local wash=frame:CreateTexture(nil,"BACKGROUND",nil,-3)
    wash:SetTexture("Interface\\Buttons\\WHITE8X8")
    wash:SetPoint("TOPLEFT",3,-3)
    wash:SetPoint("BOTTOMRIGHT",-3,3)
    wash:SetVertexColor(r,g,b,1)
    wash:SetBlendMode("ADD")
    wash:SetAlpha(active and 0.055 or 0)

    local edge={}
    local function Edge(pointA,pointB,width,height)
        local t=frame:CreateTexture(nil,"ARTWORK",nil,3)
        t:SetTexture("Interface\\Buttons\\WHITE8X8")
        t:SetVertexColor(r,g,b,1)
        t:SetBlendMode("ADD")
        t:SetAlpha(active and 0.55 or 0)
        t:SetPoint(pointA,frame,pointA,0,0)
        if pointB then t:SetPoint(pointB,frame,pointB,0,0) end
        if width then t:SetWidth(width) end
        if height then t:SetHeight(height) end
        edge[#edge+1]=t
    end
    Edge("TOPLEFT","TOPRIGHT",nil,2)
    Edge("BOTTOMLEFT","BOTTOMRIGHT",nil,2)
    Edge("TOPLEFT","BOTTOMLEFT",2,nil)
    Edge("TOPRIGHT","BOTTOMRIGHT",2,nil)

    frame:HookScript("OnEnter",function()
        wash:SetAlpha(active and 0.085 or 0.045)
        for _,t in ipairs(edge) do t:SetAlpha(active and 0.90 or 0.52) end
    end)
    frame:HookScript("OnLeave",function()
        wash:SetAlpha(active and 0.055 or 0)
        for _,t in ipairs(edge) do t:SetAlpha(active and 0.55 or 0) end
    end)

    if active then
        local phase=0
        frame:HookScript("OnUpdate",function(_,elapsed)
            phase=phase+(elapsed or 0)
            local pulse=(math.sin(phase*2.4)+1)*0.5
            if not frame:IsMouseOver() then
                wash:SetAlpha(0.035+(pulse*0.035))
                local a=0.42+(pulse*0.30)
                for _,t in ipairs(edge) do t:SetAlpha(a) end
            end
        end)
    end
end

function RPAL.AddCustomizationShine(frame,color)
    if not frame or frame._rpalCustomizationShine then return end
    frame._rpalCustomizationShine=true
    frame:SetClipsChildren(true)
    color=color or GOLD

    local shine=frame:CreateTexture(nil,"OVERLAY",nil,4)
    shine:SetTexture("Interface\\Buttons\\WHITE8X8")
    shine:SetVertexColor(color[1] or GOLD[1],color[2] or GOLD[2],color[3] or GOLD[3],1)
    shine:SetBlendMode("ADD")
    shine:SetAlpha(0.18)
    shine:SetWidth(5)
    shine:SetPoint("TOP",frame,"TOP",0,-2)
    shine:SetPoint("BOTTOM",frame,"BOTTOM",0,2)

    local x=-60
    frame:HookScript("OnUpdate",function(_,elapsed)
        if not frame:IsShown() then return end
        x=x+((elapsed or 0)*18)
        local w=frame:GetWidth() or 100
        if x>w+60 then x=-60 end
        shine:ClearAllPoints()
        shine:SetPoint("TOP",frame,"TOPLEFT",x,-2)
        shine:SetPoint("BOTTOM",frame,"BOTTOMLEFT",x,2)
    end)
end


function RPAL.AnimateCustomizationCard(frame,index)
    if not frame or frame._rpalCustomizationEntrance then return end
    frame._rpalCustomizationEntrance=true
    frame:SetAlpha(0)

    local delay=math.min(0.28,math.max(0,((tonumber(index) or 1)-1)*0.018))
    local elapsedTotal=0
    frame:HookScript("OnUpdate",function(self,elapsed)
        if self._rpalCustomizationEntranceDone then return end
        elapsedTotal=elapsedTotal+(elapsed or 0)
        if elapsedTotal<delay then return end
        local t=math.min(1,(elapsedTotal-delay)/0.20)
        self:SetAlpha(1-((1-t)*(1-t)))
        if t>=1 then
            self:SetAlpha(1)
            self._rpalCustomizationEntranceDone=true
        end
    end)
end

function RPAL.CustomizationStateBadge(parent,text,active,unlocked)
    local pill=CreateFrame("Frame",nil,parent,"BackdropTemplate")
    pill:SetSize(active and 80 or 74,20)
    pill:SetPoint("TOPRIGHT",-10,-10)
    local c=active and GOLD or (unlocked and {0.42,0.72,0.48} or MUTED)
    pill:SetBackdrop({bgFile="Interface\\Tooltips\\UI-Tooltip-Background",edgeFile="Interface\\Tooltips\\UI-Tooltip-Border",tile=false,tileSize=16,edgeSize=9,insets={left=3,right=3,top=3,bottom=3}})
    pill:SetBackdropColor(0.025,0.018,0.012,0.88)
    pill:SetBackdropBorderColor(c[1],c[2],c[3],active and 0.90 or 0.58)
    local fs=Font(pill,text,8,c,DEFAULT_FONT)
    fs:SetPoint("CENTER",0,0)
    return pill
end

function RPAL.CustomizationCost(parent,cost)
    local icon=parent:CreateTexture(nil,"ARTWORK")
    icon:SetTexture(NATIVE_CREST_ICON)
    icon:SetSize(14,14)
    icon:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],1)
    local text=Font(parent,tostring(cost or 0),9,GOLD,DEFAULT_FONT)
    text:SetPoint("LEFT",icon,"RIGHT",4,0)
    return icon,text
end

function RPAL.CustomizationPreviewStage(parent,w,h)
    local stage=CreateFrame("Frame",nil,parent,"BackdropTemplate")
    stage:SetSize(w,h)
    stage:SetBackdrop({bgFile="Interface\\Tooltips\\UI-Tooltip-Background",edgeFile="Interface\\Tooltips\\UI-Tooltip-Border",tile=false,tileSize=16,edgeSize=12,insets={left=4,right=4,top=4,bottom=4}})
    stage:SetBackdropColor(MAIN_BG[1],MAIN_BG[2],MAIN_BG[3],0.90)
    stage:SetBackdropBorderColor(BORDER[1],BORDER[2],BORDER[3],0.76)
    AddCardTexture(stage,0.22)
    local glow=stage:CreateTexture(nil,"BACKGROUND",nil,-2)
    glow:SetTexture("Interface\\Buttons\\WHITE8X8")
    glow:SetPoint("TOPLEFT",5,-5);glow:SetPoint("BOTTOMRIGHT",-5,5)
    glow:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],0.028)
    glow:SetBlendMode("ADD")
    return stage
end

local function RenderCustomizationShowroom(stage,overrideKind,overrideName)
    if not stage then return end

    local appearance=RPAL.GetCosmeticSelection and RPAL.GetCosmeticSelection() or {}
    local selected={
        portraitFrame=appearance.portraitFrame or "Default",
        levelBadge=appearance.levelBadge or "Initiate Seal",
        headerDecoration=appearance.headerDecoration or "None",
        background=appearance.background or "None",
        profileAura=appearance.profileAura or "None",
        uiOrnament=appearance.uiOrnament or "Classic",
        effectPack=appearance.effectPack or "None",
        platformStyle=appearance.platformStyle or "Default",
        characterAnimation=appearance.characterAnimation or "Stand",
        characterAnimations=appearance.characterAnimations or {},
    }
    local selectionKeys={
        portrait="portraitFrame",level="levelBadge",header="headerDecoration",background="background",aura="profileAura",
        ornament="uiOrnament",effect="effectPack",platform="platformStyle",animation="characterAnimation",
    }
    if selectionKeys[overrideKind] and overrideName then selected[selectionKeys[overrideKind]]=overrideName end
    if overrideKind=="animation" and overrideName then
        selected.characterAnimations={[overrideName]=true}
    end

    local accent={GOLD[1],GOLD[2],GOLD[3],GOLD[4] or 1}
    local showroomBg={MAIN_BG[1],MAIN_BG[2],MAIN_BG[3],0.98}
    if overrideKind=="theme" and overrideName then
        local mainColor,_,accentColor=RPAL.CustomizationThemePalette(overrideName)
        showroomBg={mainColor[1],mainColor[2],mainColor[3],0.98}
        accent={accentColor[1],accentColor[2],accentColor[3],1}
    end

    stage:SetBackdropColor(showroomBg[1],showroomBg[2],showroomBg[3],showroomBg[4])
    stage:SetBackdropBorderColor(accent[1],accent[2],accent[3],0.82)

    -- Keep one showroom and one PlayerModel alive for the whole customization
    -- session.  Hover used to create and load a fresh 3D model every time the
    -- cursor crossed a card, which was the largest source of visible stalls.
    local content=stage._rpalShowroomContent
    if not content then
        content=CreateFrame("Frame",nil,stage)
        content:SetPoint("TOPLEFT",5,-5);content:SetPoint("BOTTOMRIGHT",-5,5)
        content:SetClipsChildren(true)
        stage._rpalShowroomContent=content
    end

    if content._rpalShowroomVisual then
        content._rpalShowroomVisual:Hide()
        content._rpalShowroomVisual:SetParent(nil)
    end
    local visual=CreateFrame("Frame",nil,content)
    visual:SetAllPoints(content)
    visual:SetFrameLevel((content:GetFrameLevel() or 1)+1)
    content._rpalShowroomVisual=visual

    RenderAtmosphere(content,selected.background,accent,0.92)
    RenderUIOrnament(visual,selected.uiOrnament,accent,5)
    if RPAL.RenderEffectPack then RPAL.RenderEffectPack(visual,selected.effectPack,accent,{preview=true,frameLevelOffset=2}) end
    if (overrideKind=="toast" or overrideKind=="tooltip" or overrideKind=="focus") and RPAL.RenderInteractionCustomizationPreview then
        RPAL.RenderInteractionCustomizationPreview(visual,overrideKind,overrideName,accent)
    end

    local eyebrow=Font(visual,"LIVE PROFILE PREVIEW",8,MUTED,DEFAULT_FONT)
    eyebrow:SetPoint("TOPLEFT",12,-11)
    local shownName=overrideName and tostring(overrideName) or "Equipped appearance"
    local title=Heading(visual,shownName,14)
    title:SetPoint("TOPLEFT",12,-28);title:SetWidth(270);title:SetWordWrap(false)

    local portrait=visual:CreateTexture(nil,"ARTWORK")
    portrait:SetSize(54,54);portrait:SetPoint("TOPRIGHT",-12,-11)
    SetPortraitTexture(portrait,"player")
    if MaskCircle then MaskCircle(portrait) end
    RenderProfileAura(visual,portrait,selected.profileAura,accent,78)
    RenderPortraitCosmetic(visual,portrait,selected.portraitFrame,accent,70)
    local showroomBadgeAnchor=CreateFrame("Frame",nil,visual)
    showroomBadgeAnchor:SetSize(48,48);showroomBadgeAnchor:SetPoint("TOPRIGHT",-92,-78)
    RenderLevelBadge(visual,showroomBadgeAnchor,selected.levelBadge,46)
    local header=RenderHeaderCosmetic(visual,selected.headerDecoration,accent,270,62)
    if header then header:SetPoint("TOP",0,-58) end

    local platform=RPAL.RenderProfilePlatform(visual,selected.platformStyle,accent,270,62)
    platform:SetPoint("BOTTOM",0,28)
    platform:SetFrameLevel((visual:GetFrameLevel() or 1)+1)

    local model=content._rpalShowroomModel
    if not model then
        model=CreateFrame("PlayerModel",nil,content)
        model:SetSize(300,415);model:SetPoint("BOTTOM",0,-5)
        model:SetFrameLevel((content:GetFrameLevel() or 1)+3)
        model:SetAlpha(0.97);model:EnableMouse(false)
        content._rpalShowroomModel=model
        model._rpalSetup=function(self)
            if not self or not self:IsShown() then return end
            pcall(function()if self.SetPortraitZoom then self:SetPortraitZoom(0) end end)
            pcall(function()if self.SetCamDistanceScale then self:SetCamDistanceScale(0.91) end end)
            pcall(function()if self.SetFacing then self:SetFacing(0.18) elseif self.SetRotation then self:SetRotation(0.18) end end)
            self._rpalModelReady=true
            local selection=self._rpalAnimationSelection or {}
            self._rpalAppliedAnimationSignature=RPAL.AnimationSelectionSignature(selection)
            RPAL.ApplyProfileModelAnimation(self,selection)
        end
        model:SetScript("OnModelLoaded",function(self)
            if self._rpalSetup then self._rpalSetup(self) end
        end)
        pcall(function()model:SetUnit("player")end)
        if C_Timer and C_Timer.After then C_Timer.After(0.30,function()
            if model and model:IsShown() and not model._rpalModelReady and model._rpalSetup then model._rpalSetup(model) end
        end) end
    end
    model._rpalAnimationSelection=selected.characterAnimations
    local selectedAnimationSignature=RPAL.AnimationSelectionSignature(selected.characterAnimations)
    if model._rpalModelReady and model._rpalAppliedAnimationSignature~=selectedAnimationSignature then
        model._rpalAppliedAnimationSignature=selectedAnimationSignature
        RPAL.ApplyProfileModelAnimation(model,selected.characterAnimations)
    end

    local instruction=Font(visual,overrideName and "HOVER PREVIEW — move away to restore equipped look" or "Hover any customization to preview it here",8,MUTED,DEFAULT_FONT)
    instruction:SetPoint("BOTTOM",0,9);instruction:SetWidth(280);instruction:SetJustifyH("CENTER")
end

function RPAL.RefreshMainSurfaceCosmetics()
    if not UI.main then return end
    if UI.mainOrnament then
        UI.mainOrnament:Hide();UI.mainOrnament:SetParent(nil);UI.mainOrnament=nil
    end
    if UI.mainEffect then
        UI.mainEffect:Hide();UI.mainEffect:SetParent(nil);UI.mainEffect=nil
    end
    local appearance=RPAL.GetCosmeticSelection and RPAL.GetCosmeticSelection() or {}
    UI.mainOrnament=RenderUIOrnament(UI.main,appearance.uiOrnament or "Classic",GOLD,9)
    if RPAL.RenderEffectPack then UI.mainEffect=RPAL.RenderEffectPack(UI.main,appearance.effectPack or "None",GOLD) end
end

function RPAL.RefreshProfileAppearanceSurfaces()
    local appearance=RPAL.GetCosmeticSelection and RPAL.GetCosmeticSelection() or {}
    if UI.header then RenderAtmosphere(UI.header,"None",GOLD,1,false) end
    if UI.content then RenderAtmosphere(UI.content,appearance.background or "None",GOLD,0.72,true) end
    RPAL.RefreshMainSurfaceCosmetics()
    if UI.ready and UI.main and UI.main:IsShown() then RenderPreview() end
end

RPAL.OnCosmeticEquipped=function(kind)
    if kind=="portrait" or kind=="header" or kind=="aura" or kind=="level" then
        if UI.ready and UI.main and UI.main:IsShown() then RenderPreview() end
    elseif kind=="background" then
        local appearance=RPAL.GetCosmeticSelection and RPAL.GetCosmeticSelection() or {}
        if UI.header then RenderAtmosphere(UI.header,"None",GOLD,1,false) end
        if UI.content then RenderAtmosphere(UI.content,appearance.background or "None",GOLD,0.72,true) end
    elseif kind=="effect" or kind=="ornament" then
        if UI.customizationFrame and UI.customizationFrame:IsShown() then
            UI.customizationFrame._rpalSurfaceCosmeticsDirty=true
        else
            RPAL.RefreshMainSurfaceCosmetics()
        end
    elseif kind=="focus" then
        if RPAL.RefreshFocusMarks then RPAL.RefreshFocusMarks() end
    elseif kind=="eye" then
        if RPAL.UpdatePlayerFrameWidget then RPAL.UpdatePlayerFrameWidget() end
        if RPAL.UpdateMinimapWidget then RPAL.UpdateMinimapWidget() end
    elseif kind=="tooltip" then
        if RPAL.RefreshCursorTooltipTreatment then RPAL.RefreshCursorTooltipTreatment() end
    elseif kind=="toast" then
        for _,toast in ipairs({_G.RPAL_LevelToast,_G.RPAL_RewardToast}) do
            if toast and RPAL.ApplyToastFrameCustomization then RPAL.ApplyToastFrameCustomization(toast,GOLD) end
        end
    end
end

function RPAL.CloseCustomization(frame)
    frame=frame or UI.customizationFrame
    if not frame then return end
    local refreshSurface=frame._rpalSurfaceCosmeticsDirty and true or false
    if frame._activeScroll and frame._selectedTab then
        PROFILE_CUSTOMIZATION.state.scroll[frame._selectedTab]=frame._activeScroll:GetVerticalScroll() or 0
    end
    -- Clear the callback first so an explicit Hide cannot recursively clean
    -- the same frame. This also makes parent/ESC closure deterministic.
    frame:SetScript("OnHide",nil)
    frame:Hide()
    if UI.customizationFrame==frame then UI.customizationFrame=nil end
    if RPAL.HideCursorTooltip then RPAL.HideCursorTooltip() end
    frame:SetParent(nil)
    -- Equip runs while this full-screen child is still on top of the ledger.
    -- Attach the final holder only after the child is detached from main UI.
    if refreshSurface then RPAL.RefreshMainSurfaceCosmetics() end
end

function RPAL.OpenCustomization(initialTab)
    if RPAL.IsLiteKit and RPAL.IsLiteKit() then
        if RPAL.ShowSessionStatusToast then
            RPAL.ShowSessionStatusToast("Customization is disabled in Lite Kit. Re-run Setup from Settings to switch to Full Kit.","PAUSED")
        else
            print("|cffffcc66ODYN:|r Customization is disabled in Lite Kit. Re-run Setup from Settings to switch to Full Kit.")
        end
        return false
    end
    if not UI.main then return end
    if not UI.main:IsShown() then UI.main:Show() end

    if UI.customizationFrame then
        local old=UI.customizationFrame
        RPAL.CloseCustomization(old)
    end

    local selectedTab=initialTab or PROFILE_CUSTOMIZATION.state.tab or "Themes"
    if selectedTab=="Level Badges" then selectedTab="Badges" end
    PROFILE_CUSTOMIZATION.state.tab=selectedTab

    local m=CreateFrame("Frame",nil,UI.main,"BackdropTemplate")
    local shade=m
    UI.customizationFrame=m
    m._selectedTab=selectedTab
    m:SetPoint("TOPLEFT",16,-16);m:SetPoint("BOTTOMRIGHT",-16,16)
    m:SetFrameLevel((UI.main:GetFrameLevel() or 100)+400)
    m:SetToplevel(true);m:EnableMouse(true)
    Backdrop(m,MAIN_BG,GOLD);AddFrameShadow(m,10,0.62);AddPageDecoration(m);PolishPanel(m,"modal")

    local title=Heading(m,"CUSTOMIZATION",18);title:SetPoint("TOPLEFT",20,-17)
    local close=CloseButton(m);close:SetPoint("TOPRIGHT",-10,-9);close:SetFrameLevel(m:GetFrameLevel()+40)
    close:SetScript("OnClick",function()RPAL.CloseCustomization(m)end)
    m:SetScript("OnHide",function(self)
        -- A child receives OnHide when its parent is hidden even though the
        -- child's own shown state remains true. Do not detach in that state.
        if self:IsShown() then return end
        RPAL.CloseCustomization(self)
    end)

    local showroomAppearance=RPAL.GetCosmeticSelection and RPAL.GetCosmeticSelection() or {}
    RenderUIOrnament(m,showroomAppearance.uiOrnament or "Classic",GOLD,9)

    local tabDefs={
        {key="Themes",label="Themes"},
        {key="Portrait Frames",label="Portraits"},
        {key="Badges",label="Badges"},
        {key="Header Decorations",label="Headers"},
        {key="Backgrounds",label="Atmospheres"},
        {key="Profile Auras",label="Auras"},
        {key="Effects",label="Effects"},
        {key="UI Ornaments",label="Ornaments"},
        {key="Toast Frames",label="Toasts"},
        {key="Tooltip Treatments",label="Tooltips"},
        {key="Focus Marks",label="Focus"},
        {key="Odyn's Eye",label="Odyn's Eye"},
        {key="Platforms",label="Platforms"},
        {key="Character Animations",label="Animations"},
    }

    -- Showroom masthead -----------------------------------------------------
    local vault=Font(m,"APPEARANCE VAULT",9,MUTED,DEFAULT_FONT)
    vault:SetPoint("TOPLEFT",22,-52)

    local currency=CreateFrame("Frame",nil,m,"BackdropTemplate")
    currency:SetSize(148,32)
    currency:SetPoint("TOPRIGHT",-22,-67)
    Backdrop(currency,{MAIN_BG[1],MAIN_BG[2],MAIN_BG[3],0.90},GOLD)
    PolishPanel(currency,"card")

    local crestHalo=currency:CreateTexture(nil,"BACKGROUND",nil,-1)
    crestHalo:SetTexture("Interface\\Buttons\\UI-ActionButton-Border")
    crestHalo:SetBlendMode("ADD")
    crestHalo:SetSize(44,44)
    crestHalo:SetPoint("LEFT",-2,0)
    crestHalo:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],0.34)

    local crestPhase=0
    currency:HookScript("OnUpdate",function(self,elapsed)
        if not self:IsShown() then return end
        crestPhase=crestPhase+(elapsed or 0)
        crestHalo:SetAlpha(0.52+((math.sin(crestPhase*1.4)+1)*0.16))
    end)

    local crestIcon=currency:CreateTexture(nil,"ARTWORK")
    crestIcon:SetTexture(NATIVE_CREST_ICON)
    crestIcon:SetSize(20,20)
    crestIcon:SetPoint("LEFT",9,0)
    crestIcon:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],1)

    local balance=Heading(currency,tostring(RPAL.GetCrests and RPAL.GetCrests() or 0),15)
    balance:SetPoint("LEFT",crestIcon,"RIGHT",8,1)
    local crestLabel=Font(currency,"TOKENS",7,MUTED,DEFAULT_FONT)
    crestLabel:SetPoint("LEFT",balance,"RIGHT",7,0)

    local collectionHint=Font(m,"TRP profile collection  •  Every role character keeps its own unlocks and loadout",9,MUTED,DEFAULT_FONT)
    collectionHint:SetPoint("TOPLEFT",22,-73);collectionHint:SetWidth(790);collectionHint:SetWordWrap(false)

    local mastRule=m:CreateTexture(nil,"ARTWORK")
    mastRule:SetTexture("Interface\\Buttons\\WHITE8X8")
    mastRule:SetColorTexture(GOLD[1],GOLD[2],GOLD[3],0.18)
    mastRule:SetHeight(1)
    mastRule:SetPoint("TOPLEFT",22,-82)
    mastRule:SetPoint("TOPRIGHT",-182,-82)

    -- Compact embedded collection navigation. Widths follow label length so
    -- Atmospheres / Ornaments / Animations no longer collide while the shorter
    -- labels stop wasting horizontal room.
    local tabGap=5
    local tabWidths={
        Themes=72,Portraits=78,Badges=70,Headers=76,Atmospheres=102,Auras=66,Effects=72,
        Ornaments=88,Toasts=68,Tooltips=76,Focus=66,["Odyn's Eye"]=88,Platforms=84,Animations=92,
    }
    local tabX=22
    for i,tab in ipairs(tabDefs) do
        local activeTab=tab.key==selectedTab
        local tabWidth=tabWidths[tab.label] or 78
        local b=Button(m,tab.label,tabWidth,33,activeTab)
        b:SetPoint("TOPLEFT",tabX,-111)
        tabX=tabX+tabWidth+tabGap

        local marker=b:CreateTexture(nil,"ARTWORK",nil,2)
        marker:SetTexture("Interface\\Buttons\\WHITE8X8")
        marker:SetHeight(activeTab and 3 or 1)
        marker:SetPoint("BOTTOMLEFT",10,4)
        marker:SetPoint("BOTTOMRIGHT",-10,4)
        marker:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],activeTab and 1 or 0.22)
        if activeTab then
            marker:SetBlendMode("ADD")
            RPAL.AddCustomizationGlow(b,true,GOLD)
        end

        b:SetScript("OnClick",function()
            if tab.key==selectedTab then return end
            RPAL.OpenCustomization(tab.key)
        end)
    end

    local divider=m:CreateTexture(nil,"ARTWORK")
    divider:SetTexture("Interface\\Buttons\\WHITE8X8")
    divider:SetColorTexture(GOLD[1],GOLD[2],GOLD[3],0.35)
    divider:SetHeight(1);divider:SetPoint("TOPLEFT",22,-153);divider:SetPoint("TOPRIGHT",-22,-153)

    local showroom=CreateFrame("Frame",nil,m,"BackdropTemplate")
    showroom:SetPoint("TOPRIGHT",-20,-165);showroom:SetPoint("BOTTOMRIGHT",-20,22);showroom:SetWidth(310)
    showroom:SetBackdrop({bgFile="Interface\\Tooltips\\UI-Tooltip-Background",edgeFile="Interface\\Tooltips\\UI-Tooltip-Border",tile=false,tileSize=16,edgeSize=14,insets={left=4,right=4,top=4,bottom=4}})
    showroom:SetBackdropColor(MAIN_BG[1],MAIN_BG[2],MAIN_BG[3],0.98)
    showroom:SetBackdropBorderColor(GOLD[1],GOLD[2],GOLD[3],0.82)
    RenderCustomizationShowroom(showroom)

    local function AttachCustomizationHover(card,kind,name)
        if not card then return end
        card:EnableMouse(true)
        card:HookScript("OnEnter",function()RenderCustomizationShowroom(showroom,kind,name)end)
        card:HookScript("OnLeave",function()RenderCustomizationShowroom(showroom)end)
    end

    local function MakeScroll()
        local scroll=CreateFrame("ScrollFrame",nil,m,"UIPanelScrollFrameTemplate")
        if RPAL.SkinScrollFrame then RPAL.SkinScrollFrame(scroll) end
        scroll:SetPoint("TOPLEFT",22,-220)
        scroll:SetPoint("BOTTOMRIGHT",-350,22)
        local content=CreateFrame("Frame",nil,scroll)
        content:SetWidth(810)
        scroll:SetScrollChild(content)
        m._activeScroll=scroll
        local saved=tonumber(PROFILE_CUSTOMIZATION.state.scroll[selectedTab]) or 0
        if C_Timer and C_Timer.After then C_Timer.After(0,function()
            if scroll and scroll:IsShown() then scroll:SetVerticalScroll(saved) end
        end) else scroll:SetVerticalScroll(saved) end
        return scroll,content
    end

    local function OwnedCount(order,kind)
        local owned=0
        for _,name in ipairs(order or {}) do
            local unlocked
            if kind=="theme" then
                unlocked=(name=="Class Specific") or RPAL.IsThemeUnlocked(name)
            else
                unlocked=RPAL.IsCosmeticUnlocked(kind,name)
            end
            if unlocked then owned=owned+1 end
        end
        return owned,#(order or {})
    end

    -- Recomputed only when this customization surface is rebuilt (tab change
    -- or close/reopen). That keeps newly unlocked items easy to find without
    -- making cards jump around while the player is looking at the current tab.
    local function CustomizationDisplayOrder(order,kind)
        local unlocked,locked={},{}
        for _,name in ipairs(order or {}) do
            local owned
            if kind=="theme" then owned=(name=="Class Specific") or RPAL.IsThemeUnlocked(name)
            else owned=RPAL.IsCosmeticUnlocked(kind,name) end
            if owned then unlocked[#unlocked+1]=name else locked[#locked+1]=name end
        end
        for _,name in ipairs(locked) do unlocked[#unlocked+1]=name end
        return unlocked
    end

    if selectedTab=="Themes" then
        local appearance=RPAL.GetCharacterThemeSelection()
        local owned,total=OwnedCount(THEME_ORDER,"theme")

        local sectionTitle=Heading(m,"THEME COLLECTION",14)
        sectionTitle:SetPoint("TOPLEFT",22,-170)
        AddSectionRule(m,sectionTitle,24)

        local meta=Font(m,owned.."/"..total.." COLLECTED",8,GOLD,DEFAULT_FONT)
        meta:SetPoint("TOPRIGHT",-354,-173)

        local hint=Font(m,
            "Build the visual mood of this character. Equipped palettes breathe softly; every card previews its real color hierarchy.",
            9,MUTED)
        hint:SetPoint("TOPLEFT",22,-193)
        hint:SetWidth(780)
        hint:SetWordWrap(true)

        local displayOrder=CustomizationDisplayOrder(THEME_ORDER,"theme")
        local scroll,content=MakeScroll()
        local rows=math.ceil(#displayOrder/2)
        content:SetHeight(math.max(430,rows*108))

        for index,name in ipairs(displayOrder) do
            local row=math.floor((index-1)/2)
            local col=(index-1)%2
            local cost=RPAL.GetAdjustedCustomizationPrice(THEME_COSTS[name] or 0)
            local unlocked=(name=="Class Specific") or RPAL.IsThemeUnlocked(name)
            local unlockSource=RPAL.GetProgressionUnlockSource and RPAL.GetProgressionUnlockSource("theme",name)
            local progressionLocked=RPAL.IsProgressionCosmeticLocked and RPAL.IsProgressionCosmeticLocked(unlockSource,unlocked) or false
            local active=(name=="Class Specific" and not appearance.themeManualOverride)
                or (appearance.themeManualOverride and appearance.themeName==name)

            local card=CreateFrame("Frame",nil,content,"BackdropTemplate")
            card:SetSize(395,98)
            card:SetPoint("TOPLEFT",col*407,-row*108)
            Backdrop(card,PANEL2,active and GOLD or BORDER)
            PolishPanel(card,"card")
            AttachGentleFloat(card,3,true)
            RPAL.AnimateCustomizationCard(card,index)
            AttachCustomizationHover(card,"theme",name)

            local mainColor,panelColor,accentColor=RPAL.CustomizationThemePalette(name)
            RPAL.AddCustomizationGlow(card,active,accentColor)

            local stage=RPAL.DrawCustomizationThemeSwatch(card,name,70,66,active,false)
            stage:SetPoint("TOPLEFT",12,-16)

            local title=Font(card,ThemeDisplayName(name),13,PALE)
            title:SetPoint("TOPLEFT",94,-16)

            local subtitle
            if name=="Class Specific" then
                subtitle="TRP class color driven palette"
            elseif name=="Obsidian Crown" then
                subtitle="ODYN default • obsidian and old gold"
            elseif name=="Ancient Book" then
                subtitle="Classic original bound-ledger palette"
            elseif active then
                subtitle="Equipped on this character"
            elseif unlocked then
                subtitle="Owned palette • ready to equip"
            elseif progressionLocked then
                subtitle=(unlockSource and unlockSource.source=="future") and "Preview only • arrives in Fall 2026 Part II" or "Progression reward • complete its listed requirement"
            else
                subtitle="Locked palette • unlock with Tokens"
            end
            local sub=Font(card,subtitle,8,MUTED)
            sub:SetPoint("TOPLEFT",94,-38)
            sub:SetWidth(205)
            sub:SetWordWrap(false)

            RPAL.CustomizationStateBadge(card,active and "EQUIPPED" or (unlocked and "OWNED" or "LOCKED"),active,unlocked)

            if not unlocked then
                if progressionLocked then
                    local requirement=Font(card,unlockSource.label,8,GOLD,DEFAULT_FONT);requirement:SetPoint("BOTTOMLEFT",94,14)
                else
                    local ci,ct=RPAL.CustomizationCost(card,cost);ci:SetPoint("BOTTOMLEFT",94,13)
                end
            else
                local state=Font(card,active and "CURRENT LOOK" or "IN COLLECTION",8,active and GOLD or MUTED)
                state:SetPoint("BOTTOMLEFT",94,14)
            end

            local action=Button(card,active and "Equipped" or (unlocked and "Equip" or (progressionLocked and ((unlockSource and unlockSource.source=="future") and "Coming Soon" or "Earn") or "Unlock")),82,27,not active and not progressionLocked)
            action:SetPoint("BOTTOMRIGHT",-10,9)
            if active or progressionLocked then
                action:Disable()
            else
                action:SetScript("OnClick",function()
                    if not unlocked and name~="Class Specific" then
                        if not (RPAL.SpendStones or RPAL.SpendCrests)(cost) then
                            print("|cffff6666ODYN:|r Not enough Tokens.")
                            return
                        end
                        RPAL.UnlockTheme(name)
                    end
                    shade:Hide()
                    RPAL.SetTheme(name)
                    RPAL.OpenCustomization("Themes")
                end)
            end
        end

    elseif selectedTab=="Badges" then
        m._rpalBadgesTab=true
        local current=(RPAL.GetCosmeticSelection() or {}).levelBadge or "Initiate Seal"
        local owned,total=OwnedCount(COSMETICS.level.order,"level")

        local sectionTitle=Heading(m,"BADGES",14)
        sectionTitle:SetPoint("TOPLEFT",22,-170);AddSectionRule(m,sectionTitle,24)
        local meta=Font(m,owned.."/"..total.." EARNED",8,GOLD,DEFAULT_FONT);meta:SetPoint("TOPRIGHT",-354,-173)
        local help=Font(m,"Renown, Legacy and Main insignia. Every Badge can be previewed, but none can be purchased with Tokens.",9,MUTED)
        help:SetPoint("TOPLEFT",22,-193);help:SetWidth(780);help:SetWordWrap(true)

        local displayOrder=CustomizationDisplayOrder(COSMETICS.level.order,"level")
        local scroll,content=MakeScroll()
        local rows=math.ceil(#displayOrder/3);content:SetHeight(math.max(560,116+(rows*218)))

        local function RemainingLabel(seconds)
            seconds=math.max(0,math.floor(tonumber(seconds) or 0))
            local days=math.ceil(seconds/86400)
            return days>1 and (days.." days") or (days==1 and "1 day" or "ready")
        end

        local mainInfo=RPAL.GetMainCharacterInfo and RPAL.GetMainCharacterInfo()
        local isMain=RPAL.IsCurrentCharacterMain and RPAL.IsCurrentCharacterMain()
        local mainRemaining=RPAL.GetMainCharacterCooldownRemaining and RPAL.GetMainCharacterCooldownRemaining() or 0
        local mainCard=CreateFrame("Frame",nil,content,"BackdropTemplate");mainCard:SetSize(395,100);mainCard:SetPoint("TOPLEFT",0,0)
        Backdrop(mainCard,PANEL2,isMain and GOLD or BORDER);PolishPanel(mainCard,"card")
        local mainEyebrow=Font(mainCard,isMain and "CURRENT MAIN" or "MAIN CHARACTER",8,isMain and GREEN or GOLD,DEFAULT_FONT);mainEyebrow:SetPoint("TOPLEFT",14,-12)
        local mainTitle=Font(mainCard,isMain and "Captain's Radiance equipped" or (mainInfo and (tostring(mainInfo.displayName or "Another character").." is Main") or "Choose this character as Main"),11.5,PALE)
        mainTitle:SetPoint("TOPLEFT",14,-32);mainTitle:SetWidth(255);mainTitle:SetHeight(28);mainTitle:SetWordWrap(true)
        local mainSubText=isMain and ("Main change available in "..RemainingLabel(mainRemaining))
            or (mainInfo and mainRemaining>0 and ("Change cooldown: "..RemainingLabel(mainRemaining)) or "Unlocks the exclusive animated Captain Badge")
        local mainSub=Font(mainCard,mainSubText,8,MUTED);mainSub:SetPoint("TOPLEFT",14,-68);mainSub:SetWidth(255);mainSub:SetWordWrap(false)
        local mainAction=Button(mainCard,isMain and "Selected" or (mainInfo and mainRemaining>0 and "Cooldown" or "Set as Main"),104,30,not isMain and mainRemaining<=0)
        m._rpalMainAction=mainAction
        mainAction:SetPoint("RIGHT",-12,0)
        if isMain or mainRemaining>0 then mainAction:Disable() else
            mainAction:SetScript("OnClick",function()
                local ok,info=RPAL.SetCurrentCharacterAsMain and RPAL.SetCurrentCharacterAsMain()
                if not ok then print("|cffff7777ODYN:|r "..tostring(info or "Main character could not be changed."));return end
                shade:Hide()
            end)
        end

        local calibration=RPAL.GetLegacyCalibration and RPAL.GetLegacyCalibration()
        local legacyCard=CreateFrame("Frame",nil,content,"BackdropTemplate");legacyCard:SetSize(395,100);legacyCard:SetPoint("TOPLEFT",407,0)
        Backdrop(legacyCard,PANEL2,(calibration and calibration.completed) and GOLD or BORDER);PolishPanel(legacyCard,"card")
        local legacyEyebrow=Font(legacyCard,"LEGACY CALIBRATION",8,GOLD,DEFAULT_FONT);legacyEyebrow:SetPoint("TOPLEFT",14,-12)
        local legacyTitleText=(calibration and calibration.completed) and ((RPAL.LEGACY_CALIBRATION_TIERS[calibration.tier] or {}).label or "Complete") or "Bring an established RP character forward"
        local legacyTitle=Font(legacyCard,legacyTitleText,11.5,PALE);legacyTitle:SetPoint("TOPLEFT",14,-32);legacyTitle:SetWidth(250);legacyTitle:SetHeight(28);legacyTitle:SetWordWrap(true)
        local legacySubText
        if calibration and calibration.completed then
            local remaining=math.max(0,tonumber(calibration.catchupSessionsRemaining) or 0)
            legacySubText=remaining>0 and (tostring(calibration.catchupPercent or 0).."% catch-up • "..remaining.." Sessions left") or "One-time profile calibration complete"
        else
            legacySubText="No fabricated emotes, people or Session history"
        end
        local legacySub=Font(legacyCard,legacySubText,8,MUTED);legacySub:SetPoint("TOPLEFT",14,-68);legacySub:SetWidth(250);legacySub:SetWordWrap(false)
        local legacyAction=Button(legacyCard,(calibration and calibration.completed) and "Complete" or "Calibrate",98,30,not (calibration and calibration.completed))
        m._rpalLegacyAction=legacyAction
        legacyAction:SetPoint("RIGHT",-12,0)
        if calibration and calibration.completed then legacyAction:Disable() else
            legacyAction:SetScript("OnClick",function()if RPAL.OpenLegacyCalibration then RPAL.OpenLegacyCalibration() end end)
        end

        for index,name in ipairs(displayOrder) do
            local row=math.floor((index-1)/3);local col=(index-1)%3
            local active=current==name
            local unlocked=RPAL.IsCosmeticUnlocked("level",name)
            local required=COSMETICS.level.levels[name]
            local r,g,b=GetRenownLevelColor(required or 1)
            if name=="Legacy Chronicle" then r,g,b=0.82,0.58,1.00 elseif name=="Captain's Radiance" then r,g,b=1.00,0.82,0.30
    elseif name=="Open Beta Crest" then r,g,b=0.66,0.50,1.00 end
            local tierColor={r,g,b}
            local card=CreateFrame("Frame",nil,content,"BackdropTemplate")
            card:SetSize(260,205);card:SetPoint("TOPLEFT",col*274,-116-(row*218))
            Backdrop(card,PANEL2,active and tierColor or BORDER);PolishPanel(card,"card");AttachGentleFloat(card,3,true)
            RPAL.AddCustomizationGlow(card,active,tierColor);RPAL.AnimateCustomizationCard(card,index)
            AttachCustomizationHover(card,"level",name)

            local stage=RPAL.CustomizationPreviewStage(card,230,116);stage:SetPoint("TOP",0,-14);stage:SetClipsChildren(true)
            local anchor=CreateFrame("Frame",nil,stage);anchor:SetSize(92,92);anchor:SetPoint("CENTER",0,-1)
            RenderLevelBadge(stage,anchor,name,88,required or 1)
            if active then RPAL.AddCustomizationShine(stage,tierColor) end

            local title=Font(card,name,12,PALE);title:SetPoint("TOPLEFT",14,-139)
            RPAL.CustomizationStateBadge(card,active and "EQUIPPED" or (unlocked and "EARNED" or "LOCKED"),active,unlocked)
            local specialSource=COSMETICS.level.sources[name]
            local sourceText
            if specialSource then sourceText=unlocked and (active and "CURRENT INSIGNIA" or specialSource) or specialSource
            else sourceText=unlocked and (active and "CURRENT INSIGNIA" or "EARNED AT LEVEL "..required) or "UNLOCKS AT LEVEL "..required end
            local source=Font(card,sourceText,8,unlocked and tierColor or MUTED)
            source:SetPoint("BOTTOMLEFT",14,15)
            local lockedLabel=specialSource and "Locked" or ("Level "..required)
            local action=Button(card,active and "Equipped" or (unlocked and "Equip" or lockedLabel),82,27,unlocked and not active)
            action:SetPoint("BOTTOMRIGHT",-10,9)
            if not unlocked or active then action:Disable() else
                action:SetScript("OnClick",function()
                    RPAL.EquipCosmetic("level",name);shade:Hide();RPAL.OpenCustomization("Badges")
                end)
            end
        end
        m._rpalBadgeCardCount=#COSMETICS.level.order

    elseif selectedTab=="Backgrounds" then
        local current=(RPAL.GetCosmeticSelection() or {}).background or "None"
        local owned,total=OwnedCount(BACKGROUND_ORDER,"background")

        local sectionTitle=Heading(m,"PROFILE ATMOSPHERES",14)
        sectionTitle:SetPoint("TOPLEFT",22,-170)
        AddSectionRule(m,sectionTitle,24)

        local meta=Font(m,owned.."/"..total.." COLLECTED",8,GOLD,DEFAULT_FONT)
        meta:SetPoint("TOPRIGHT",-354,-173)

        local help=Font(m,
            "Ambient fantasy overlays tint the page behind your profile with mist, candlelight, frost, embers, starlight and enchanted haze. They stay intentionally soft so your information remains readable.",
            9,MUTED)
        help:SetPoint("TOPLEFT",22,-193);help:SetWidth(780);help:SetWordWrap(true)

        local displayOrder=CustomizationDisplayOrder(BACKGROUND_ORDER,"background")
        local scroll,content=MakeScroll()
        local rows=math.ceil(#displayOrder/2)
        content:SetHeight(math.max(430,rows*205))

        for index,name in ipairs(displayOrder) do
            local row=math.floor((index-1)/2)
            local col=(index-1)%2
            local active=current==name
            local unlocked=RPAL.IsCosmeticUnlocked("background",name)
            local cost=RPAL.GetAdjustedCustomizationPrice(BACKGROUND_COSTS[name] or 0)
            local unlockSource=RPAL.GetProgressionUnlockSource and RPAL.GetProgressionUnlockSource("background",name)
            local progressionLocked=RPAL.IsProgressionCosmeticLocked and RPAL.IsProgressionCosmeticLocked(unlockSource,unlocked) or false

            local card=CreateFrame("Frame",nil,content,"BackdropTemplate")
            card:SetSize(395,192)
            card:SetPoint("TOPLEFT",col*407,-row*205)
            Backdrop(card,PANEL2,active and GOLD or BORDER)
            PolishPanel(card,"card")
            AttachGentleFloat(card,3,true)
            RPAL.AddCustomizationGlow(card,active,GOLD)
            RPAL.AnimateCustomizationCard(card,index)
            AttachCustomizationHover(card,"background",name)

            local stage=RPAL.CustomizationPreviewStage(card,367,116)
            stage:SetPoint("TOP",0,-13)
            stage:SetClipsChildren(true)

            local haze=stage:CreateTexture(nil,"BACKGROUND",nil,-1)
            haze:SetTexture("Interface\\Buttons\\WHITE8X8")
            haze:SetPoint("TOPLEFT",4,-4);haze:SetPoint("BOTTOMRIGHT",-4,4)
            haze:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],active and 0.060 or 0.020)
            haze:SetBlendMode("ADD")

            if name=="None" then
                local none=Font(stage,"NO ATMOSPHERE",12,MUTED)
                none:SetPoint("CENTER",0,0)
            else
                RenderAtmosphere(stage,name,GOLD,active and 1.10 or 0.92,false)
                local atmosphereName=Font(stage,string.upper(name),8,PALE,DEFAULT_FONT)
                atmosphereName:SetPoint("BOTTOMLEFT",12,10)
                atmosphereName:SetShadowColor(0,0,0,1)
                atmosphereName:SetShadowOffset(1,-1)
                if active then RPAL.AddCustomizationShine(stage,GOLD) end
            end

            local title=Font(card,name,12,PALE)
            title:SetPoint("BOTTOMLEFT",14,36)
            RPAL.CustomizationStateBadge(card,active and "EQUIPPED" or (unlocked and "OWNED" or "LOCKED"),active,unlocked)

            if not unlocked then
                if progressionLocked then
                    local requirement=Font(card,unlockSource.label,8,GOLD,DEFAULT_FONT);requirement:SetPoint("BOTTOMLEFT",14,14)
                else
                    local ci,ct=RPAL.CustomizationCost(card,cost);ci:SetPoint("BOTTOMLEFT",14,12)
                end
            else
                local state=Font(card,active and "CURRENT ATMOSPHERE" or "IN COLLECTION",8,active and GOLD or MUTED)
                state:SetPoint("BOTTOMLEFT",14,14)
            end

            local action=Button(card,active and "Equipped" or (unlocked and "Equip" or (progressionLocked and ((unlockSource and unlockSource.source=="future") and "Coming Soon" or "Earn") or "Unlock")),82,27,not active and not progressionLocked)
            action:SetPoint("BOTTOMRIGHT",-10,9)
            if active or progressionLocked then
                action:Disable()
            else
                action:SetScript("OnClick",function()
                    if not unlocked then
                        if not (RPAL.SpendStones or RPAL.SpendCrests)(cost) then
                            print("|cffff6666ODYN:|r Not enough Tokens.")
                            return
                        end
                        RPAL.UnlockCosmetic("background",name)
                    end
                    RPAL.EquipCosmetic("background",name)
                    shade:Hide()
                    RPAL.OpenCustomization("Backgrounds")
                end)
            end
        end

    elseif selectedTab=="Profile Auras" then
        local current=(RPAL.GetCosmeticSelection() or {}).profileAura or "None"
        local owned,total=OwnedCount(PROFILE_AURAS.order,"aura")

        local sectionTitle=Heading(m,"PROFILE AURAS",14)
        sectionTitle:SetPoint("TOPLEFT",22,-170)
        AddSectionRule(m,sectionTitle,24)

        local meta=Font(m,owned.."/"..total.." COLLECTED",8,GOLD,DEFAULT_FONT)
        meta:SetPoint("TOPRIGHT",-354,-173)

        local help=Font(m,
            "Animated aura effects surround your portrait in the ledger, public preview and ODYN inspect. Effects are deliberately subtle enough for RP readability.",
            9,MUTED)
        help:SetPoint("TOPLEFT",22,-193);help:SetWidth(780);help:SetWordWrap(true)

        local displayOrder=CustomizationDisplayOrder(PROFILE_AURAS.order,"aura")
        local scroll,content=MakeScroll()
        local rows=math.ceil(#displayOrder/3)
        content:SetHeight(math.max(430,rows*218))

        for index,name in ipairs(displayOrder) do
            local row=math.floor((index-1)/3)
            local col=(index-1)%3
            local active=current==name
            local unlocked=RPAL.IsCosmeticUnlocked("aura",name)
            local cost=RPAL.GetAdjustedCustomizationPrice(PROFILE_AURAS.costs[name] or 0)
            local def=PROFILE_AURAS.styles[name]
            local c=(def and def.color) or GOLD
            local unlockSource=RPAL.GetProgressionUnlockSource and RPAL.GetProgressionUnlockSource("aura",name)
            local progressionLocked=RPAL.IsProgressionCosmeticLocked and RPAL.IsProgressionCosmeticLocked(unlockSource,unlocked) or false

            local card=CreateFrame("Frame",nil,content,"BackdropTemplate")
            card:SetSize(260,205)
            card:SetPoint("TOPLEFT",col*274,-row*218)
            Backdrop(card,PANEL2,active and c or BORDER)
            PolishPanel(card,"card")
            AttachGentleFloat(card,3,true)
            RPAL.AddCustomizationGlow(card,active,c)
            RPAL.AnimateCustomizationCard(card,index)
            AttachCustomizationHover(card,"aura",name)

            local stage=RPAL.CustomizationPreviewStage(card,230,116)
            stage:SetPoint("TOP",0,-14)
            stage:SetClipsChildren(true)

            local portrait=stage:CreateTexture(nil,"ARTWORK")
            portrait:SetSize(78,78)
            portrait:SetPoint("CENTER",0,-2)
            SetPortraitTexture(portrait,"player")

            RenderProfileAura(stage,portrait,name,GOLD,108)
            if active then RPAL.AddCustomizationShine(stage,c) end

            local title=Font(card,name,12,PALE)
            title:SetPoint("TOPLEFT",14,-139)
            RPAL.CustomizationStateBadge(card,active and "EQUIPPED" or (unlocked and "OWNED" or "LOCKED"),active,unlocked)

            if not unlocked then
                if progressionLocked then
                    local requirement=Font(card,unlockSource.label,8,c,DEFAULT_FONT);requirement:SetPoint("BOTTOMLEFT",14,15)
                else
                    local ci,ct=RPAL.CustomizationCost(card,cost);ci:SetPoint("BOTTOMLEFT",14,13)
                end
            else
                local state=Font(card,active and "CURRENT AURA" or "IN COLLECTION",8,active and c or MUTED)
                state:SetPoint("BOTTOMLEFT",14,15)
            end

            local action=Button(card,active and "Equipped" or (unlocked and "Equip" or (progressionLocked and ((unlockSource and unlockSource.source=="future") and "Coming Soon" or "Earn") or "Unlock")),82,27,not active and not progressionLocked)
            action:SetPoint("BOTTOMRIGHT",-10,9)
            if active or progressionLocked then
                action:Disable()
            else
                action:SetScript("OnClick",function()
                    if not unlocked then
                        if not (RPAL.SpendStones or RPAL.SpendCrests)(cost) then
                            print("|cffff6666ODYN:|r Not enough Tokens.")
                            return
                        end
                        RPAL.UnlockCosmetic("aura",name)
                    end
                    RPAL.EquipCosmetic("aura",name)
                    shade:Hide()
                    RPAL.OpenCustomization("Profile Auras")
                end)
            end
        end

    elseif selectedTab=="Effects" then
        local packs=RPAL.EFFECT_PACKS or {order={"None"},costs={None=0},descriptions={}}
        local current=(RPAL.GetCosmeticSelection() or {}).effectPack or "None"
        local owned,total=OwnedCount(packs.order,"effect")

        local sectionTitle=Heading(m,"EFFECTS PACK",14)
        sectionTitle:SetPoint("TOPLEFT",22,-170)
        AddSectionRule(m,sectionTitle,24)

        local meta=Font(m,owned.."/"..total.." COLLECTED",8,GOLD,DEFAULT_FONT)
        meta:SetPoint("TOPRIGHT",-354,-173)

        local help=Font(m,
            "Animated weather and magical ambience for the ledger and public inspect. Effects never attach to the Survival HUD.",
            9,MUTED)
        help:SetPoint("TOPLEFT",22,-193);help:SetWidth(780);help:SetWordWrap(true)

        local displayOrder=CustomizationDisplayOrder(packs.order,"effect")
        local scroll,content=MakeScroll()
        local rows=math.ceil(#displayOrder/2)
        content:SetHeight(math.max(430,rows*150))

        for index,name in ipairs(displayOrder) do
            local row=math.floor((index-1)/2)
            local col=(index-1)%2
            local active=current==name
            local unlocked=RPAL.IsCosmeticUnlocked("effect",name)
            local cost=RPAL.GetAdjustedCustomizationPrice((packs.costs and packs.costs[name]) or 0)
            local unlockSource=RPAL.GetProgressionUnlockSource and RPAL.GetProgressionUnlockSource("effect",name)
            local progressionLocked=RPAL.IsProgressionCosmeticLocked and RPAL.IsProgressionCosmeticLocked(unlockSource,unlocked) or false

            local card=CreateFrame("Frame",nil,content,"BackdropTemplate")
            card:SetSize(395,138)
            card:SetPoint("TOPLEFT",col*407,-row*150)
            Backdrop(card,PANEL2,active and GOLD or BORDER)
            PolishPanel(card,"card")
            AttachGentleFloat(card,3,true)
            RPAL.AddCustomizationGlow(card,active,GOLD)
            RPAL.AnimateCustomizationCard(card,index)
            AttachCustomizationHover(card,"effect",name)

            local stage=RPAL.CustomizationPreviewStage(card,142,102)
            stage:SetPoint("TOPLEFT",12,-18)
            stage:SetClipsChildren(true)
            local sample=CreateFrame("Frame",nil,stage,"BackdropTemplate")
            sample:SetPoint("TOPLEFT",5,-5);sample:SetPoint("BOTTOMRIGHT",-5,5)
            sample:SetBackdrop({bgFile="Interface\\Tooltips\\UI-Tooltip-Background",edgeFile="Interface\\Tooltips\\UI-Tooltip-Border",tile=false,tileSize=16,edgeSize=10,insets={left=3,right=3,top=3,bottom=3}})
            sample:SetBackdropColor(0.025,0.020,0.040,0.96);sample:SetBackdropBorderColor(GOLD[1],GOLD[2],GOLD[3],0.32)
            local sampleTitle=Font(sample,"ODYN",8,GOLD,DEFAULT_FONT);sampleTitle:SetPoint("TOPLEFT",9,-8)
            local sampleLine=sample:CreateTexture(nil,"ARTWORK");sampleLine:SetTexture("Interface\\Buttons\\WHITE8X8");sampleLine:SetHeight(1);sampleLine:SetPoint("TOPLEFT",9,-25);sampleLine:SetPoint("TOPRIGHT",-9,-25);sampleLine:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],0.22)
            if RPAL.RenderEffectPack then RPAL.RenderEffectPack(sample,name,GOLD,{preview=true}) end
            if active then RPAL.AddCustomizationShine(stage,GOLD) end

            local title=Font(card,name,13,PALE);title:SetPoint("TOPLEFT",168,-18)
            title:SetWidth(150);title:SetWordWrap(false)
            local desc=Font(card,(packs.descriptions and packs.descriptions[name]) or "Ambient ledger effect.",8.5,MUTED)
            desc:SetPoint("TOPLEFT",168,-42);desc:SetWidth(205);desc:SetWordWrap(true)
            RPAL.CustomizationStateBadge(card,active and "EQUIPPED" or (unlocked and "OWNED" or "LOCKED"),active,unlocked)

            if not unlocked then
                if progressionLocked then
                    local requirement=Font(card,unlockSource.label,8,GOLD,DEFAULT_FONT);requirement:SetPoint("BOTTOMLEFT",168,14)
                else
                    local ci,ct=RPAL.CustomizationCost(card,cost);ci:SetPoint("BOTTOMLEFT",168,12)
                end
            else
                local state=Font(card,active and "ACTIVE EFFECT" or "IN COLLECTION",8,active and GOLD or MUTED)
                state:SetPoint("BOTTOMLEFT",168,14)
            end

            local action=Button(card,active and "Equipped" or (unlocked and "Equip" or (progressionLocked and ((unlockSource and unlockSource.source=="future") and "Coming Soon" or "Earn") or "Unlock")),82,27,not active and not progressionLocked)
            action:SetPoint("BOTTOMRIGHT",-10,9)
            if active or progressionLocked then action:Disable() else
                action:SetScript("OnClick",function()
                    if not unlocked then
                        if not (RPAL.SpendStones or RPAL.SpendCrests)(cost) then
                            print("|cffff6666ODYN:|r Not enough Tokens.")
                            return
                        end
                        RPAL.UnlockCosmetic("effect",name)
                    end
                    RPAL.EquipCosmetic("effect",name)
                    shade:Hide()
                    RPAL.OpenCustomization("Effects")
                end)
            end
        end

    elseif selectedTab=="Odyn's Eye" then
        local current=(RPAL.GetCosmeticSelection() or {}).odynEye or "Default"
        local owned,total=OwnedCount(RPAL.ODYN_EYE_ORDER,"eye")
        local sectionTitle=Heading(m,"ODYN'S EYE",14);sectionTitle:SetPoint("TOPLEFT",22,-170);AddSectionRule(m,sectionTitle,24)
        local meta=Font(m,owned.."/"..total.." COLLECTED",8,GOLD,DEFAULT_FONT);meta:SetPoint("TOPRIGHT",-354,-173)
        local help=Font(m,"Hover any Eye to replay its unique motion. The source below shows where it is earned; both ODYN HUD orbs use the equipped design.",9,MUTED)
        help:SetPoint("TOPLEFT",22,-193);help:SetWidth(780);help:SetWordWrap(true)
        local displayOrder=CustomizationDisplayOrder(RPAL.ODYN_EYE_ORDER,"eye")
        local scroll,content=MakeScroll();local rows=math.ceil(#displayOrder/3);content:SetHeight(math.max(430,rows*178))
        for index,name in ipairs(displayOrder) do
            local row=math.floor((index-1)/3);local col=(index-1)%3
            local style=RPAL.ODYN_EYE_STYLES[name] or RPAL.ODYN_EYE_STYLES.Default
            local active=current==name;local unlocked=RPAL.IsCosmeticUnlocked("eye",name)
            local cost=RPAL.GetAdjustedCustomizationPrice(RPAL.ODYN_EYE_COSTS[name] or 0)
            local unlockSource=RPAL.GetProgressionUnlockSource and RPAL.GetProgressionUnlockSource("eye",name)
            local progressionLocked=RPAL.IsProgressionCosmeticLocked and RPAL.IsProgressionCosmeticLocked(unlockSource,unlocked) or false
            local eyePalette=RPAL.GetCurrentThemePalette and RPAL.GetCurrentThemePalette() or nil
            local c=(eyePalette and eyePalette.gold) or GOLD
            local card=CreateFrame("Frame",nil,content,"BackdropTemplate");card:SetSize(260,166);card:SetPoint("TOPLEFT",col*274,-row*178)
            Backdrop(card,PANEL2,active and c or BORDER);PolishPanel(card,"card");AttachGentleFloat(card,3,true);RPAL.AddCustomizationGlow(card,active,c);RPAL.AnimateCustomizationCard(card,index)
            local stage=RPAL.CustomizationPreviewStage(card,230,92);stage:SetPoint("TOP",0,-12);stage:SetClipsChildren(true)
            local eye=RPAL.RenderODYNEyePreview(stage,name,58);eye:SetPoint("CENTER",0,-1)
            if RPAL.AttachODYNEyeHoverPreview then RPAL.AttachODYNEyeHoverPreview(card,eye,name) end
            if active then RPAL.AddCustomizationShine(stage,c) end
            local displayName=(style.label or name);local title=Font(card,displayName,11.5,PALE);title:SetPoint("BOTTOMLEFT",14,34);title:SetWidth(150);title:SetWordWrap(false)
            RPAL.CustomizationStateBadge(card,active and "EQUIPPED" or (unlocked and "OWNED" or "LOCKED"),active,unlocked)
            if not unlocked and progressionLocked then
                local requirement=Font(card,unlockSource.label,7.7,c,DEFAULT_FONT);requirement:SetPoint("BOTTOMLEFT",14,12);requirement:SetWidth(154);requirement:SetWordWrap(false)
            elseif not unlocked then local ci=RPAL.CustomizationCost(card,cost);ci:SetPoint("BOTTOMLEFT",14,10)
            else local state=Font(card,active and "CURRENT EYE" or "IN COLLECTION",8,active and c or MUTED);state:SetPoint("BOTTOMLEFT",14,12) end
            local actionLabel=active and "Equipped" or (unlocked and "Equip" or (progressionLocked and ((unlockSource and unlockSource.source=="future") and "Coming Soon" or "Earn") or "Unlock"))
            local action=Button(card,actionLabel,82,27,not active and not progressionLocked);action:SetPoint("BOTTOMRIGHT",-10,8)
            if active or progressionLocked then action:Disable() else action:SetScript("OnClick",function()
                if not unlocked then
                    if not (RPAL.SpendStones or RPAL.SpendCrests)(cost) then print("|cffff6666ODYN:|r Not enough Tokens.");return end
                    RPAL.UnlockCosmetic("eye",name)
                end
                RPAL.EquipCosmetic("eye",name);shade:Hide();RPAL.OpenCustomization("Odyn's Eye")
            end) end
        end

    elseif selectedTab=="Platforms" then
        local current=(RPAL.GetCosmeticSelection() or {}).platformStyle or "Default"
        local owned,total=OwnedCount(PROFILE_CUSTOMIZATION.platforms.order,"platform")

        local sectionTitle=Heading(m,"MODEL PLATFORMS",14)
        sectionTitle:SetPoint("TOPLEFT",22,-170)
        AddSectionRule(m,sectionTitle,24)
        local meta=Font(m,owned.."/"..total.." COLLECTED",8,GOLD,DEFAULT_FONT)
        meta:SetPoint("TOPRIGHT",-354,-173)
        local help=Font(m,"Theme-aware plinths sit directly beneath the 3D character in Profile Preview. Hover a platform to try it on the live model before unlocking it.",9,MUTED)
        help:SetPoint("TOPLEFT",22,-193);help:SetWidth(780);help:SetWordWrap(true)

        local displayOrder=CustomizationDisplayOrder(PROFILE_CUSTOMIZATION.platforms.order,"platform")
        local scroll,content=MakeScroll()
        local rows=math.ceil(#displayOrder/2)
        content:SetHeight(math.max(430,rows*156))

        for index,name in ipairs(displayOrder) do
            local row=math.floor((index-1)/2);local col=(index-1)%2
            local definition=PROFILE_CUSTOMIZATION.platforms.styles[name] or PROFILE_CUSTOMIZATION.platforms.styles.Default
            local active=current==name
            local unlocked=RPAL.IsCosmeticUnlocked("platform",name)
            local cost=RPAL.GetAdjustedCustomizationPrice(PROFILE_CUSTOMIZATION.platforms.costs[name] or 0)
            local unlockSource=RPAL.GetProgressionUnlockSource and RPAL.GetProgressionUnlockSource("platform",name)
            local progressionLocked=RPAL.IsProgressionCosmeticLocked and RPAL.IsProgressionCosmeticLocked(unlockSource,unlocked) or false
            local c=definition.color or GOLD

            local card=CreateFrame("Frame",nil,content,"BackdropTemplate")
            card:SetSize(395,144);card:SetPoint("TOPLEFT",col*407,-row*156)
            Backdrop(card,PANEL2,active and c or BORDER);PolishPanel(card,"card");AttachGentleFloat(card,3,true)
            RPAL.AddCustomizationGlow(card,active,c);RPAL.AnimateCustomizationCard(card,index)
            AttachCustomizationHover(card,"platform",name)

            local stage=RPAL.CustomizationPreviewStage(card,367,72)
            stage:SetPoint("TOP",0,-12);stage:SetClipsChildren(true)
            local sample=RPAL.RenderProfilePlatform(stage,name,GOLD,300,58,false);sample:SetPoint("CENTER",0,-2)
            local displayName=(definition.label or name)
            local title=Font(card,displayName,11.5,PALE);title:SetPoint("BOTTOMLEFT",14,34)
            RPAL.CustomizationStateBadge(card,active and "EQUIPPED" or (unlocked and "OWNED" or "LOCKED"),active,unlocked)
            if not unlocked then
                if progressionLocked then
                    local requirement=Font(card,unlockSource.label,8,c,DEFAULT_FONT);requirement:SetPoint("BOTTOMLEFT",14,12)
                else
                    local ci=RPAL.CustomizationCost(card,cost);ci:SetPoint("BOTTOMLEFT",14,10)
                end
            else
                local state=Font(card,active and "CURRENT PLATFORM" or "IN COLLECTION",8,active and c or MUTED);state:SetPoint("BOTTOMLEFT",14,12)
            end
            local action=Button(card,active and "Equipped" or (unlocked and "Equip" or (progressionLocked and ((unlockSource and unlockSource.source=="future") and "Coming Soon" or "Earn") or "Unlock")),82,27,not active and not progressionLocked)
            action:SetPoint("BOTTOMRIGHT",-10,8)
            if active or progressionLocked then action:Disable() else action:SetScript("OnClick",function()
                if not unlocked then
                    if not (RPAL.SpendStones or RPAL.SpendCrests)(cost) then print("|cffff6666ODYN:|r Not enough Tokens.");return end
                    RPAL.UnlockCosmetic("platform",name)
                end
                RPAL.EquipCosmetic("platform",name);shade:Hide();RPAL.OpenCustomization("Platforms")
            end) end
        end

    elseif selectedTab=="Character Animations" then
        local catalog=RPAL.BuildProfileAnimationCatalog()
        local selectableCatalog={}
        local selectedAnimations=(RPAL.GetCosmeticSelection() or {}).characterAnimations or {}
        local animationOrder={}
        local selectedCount=0
        for _,definition in ipairs(catalog) do
            if definition.key~="Stand" then
                selectableCatalog[#selectableCatalog+1]=definition
                animationOrder[#animationOrder+1]=definition.key
                if selectedAnimations[definition.key] then selectedCount=selectedCount+1 end
            end
        end
        local owned,total=OwnedCount(animationOrder,"animation")
        do
            local ownedDefs,lockedDefs={},{}
            for _,definition in ipairs(selectableCatalog) do
                if RPAL.IsCosmeticUnlocked("animation",definition.key) then ownedDefs[#ownedDefs+1]=definition else lockedDefs[#lockedDefs+1]=definition end
            end
            for _,definition in ipairs(lockedDefs) do ownedDefs[#ownedDefs+1]=definition end
            selectableCatalog=ownedDefs
        end

        local sectionTitle=Heading(m,"CHARACTER ANIMATIONS",14)
        sectionTitle:SetPoint("TOPLEFT",22,-170)
        AddSectionRule(m,sectionTitle,24)
        local meta=Font(m,selectedCount.." SELECTED  •  "..owned.."/"..total.." COLLECTED",8,GOLD,DEFAULT_FONT)
        meta:SetPoint("TOPRIGHT",-354,-173)
        local help=Font(m,"Select any number of owned emotes. Profile preview and inspect play every checked emote once at its natural game speed, in a shuffled order; no slow motion or forced sequence loop is used. With nothing selected, the character remains idle.",9,MUTED)
        help:SetPoint("TOPLEFT",22,-193);help:SetWidth(790);help:SetWordWrap(true)

        local scroll,content=MakeScroll()
        local rows=math.ceil(#selectableCatalog/2)
        content:SetHeight(math.max(430,rows*94))
        for index,definition in ipairs(selectableCatalog) do
            local name=definition.key
            local row=math.floor((index-1)/2);local col=(index-1)%2
            local active=selectedAnimations[name] and true or false
            local unlocked=RPAL.IsCosmeticUnlocked("animation",name)
            local cost=RPAL.GetAdjustedCustomizationPrice(definition.cost or 0)
            local unlockSource=RPAL.GetProgressionUnlockSource and RPAL.GetProgressionUnlockSource("animation",name)
            local progressionLocked=RPAL.IsProgressionCosmeticLocked and RPAL.IsProgressionCosmeticLocked(unlockSource,unlocked) or false
            local card=CreateFrame("Frame",nil,content,"BackdropTemplate")
            card:SetSize(395,82);card:SetPoint("TOPLEFT",col*407,-row*94)
            Backdrop(card,PANEL2,active and GOLD or BORDER);PolishPanel(card,"card");AttachGentleFloat(card,3,true)
            RPAL.AddCustomizationGlow(card,active,GOLD);RPAL.AnimateCustomizationCard(card,index)
            AttachCustomizationHover(card,"animation",name)

            local command=Font(card,definition.command or ("/"..name:lower()),15,GOLD,DEFAULT_FONT)
            command:SetPoint("TOPLEFT",14,-13);command:SetWidth(130);command:SetWordWrap(false)
            local desc=Font(card,"Natural-speed model emote",8.5,MUTED);desc:SetPoint("TOPLEFT",14,-40)
            RPAL.CustomizationStateBadge(card,active and "SELECTED" or (unlocked and "OWNED" or "LOCKED"),active,unlocked)
            if not unlocked then
                if progressionLocked then
                    local requirement=Font(card,unlockSource.label,8,GOLD,DEFAULT_FONT);requirement:SetPoint("BOTTOMLEFT",154,12)
                    local action=Button(card,(unlockSource and unlockSource.source=="future") and "Coming Soon" or "Earn",82,27,false)
                    action:SetPoint("BOTTOMRIGHT",-10,8);action:Disable()
                else
                    local ci=RPAL.CustomizationCost(card,cost);ci:SetPoint("BOTTOMLEFT",154,11)
                    local action=Button(card,"Unlock",82,27,true)
                    action:SetPoint("BOTTOMRIGHT",-10,8)
                    action:SetScript("OnClick",function()
                        if not (RPAL.SpendStones or RPAL.SpendCrests)(cost) then print("|cffff6666ODYN:|r Not enough Tokens.");return end
                        RPAL.UnlockCosmetic("animation",name)
                        RPAL.SetCharacterAnimationSelected(name,true)
                        shade:Hide();RPAL.OpenCustomization("Character Animations")
                    end)
                end
            else
                local selector=Check(card,"Use in random set")
                selector:SetSize(176,27);selector:SetPoint("BOTTOMRIGHT",-10,8)
                selector:SetChecked(active)
                selector:SetScript("OnClick",function(self)
                    local enabled=not self:GetChecked()
                    self:SetChecked(enabled)
                    RPAL.SetCharacterAnimationSelected(name,enabled)
                    RPAL.OpenCustomization("Character Animations")
                end)
            end
        end

    elseif selectedTab=="Toast Frames" or selectedTab=="Tooltip Treatments" or selectedTab=="Focus Marks" then
        local interaction
        if selectedTab=="Toast Frames" then
            interaction={kind="toast",selectionKey="toastFrame",definition=RPAL.TOAST_FRAMES,title="TOAST FRAMES",help="Frames every ODYN reward, progression and status toast without changing its message or timing."}
        elseif selectedTab=="Tooltip Treatments" then
            interaction={kind="tooltip",selectionKey="tooltipTreatment",definition=RPAL.TOOLTIP_TREATMENTS,title="TOOLTIP TREATMENTS",help="Restyles ODYN cursor cards, including Inspect and Health explanations, while preserving their measured layout."}
        else
            interaction={kind="focus",selectionKey="focusMark",definition=RPAL.FOCUS_MARKS,title="FOCUS MARKS",help="Marks the control under your cursor across the ledger, Inspect, Health, Seasons and mini-games."}
        end
        local definition=interaction.definition or {order={"Classic"},costs={Classic=0},descriptions={}}
        local current=(RPAL.GetCosmeticSelection() or {})[interaction.selectionKey] or "Classic"
        local owned,total=OwnedCount(definition.order,interaction.kind)
        local sectionTitle=Heading(m,interaction.title,14);sectionTitle:SetPoint("TOPLEFT",22,-170);AddSectionRule(m,sectionTitle,24)
        local meta=Font(m,owned.."/"..total.." COLLECTED",8,GOLD,DEFAULT_FONT);meta:SetPoint("TOPRIGHT",-354,-173)
        local help=Font(m,interaction.help,9,MUTED);help:SetPoint("TOPLEFT",22,-193);help:SetWidth(790);help:SetWordWrap(true)
        local displayOrder=CustomizationDisplayOrder(definition.order,interaction.kind)
        local scroll,content=MakeScroll();content:SetHeight(math.max(430,math.ceil(#displayOrder/2)*158))
        for index,name in ipairs(displayOrder) do
            local row=math.floor((index-1)/2);local col=(index-1)%2
            local active=current==name;local unlocked=RPAL.IsCosmeticUnlocked(interaction.kind,name)
            local cost=RPAL.GetAdjustedCustomizationPrice((definition.costs and definition.costs[name]) or 0)
            local unlockSource=RPAL.GetProgressionUnlockSource and RPAL.GetProgressionUnlockSource(interaction.kind,name)
            local progressionLocked=RPAL.IsProgressionCosmeticLocked and RPAL.IsProgressionCosmeticLocked(unlockSource,unlocked) or false
            local card=CreateFrame("Frame",nil,content,"BackdropTemplate");card:SetSize(395,146);card:SetPoint("TOPLEFT",col*407,-row*158);Backdrop(card,PANEL2,active and GOLD or BORDER);PolishPanel(card,"card");AttachGentleFloat(card,3,true)
            RPAL.AddCustomizationGlow(card,active,GOLD);RPAL.AnimateCustomizationCard(card,index);AttachCustomizationHover(card,interaction.kind,name)
            local stage=RPAL.CustomizationPreviewStage(card,142,104);stage:SetPoint("TOPLEFT",12,-18);stage:SetClipsChildren(false)
            if RPAL.RenderInteractionCustomizationPreview then RPAL.RenderInteractionCustomizationPreview(stage,interaction.kind,name,GOLD) end
            local title=Font(card,name,12.5,PALE);title:SetPoint("TOPLEFT",168,-20);title:SetWidth(155);title:SetWordWrap(false)
            local desc=Font(card,(definition.descriptions and definition.descriptions[name]) or "ODYN interaction cosmetic.",8.3,MUTED);desc:SetPoint("TOPLEFT",168,-44);desc:SetWidth(205);desc:SetWordWrap(true)
            RPAL.CustomizationStateBadge(card,active and "EQUIPPED" or (unlocked and "OWNED" or "LOCKED"),active,unlocked)
            if not unlocked then
                if progressionLocked then local requirement=Font(card,unlockSource.label,8,GOLD,DEFAULT_FONT);requirement:SetPoint("BOTTOMLEFT",168,14)
                else local ci=RPAL.CustomizationCost(card,cost);ci:SetPoint("BOTTOMLEFT",168,12) end
            else
                local state=Font(card,active and "CURRENT TREATMENT" or "IN COLLECTION",8,active and GOLD or MUTED);state:SetPoint("BOTTOMLEFT",168,14)
            end
            local action=Button(card,active and "Equipped" or (unlocked and "Equip" or (progressionLocked and ((unlockSource and unlockSource.source=="future") and "Coming Soon" or "Earn") or "Unlock")),82,27,not active and not progressionLocked)
            action:SetPoint("BOTTOMRIGHT",-10,9)
            if active or progressionLocked then action:Disable() else action:SetScript("OnClick",function()
                if not unlocked then
                    if not (RPAL.SpendStones or RPAL.SpendCrests)(cost) then print("|cffff6666ODYN:|r Not enough Tokens.");return end
                    RPAL.UnlockCosmetic(interaction.kind,name)
                end
                RPAL.EquipCosmetic(interaction.kind,name);shade:Hide();RPAL.OpenCustomization(selectedTab)
            end) end
        end

    elseif selectedTab=="UI Ornaments" then
        local current=(RPAL.GetCosmeticSelection() or {}).uiOrnament or "Classic"
        local owned,total=OwnedCount(UI_ORNAMENTS.order,"ornament")

        local sectionTitle=Heading(m,"LEDGER ORNAMENTS",14)
        sectionTitle:SetPoint("TOPLEFT",22,-170)
        AddSectionRule(m,sectionTitle,24)

        local meta=Font(m,owned.."/"..total.." COLLECTED",8,GOLD,DEFAULT_FONT)
        meta:SetPoint("TOPRIGHT",-354,-173)

        local help=Font(m,
            "Ornaments wrap the ledger in selected premade SVG corner art and restrained binding rails while leaving every reading surface clear.",
            9,MUTED)
        help:SetPoint("TOPLEFT",22,-193);help:SetWidth(780);help:SetWordWrap(true)

        local displayOrder=CustomizationDisplayOrder(UI_ORNAMENTS.order,"ornament")
        local scroll,content=MakeScroll()
        local rows=math.ceil(#displayOrder/3)
        content:SetHeight(math.max(430,rows*218))

        for index,name in ipairs(displayOrder) do
            local row=math.floor((index-1)/3)
            local col=(index-1)%3
            local active=current==name
            local unlocked=RPAL.IsCosmeticUnlocked("ornament",name)
            local cost=RPAL.GetAdjustedCustomizationPrice(UI_ORNAMENTS.costs[name] or 0)
            local def=UI_ORNAMENTS.styles[name]
            local c=GOLD
            local unlockSource=RPAL.GetProgressionUnlockSource and RPAL.GetProgressionUnlockSource("ornament",name)
            local progressionLocked=RPAL.IsProgressionCosmeticLocked and RPAL.IsProgressionCosmeticLocked(unlockSource,unlocked) or false

            local card=CreateFrame("Frame",nil,content,"BackdropTemplate")
            card:SetSize(260,205)
            card:SetPoint("TOPLEFT",col*274,-row*218)
            Backdrop(card,PANEL2,active and c or BORDER)
            PolishPanel(card,"card")
            AttachGentleFloat(card,3,true)
            RPAL.AddCustomizationGlow(card,active,c)
            RPAL.AnimateCustomizationCard(card,index)
            AttachCustomizationHover(card,"ornament",name)

            local stage=RPAL.CustomizationPreviewStage(card,230,116)
            stage:SetPoint("TOP",0,-14)
            stage:SetClipsChildren(true)

            local sample=CreateFrame("Frame",nil,stage,"BackdropTemplate")
            sample:SetSize(204,86)
            sample:SetPoint("CENTER")
            Backdrop(sample,{MAIN_BG[1],MAIN_BG[2],MAIN_BG[3],0.96},BORDER)
            AddPageDecoration(sample)

            local sampleTitle=Font(sample,"RP LEDGER",8,GOLD,DEFAULT_FONT)
            sampleTitle:SetPoint("TOPLEFT",12,-10)
            local sampleRule=sample:CreateTexture(nil,"ARTWORK")
            sampleRule:SetTexture("Interface\\Buttons\\WHITE8X8")
            sampleRule:SetColorTexture(GOLD[1],GOLD[2],GOLD[3],0.22)
            sampleRule:SetHeight(1);sampleRule:SetPoint("TOPLEFT",12,-27);sampleRule:SetPoint("TOPRIGHT",-12,-27)
            local sampleText=Font(sample,"Character profile",9,PALE,DEFAULT_FONT)
            sampleText:SetPoint("TOPLEFT",12,-37)

            RenderUIOrnament(sample,name,GOLD,4)
            if active then RPAL.AddCustomizationShine(stage,c) end

            local displayName=(def and def.displayName) or name
            local title=Font(card,displayName,12,PALE)
            title:SetPoint("TOPLEFT",14,-139)
            title:SetWidth(224);title:SetWordWrap(false)
            RPAL.CustomizationStateBadge(card,active and "EQUIPPED" or (unlocked and "OWNED" or "LOCKED"),active,unlocked)

            if not unlocked then
                if progressionLocked then
                    local requirement=Font(card,unlockSource.label,8,c,DEFAULT_FONT);requirement:SetPoint("BOTTOMLEFT",14,15)
                else
                    local ci,ct=RPAL.CustomizationCost(card,cost);ci:SetPoint("BOTTOMLEFT",14,13)
                end
            else
                local state=Font(card,active and "CURRENT ORNAMENT" or "IN COLLECTION",8,active and c or MUTED)
                state:SetPoint("BOTTOMLEFT",14,15)
            end

            local action=Button(card,active and "Equipped" or (unlocked and "Equip" or (progressionLocked and ((unlockSource and unlockSource.source=="future") and "Coming Soon" or "Earn") or "Unlock")),82,27,not active and not progressionLocked)
            action:SetPoint("BOTTOMRIGHT",-10,9)
            if active or progressionLocked then
                action:Disable()
            else
                action:SetScript("OnClick",function()
                    if not unlocked then
                        if not (RPAL.SpendStones or RPAL.SpendCrests)(cost) then
                            print("|cffff6666ODYN:|r Not enough Tokens.")
                            return
                        end
                        RPAL.UnlockCosmetic("ornament",name)
                    end
                    RPAL.EquipCosmetic("ornament",name)
                    shade:Hide()
                    RPAL.OpenCustomization("UI Ornaments")
                end)
            end
        end

    else
        local kind,selectionKey,def
        if selectedTab=="Portrait Frames" then
            kind="portrait";selectionKey="portraitFrame";def=COSMETICS.portrait
        else
            kind="header";selectionKey="headerDecoration";def=COSMETICS.header
        end

        local current=(RPAL.GetCosmeticSelection() or {})[selectionKey]
        local owned,total=OwnedCount(def.order,kind)

        local sectionTitle=Heading(m,kind=="portrait" and "PORTRAIT FRAME COLLECTION" or "HEADER DECORATION COLLECTION",14)
        sectionTitle:SetPoint("TOPLEFT",22,-170)
        AddSectionRule(m,sectionTitle,24)

        local meta=Font(m,owned.."/"..total.." COLLECTED",8,GOLD,DEFAULT_FONT)
        meta:SetPoint("TOPRIGHT",-354,-173)

        local help=Font(m,
            kind=="portrait"
                and "Purchased Small Container line art forms every open-center portrait frame. Each shape inherits the active character theme without details crossing the face."
                or "Purchased Medium and Large SVG crowns form paired lower-left and upper-right signatures. Every design keeps its natural geometry, leaves identity text uncovered and inherits the viewed character's theme.",
            9,MUTED)
        help:SetPoint("TOPLEFT",22,-193);help:SetWidth(780);help:SetWordWrap(true)

        local displayOrder=CustomizationDisplayOrder(def.order,kind)
        local scroll,content=MakeScroll()
        local rows=math.ceil(#displayOrder/3)
        content:SetHeight(math.max(430,rows*218))

        for index,name in ipairs(displayOrder) do
            local row=math.floor((index-1)/3)
            local col=(index-1)%3
            local card=CreateFrame("Frame",nil,content,"BackdropTemplate")
            card:SetSize(260,205)
            card:SetPoint("TOPLEFT",col*274,-row*218)

            local active=current==name
            local unlocked=RPAL.IsCosmeticUnlocked(kind,name)
            local cost=RPAL.GetAdjustedCustomizationPrice(def.costs[name] or 0)
            local c=GOLD
            local unlockSource=RPAL.GetProgressionUnlockSource and RPAL.GetProgressionUnlockSource(kind,name)
            local progressionLocked=RPAL.IsProgressionCosmeticLocked and RPAL.IsProgressionCosmeticLocked(unlockSource,unlocked) or false

            Backdrop(card,PANEL2,active and c or BORDER)
            PolishPanel(card,"card")
            AttachGentleFloat(card,3,true)
            RPAL.AddCustomizationGlow(card,active,c)
            RPAL.AnimateCustomizationCard(card,index)
            AttachCustomizationHover(card,kind,name)

            local stage=RPAL.CustomizationPreviewStage(card,230,116)
            stage:SetPoint("TOP",0,-14)
            stage:SetClipsChildren(true)

            if kind=="portrait" then
                local portrait=stage:CreateTexture(nil,"ARTWORK")
                portrait:SetSize(82,82)
                portrait:SetPoint("CENTER",0,-1)
                SetPortraitTexture(portrait,"player")

                local rendered=RenderPortraitCosmetic(stage,portrait,name,GOLD,104)
                if name=="Default" then
                    local unframed=Font(stage,"UNFRAMED",7,MUTED,DEFAULT_FONT)
                    unframed:SetPoint("BOTTOM",portrait,"TOP",0,2)
                end
            else
                local sampleName=Font(stage,"CHARACTER HEADER",8,MUTED)
                sampleName:SetPoint("TOPLEFT",14,-18)
                local sampleRule=stage:CreateTexture(nil,"ARTWORK")
                sampleRule:SetTexture("Interface\\Buttons\\WHITE8X8")
                sampleRule:SetColorTexture(GOLD[1],GOLD[2],GOLD[3],0.25)
                sampleRule:SetHeight(1);sampleRule:SetPoint("TOPLEFT",14,-36);sampleRule:SetPoint("TOPRIGHT",-14,-36)
                local sampleLine=Font(stage,RPAL.DisplayName(),10,PALE)
                sampleLine:SetPoint("TOPLEFT",14,-47)
                sampleLine:SetWidth(190)
                sampleLine:SetWordWrap(false)

                local rendered=RenderHeaderCosmetic(stage,name,GOLD,214,54)
                if rendered then rendered:SetPoint("CENTER",0,6) end
                if name=="None" then
                    local empty=Font(stage,"No decoration",8,MUTED)
                    empty:SetPoint("BOTTOMRIGHT",-14,14)
                end
            end

            if active then RPAL.AddCustomizationShine(stage,c) end

            local title=Font(card,name,12,PALE)
            title:SetPoint("TOPLEFT",14,-139)
            RPAL.CustomizationStateBadge(card,active and "EQUIPPED" or (unlocked and "OWNED" or "LOCKED"),active,unlocked)

            if not unlocked then
                if progressionLocked then
                    local requirement=Font(card,unlockSource.label,8,c,DEFAULT_FONT);requirement:SetPoint("BOTTOMLEFT",14,15)
                else
                    local ci,ct=RPAL.CustomizationCost(card,cost);ci:SetPoint("BOTTOMLEFT",14,13)
                end
            else
                local state=Font(card,active and "CURRENT LOOK" or "IN COLLECTION",8,active and c or MUTED)
                state:SetPoint("BOTTOMLEFT",14,15)
            end

            local action=Button(card,active and "Equipped" or (unlocked and "Equip" or (progressionLocked and ((unlockSource and unlockSource.source=="future") and "Coming Soon" or "Earn") or "Unlock")),82,27,not active and not progressionLocked)
            action:SetPoint("BOTTOMRIGHT",-10,9)

            if active or progressionLocked then
                action:Disable()
            else
                action:SetScript("OnClick",function()
                    if not unlocked then
                        if not (RPAL.SpendStones or RPAL.SpendCrests)(cost) then
                            print("|cffff6666ODYN:|r Not enough Tokens.")
                            return
                        end
                        RPAL.UnlockCosmetic(kind,name)
                    end
                    RPAL.EquipCosmetic(kind,name)
                    shade:Hide()
                    RPAL.OpenCustomization(selectedTab)
                end)
            end
        end
    end
    return shade,m
end

function RPAL.OpenTools()
    local shade,m=Modal("Tools",510,295)
    local toolsHint=Font(m,"Sharing, Online Exchange and maintenance utilities.",9,MUTED)
    toolsHint:SetPoint("TOPLEFT",24,-54)
    local items={
        {"Online Exchange",function()
            if RPAL.OpenExchangeHub then RPAL.OpenExchangeHub()
            else print("|cffff6666ODYN:|r Calendar Exchange is not available yet.") end
        end},
        {"Share Screenshot",function()RPAL.OpenShareView()end},
        {"Reset Data",function()if RPAL.OpenResetData then RPAL.OpenResetData() end end,true},
    }
    local y=-78
    for i,item in ipairs(items) do
        local b=Button(m,item[1],460,43,true)
        b:SetPoint("TOPLEFT",24,y)
        b:SetScript("OnClick",function()shade:Hide();item[2]()end)
        if item[3] and b.label then b.label:SetTextColor(.95,.35,.35) end
        y=y-50
    end
    return shade,m
end

function RPAL.OpenShareView()
    if activeShareView and activeShareView:IsShown() then
        activeShareView:Raise()
        return activeShareView,activeShareView.card
    end

    local snapshot=RPAL.GetPublicSocialSnapshot and RPAL.GetPublicSocialSnapshot() or {}
    local stats=RPAL.GetStatistics and RPAL.GetStatistics() or {}
    local cosmetics=RPAL.GetCosmeticSelection and RPAL.GetCosmeticSelection() or {}
    local completed,total=RPAL.ProgressTotals()
    local level=RPAL.GetCurrentRenownLevel and select(1,RPAL.GetCurrentRenownLevel()) or 1
    local lr,lg,lb=GetRenownLevelColor(level)

    RPAL._shareViewSerial=(tonumber(RPAL._shareViewSerial) or 0)+1
    local shade=CreateFrame("Frame","RPAL_ShareView_"..tostring(RPAL._shareViewSerial),UIParent,"BackdropTemplate")
    activeShareView=shade
    shade:SetAllPoints();shade:SetFrameStrata("FULLSCREEN_DIALOG");shade:SetFrameLevel(9200);shade:SetToplevel(true);shade:EnableMouse(true)
    shade:SetBackdrop({bgFile="Interface\\Buttons\\WHITE8X8"});shade:SetBackdropColor(0.004,0.006,0.012,0.94)
    RPAL.RegisterEscapeFrame(shade);shade._rpalEscapeFailsafe=true

    local card=CreateFrame("Frame",nil,shade,"BackdropTemplate")
    shade.card=card
    card:SetSize(930,560);card:SetPoint("CENTER");card:SetScale(GetUIScaleSetting());card:SetFrameLevel(9210)
    Backdrop(card,MAIN_BG,GOLD);AddFrameShadow(card,15,0.82);AddBookDecoration(card);AddInnerBorder(card,12,0.18)
    card._rpalShareMode="compact-profile"
    card._rpalShareStatCards=4
    card._rpalShareAchievementSlots=3
    card._rpalReceivedEmotesOnly=true

    RenderAtmosphere(card,cosmetics.background or "None",GOLD,0.48,false)
    RenderUIOrnament(card,cosmetics.uiOrnament or "Classic",GOLD,9)
    if RPAL.RenderEffectPack then
        card._rpalShareEffect=RPAL.RenderEffectPack(card,cosmetics.effectPack or "None",GOLD,{preview=true,frameLevelOffset=2})
    end

    local left=CreateFrame("Frame",nil,card,"BackdropTemplate")
    left:SetPoint("TOPLEFT",18,-18);left:SetPoint("BOTTOMLEFT",18,48);left:SetWidth(298);left:SetFrameLevel(card:GetFrameLevel()+5)
    Backdrop(left,PAGE_BG_ALT,BORDER);PolishPanel(left,"card")
    local stageWash=left:CreateTexture(nil,"BACKGROUND",nil,-1);stageWash:SetTexture(TEX.."Cosmetics\\aura_soft_ring.tga");stageWash:SetBlendMode("ADD");stageWash:SetSize(330,330);stageWash:SetPoint("CENTER",0,-40);stageWash:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],0.14)
    local stageKicker=Font(left,"CHARACTER PROFILE",8,GOLD,DEFAULT_FONT);stageKicker:SetPoint("TOP",0,-13)

    local platform=RPAL.RenderProfilePlatform(left,cosmetics.platformStyle or "Default",GOLD,270,68,false)
    platform:SetPoint("BOTTOM",0,18);platform:SetFrameLevel(left:GetFrameLevel()+1)

    local model=CreateFrame("PlayerModel",nil,left)
    card._rpalShareModel=model
    model:SetSize(294,450);model:SetPoint("BOTTOM",0,8);model:SetFrameLevel(left:GetFrameLevel()+3);model:SetAlpha(0.98);model:EnableMouse(false)

    local content=CreateFrame("Frame",nil,card)
    content:SetPoint("TOPLEFT",330,-18);content:SetPoint("BOTTOMRIGHT",-18,48);content:SetFrameLevel(card:GetFrameLevel()+6)

    local levelBadgeAnchor=CreateFrame("Frame",nil,content)
    levelBadgeAnchor:SetSize(74,74);levelBadgeAnchor:SetPoint("TOPRIGHT",-4,-3)
    RenderLevelBadge(content,levelBadgeAnchor,cosmetics.levelBadge or "Initiate Seal",70,level,0.82)

    local kicker=Font(content,"ODYN  •  SHAREABLE CHARACTER CARD",8,GOLD,DEFAULT_FONT);kicker:SetPoint("TOPLEFT",4,-5)
    local name=Heading(content,snapshot.displayName or RPAL.DisplayName(),23);name:SetPoint("TOPLEFT",4,-25);name:SetWidth(475);name:SetWordWrap(false)
    local titleParts={}
    if snapshot.title and snapshot.title~="" then titleParts[#titleParts+1]=snapshot.title end
    local identity=table.concat({snapshot.race or "",snapshot.className or ""}," "):gsub("^%s+",""):gsub("%s+$","")
    if identity~="" then titleParts[#titleParts+1]=identity end
    local subtitle=Font(content,table.concat(titleParts,"  •  "),10.5,PALE,DEFAULT_FONT);subtitle:SetPoint("TOPLEFT",4,-58);subtitle:SetWidth(500);subtitle:SetWordWrap(false)
    local guild=Font(content,(snapshot.guild and snapshot.guild~="") and snapshot.guild or "Independent",9,MUTED,DEFAULT_FONT);guild:SetPoint("TOPLEFT",4,-79);guild:SetWidth(500);guild:SetWordWrap(false)
    local levelText=Font(content,"LEVEL "..tostring(level),10,{lr,lg,lb},DEFAULT_FONT);levelText._rpalColorRole=nil;levelText:SetPoint("TOPRIGHT",-84,-52)

    local rule=content:CreateTexture(nil,"ARTWORK");rule:SetTexture("Interface\\Buttons\\WHITE8X8");rule:SetHeight(1);rule:SetPoint("TOPLEFT",4,-103);rule:SetPoint("TOPRIGHT",-4,-103);rule:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],0.34)

    local function StatCard(label,value,x)
        local frame=CreateFrame("Frame",nil,content,"BackdropTemplate")
        frame:SetSize(136,66);frame:SetPoint("TOPLEFT",x,-118);Backdrop(frame,PANEL2,BORDER);PolishPanel(frame,"card")
        local lab=Font(frame,label,7.5,MUTED,DEFAULT_FONT);lab:SetPoint("TOPLEFT",10,-10)
        local val=Heading(frame,tostring(value or 0),22);val:SetPoint("BOTTOMLEFT",10,10)
        local accentLine=frame:CreateTexture(nil,"ARTWORK");accentLine:SetTexture("Interface\\Buttons\\WHITE8X8");accentLine:SetPoint("BOTTOMLEFT",8,5);accentLine:SetPoint("BOTTOMRIGHT",-8,5);accentLine:SetHeight(1);accentLine:SetVertexColor(GOLD[1],GOLD[2],GOLD[3],0.35)
        return frame
    end
    StatCard("RENOWN",snapshot.score or stats.score or 0,4)
    StatCard("LEVEL",level,146)
    StatCard("ACHIEVEMENTS",tostring(completed).." / "..tostring(total),288)
    StatCard("CHARACTERS MET",snapshot.charactersMet or stats.charactersMet or 0,430)

    local glanceLabel=Font(content,"AT FIRST GLANCE",8,GOLD,DEFAULT_FONT);glanceLabel:SetPoint("TOPLEFT",4,-202)
    local glances=RPAL.CreatePublicGlanceStrip(content,38,7);glances:SetPoint("TOPLEFT",4,-220);RPAL.UpdatePublicGlanceStrip(glances,snapshot.glances or {},GOLD)

    local socialTitle=Font(content,"TOP EMOTES RECEIVED",8,GOLD,DEFAULT_FONT);socialTitle:SetPoint("TOPRIGHT",-4,-202)
    local socialBits={}
    for index,key in ipairs(RPAL.GetSortedStatisticKeys and RPAL.GetSortedStatisticKeys(stats,3) or {}) do
        local def=RPAL.STAT_DEFINITIONS and RPAL.STAT_DEFINITIONS[key] or {}
        local count=tonumber((stats.counts or {})[key]) or 0
        if count>0 then socialBits[#socialBits+1]=tostring(def.command or ("/"..key)).."  "..tostring(count) end
    end
    local social=Font(content,#socialBits>0 and table.concat(socialBits,"   •   ") or "No received emotes yet",9,PALE,DEFAULT_FONT);social:SetPoint("TOPRIGHT",-4,-226);social:SetWidth(286);social:SetJustifyH("RIGHT");social:SetWordWrap(false)

    local achievementTitle=Font(content,"SIGNATURE ACHIEVEMENTS",8,GOLD,DEFAULT_FONT);achievementTitle:SetPoint("TOPLEFT",4,-281)
    local achievements=snapshot.achievements or {}
    for index=1,3 do
        local item=achievements[index]
        local row=CreateFrame("Frame",nil,content,"BackdropTemplate")
        row:SetSize(558,50);row:SetPoint("TOPLEFT",4,-298-((index-1)*56));Backdrop(row,PANEL2,item and GOLD or BORDER);PolishPanel(row,"card")
        local icon=row:CreateTexture(nil,"ARTWORK");icon:SetSize(34,34);icon:SetPoint("LEFT",8,0);icon:SetTexture((item and item.icon and item.icon~="") and item.icon or RPAL.ICONS.question);if not item then icon:SetDesaturated(true);icon:SetAlpha(0.18) end
        local achievementName=Font(row,item and RPAL.ReplaceName(item.name or "Achievement") or "Awaiting a signature achievement",10,item and PALE or MUTED,DEFAULT_FONT);achievementName:SetPoint("TOPLEFT",50,-9);achievementName:SetWidth(355);achievementName:SetWordWrap(false)
        local category=Font(row,item and (item.category or "ACHIEVEMENT") or "COMPLETE ACHIEVEMENTS TO FILL THIS SLOT",7.5,MUTED,DEFAULT_FONT);category:SetPoint("TOPLEFT",50,-28);category:SetWidth(420);category:SetWordWrap(false)
        if item and item.favourite then local favourite=Font(row,"FAVOURITE",7.5,GOLD,DEFAULT_FONT);favourite:SetPoint("RIGHT",-10,0) end
    end

    local footer=Font(card,"ODYN  •  CHARACTER PROFILE",8,MUTED,DEFAULT_FONT);footer:SetPoint("BOTTOMLEFT",330,20)
    local hint=Font(shade,"The card is captured after the 3D model finishes loading.",9,MUTED,DEFAULT_FONT);hint:SetPoint("TOP",card,"BOTTOM",0,-12)
    local close=Button(shade,"Close",86,30,false);close:SetPoint("TOPRIGHT",card,"BOTTOMRIGHT",0,-10)
    local capture=Button(shade,"Retake Screenshot",142,30,true);capture:SetPoint("RIGHT",close,"LEFT",-8,0)

    local function CaptureCard()
        if shade._rpalCapturing or not Screenshot then return end
        shade._rpalCapturing=true
        hint:Hide();close:Hide();capture:Hide()
        local function Take()
            if shade:IsShown() then Screenshot() end
            local function Restore()
                if shade:IsShown() then hint:Show();close:Show();capture:Show() end
                shade._rpalCapturing=nil
            end
            if C_Timer and C_Timer.After then C_Timer.After(0.22,Restore) else Restore() end
        end
        if C_Timer and C_Timer.After then C_Timer.After(0.08,Take) else Take() end
    end

    local function QueueAutoCapture()
        if shade._rpalAutoCaptureQueued or not Screenshot then return end
        shade._rpalAutoCaptureQueued=true
        if C_Timer and C_Timer.After then C_Timer.After(0.42,function()if shade:IsShown() then CaptureCard() end end) else CaptureCard() end
    end

    local function SetupModel(self)
        if not self or not self:IsShown() then return end
        pcall(function()if self.SetPortraitZoom then self:SetPortraitZoom(0) end end)
        pcall(function()if self.SetCamDistanceScale then self:SetCamDistanceScale(0.90) end end)
        pcall(function()if self.SetFacing then self:SetFacing(0.18) elseif self.SetRotation then self:SetRotation(0.18) end end)
        pcall(function()if self.SetAnimation then self:SetAnimation(0) end end)
        self._rpalModelReady=true
        QueueAutoCapture()
    end
    model:SetScript("OnModelLoaded",SetupModel)
    pcall(function()model:SetUnit("player")end)
    if C_Timer and C_Timer.After then C_Timer.After(1.20,function()
        if model and model:IsShown() and not model._rpalModelReady then SetupModel(model) end
    end) end

    capture:SetScript("OnClick",CaptureCard)
    close:SetScript("OnClick",function()shade:Hide()end)
    shade:HookScript("OnHide",function(self)
        if activeShareView==self then activeShareView=nil end
        model:SetScript("OnModelLoaded",nil);model:SetScript("OnAnimFinished",nil)
    end)
    return shade,card
end

RPAL._socialEvent=CreateFrame("Frame")
RPAL._socialEvent:RegisterEvent("CHAT_MSG_ADDON")
RPAL._socialEvent:RegisterEvent("PLAYER_TARGET_CHANGED")
RPAL._socialEvent:RegisterEvent("PLAYER_ENTERING_WORLD")
RPAL._socialEvent:SetScript("OnEvent",function(_,eventName,...)
    if eventName=="CHAT_MSG_ADDON" then
        local prefix,text,channel,sender=...
        RPAL.HandleSocialAddonMessage(prefix,text,channel,sender)
    elseif eventName=="PLAYER_TARGET_CHANGED" then
        RPAL.UpdateTargetBadge()
        if UnitExists("target") and UnitIsPlayer("target") and not UnitIsUnit("target","player") and RPAL.RequestRemoteTRPStatus then
            local targetName=SocialFullUnitName("target") or UnitName("target")
            if targetName then RPAL.RequestRemoteTRPStatus(targetName,false) end
        end
    elseif eventName=="PLAYER_ENTERING_WORLD" then
        GameTooltip:Hide()
        if RPAL.HideCursorTooltip then RPAL.HideCursorTooltip() end
        if UI.tooltip then UI.tooltip:Hide();UI.tooltip:ClearLines() end
        C_Timer.After(0.75,function()
            StartSocialPresence()
            if RPAL.StartSurvivalTicker then RPAL.StartSurvivalTicker() end
            if RPAL.UpdateSurvivalWidget then RPAL.UpdateSurvivalWidget(0) end
            if RPAL.Refresh then RPAL.Refresh() end
            if RPAL.UpdateTargetBadge then RPAL.UpdateTargetBadge() end
            if RPAL.UpdatePlayerFrameWidget then RPAL.UpdatePlayerFrameWidget() end
            if RPAL.UpdateMinimapWidget then RPAL.UpdateMinimapWidget() end
            if RPAL.CheckTRPSessionStatus then RPAL.CheckTRPSessionStatus(true) end
        end)
    end
end)



RPAL._statisticsEvent=CreateFrame("Frame")RPAL._statisticsEvent=CreateFrame("Frame")
-- Track both Blizzard text emotes (/wave, /smile, etc.) and free-form
-- /emote messages. The latter is CHAT_MSG_EMOTE rather than
-- CHAT_MSG_TEXT_EMOTE, so listening only to the text-emote event silently
-- misses normal RP emotes.
RPAL._statisticsEvent:RegisterEvent("CHAT_MSG_TEXT_EMOTE")
RPAL._statisticsEvent:RegisterEvent("CHAT_MSG_EMOTE")
RPAL._statisticsEvent:SetScript("OnEvent",function(_,eventName,message,sender,...)
    if (eventName=="CHAT_MSG_TEXT_EMOTE" or eventName=="CHAT_MSG_EMOTE") and RPAL.HandleTextEmote then
        RPAL.HandleTextEmote(message,sender,...)
    end
end)

RPAL._loginEvent=CreateFrame("Frame")
RPAL._loginEvent:RegisterEvent("PLAYER_LOGIN")
RPAL._loginEvent:SetScript("OnEvent",function()
    local ok, err = xpcall(function()
        if RPAL.BeginODYNMigration then
            local migrationOK,migrationError=RPAL.BeginODYNMigration()
            if migrationOK==false then error(tostring(migrationError or "ODYN migration was blocked for safety.")) end
        end
        RPAL.InitializeDB()
        if RPAL.InitializeSeasonSystem then RPAL.InitializeSeasonSystem() end
        if RPAL.CompleteODYNMigration then RPAL.CompleteODYNMigration() end

        ApplyCurrentTheme()
        BuildMain()
        if not UI.ready then
            error("BuildMain did not finish.")
        end
        UI.tools:SetScript("OnClick",function()RPAL.OpenTools()end)
        UI.settings:SetScript("OnClick",function()RPAL.OpenSettings()end)
        UI.customize:SetScript("OnClick",function()RPAL.OpenCustomization()end)
        local firstExperienceRun=not ExperienceSetupComplete()
        if firstExperienceRun then
            local firstSettings=RPAL.GetSettings and RPAL.GetSettings() or nil
            if firstSettings then
                firstSettings.autoTRPUpdate=true
                -- A brand-new player has no previous ODYN version to compare.
                -- Reserve WHAT'S NEW for actual updates after their first install.
                firstSettings.patchNotesSeenVersion=RPAL.PATCH_NOTES_VERSION
            end
            if RPAL.SyncTRPProfileScope then pcall(RPAL.SyncTRPProfileScope,true) end
        end
        -- Fresh ODYN installs default to live TRP synchronization in Core.lua.
        -- Existing users keep any explicit saved preference. Pull the active TRP
        -- identity before Setup so the first screen already reflects their profile.
        if RPAL.ScheduleActiveTRPProfileImport then RPAL.ScheduleActiveTRPProfileImport(true) end
        if firstExperienceRun and RPAL.EnsureActiveTRPProfileImported then
            C_Timer.After(0.20,function()if RPAL.EnsureActiveTRPProfileImported then RPAL.EnsureActiveTRPProfileImported(true) end end)
        end
        if RPAL.GetSettings().autoTRPUpdate then
            RPAL.StartTRPAutoUpdate(true)
        end
        StartSocialPresence()
        if RPAL.StartSurvivalTicker then RPAL.StartSurvivalTicker() end
        if RPAL.UpdateSurvivalWidget then RPAL.UpdateSurvivalWidget(0) end
        if RPAL.StartSceneSparkTicker then RPAL.StartSceneSparkTicker() end
        if RPAL.StartTRPSessionStatusMonitor then RPAL.StartTRPSessionStatusMonitor() end
        if RPAL.RefreshSceneButtons then RPAL.RefreshSceneButtons() end
        if RPAL.CreateSessionWidget then RPAL.CreateSessionWidget() end
        if RPAL.UpdateSessionWidget then RPAL.UpdateSessionWidget() end
        if RPAL.ApplyExperienceModeUI then RPAL.ApplyExperienceModeUI() end
        local function OpenConfiguredFirstRunFlow()
            if RPAL.MaybeOpenExperienceSetup then
                local opened=RPAL.MaybeOpenExperienceSetup()
                if not opened and RPAL.MaybePromptLegacyCalibration then RPAL.MaybePromptLegacyCalibration() end
            end
        end
        if firstExperienceRun then
            -- Give Total RP 3 a short readiness window before the mandatory
            -- setup gate appears. If TRP is installed, Setup opens as soon as
            -- the active profile has been imported; if TRP is absent or still
            -- unavailable after ~4 seconds, ODYN proceeds normally instead of
            -- trapping first-run behind an external addon.
            local startedAt=(GetTime and GetTime()) or 0
            local function WaitForFirstTRPIdentity()
                if RPAL.EnsureActiveTRPProfileImported then RPAL.EnsureActiveTRPProfileImported(true) end
                local p=RPAL.char and RPAL.char.profile
                local trpInstalled=type(_G.TRP3_API)=="table" or type(_G.TRP3_Profiles)=="table"
                local elapsed=((GetTime and GetTime()) or startedAt)-startedAt
                if not trpInstalled or (p and p.trpImportedOnce) or elapsed>=4.0 then
                    OpenConfiguredFirstRunFlow()
                elseif C_Timer and C_Timer.After then
                    C_Timer.After(0.25,WaitForFirstTRPIdentity)
                else
                    OpenConfiguredFirstRunFlow()
                end
            end
            C_Timer.After(0.35,WaitForFirstTRPIdentity)
        else
            C_Timer.After(0.55,OpenConfiguredFirstRunFlow)
        end
        C_Timer.After(1.0,function()
            RPAL.UpdateTargetBadge()
            if RPAL.UpdatePlayerFrameWidget then RPAL.UpdatePlayerFrameWidget() end
            if RPAL.UpdateMinimapWidget then RPAL.UpdateMinimapWidget() end
        end)
        print("|cffd9a644ODYN|r loaded. Type |cffffffff/odyn|r to open or |cffffffff/odyn help|r for commands.")
        if RPAL.GetODYNMigrationNotice then
            local notice=RPAL.GetODYNMigrationNotice()
            if notice and notice~="" then print("|cffd9a644ODYN Migration:|r "..tostring(notice)) end
        end
        RPAL.Refresh()
    end, function(message)
        return debugstack(message, 2, 20, 20)
    end)

    if not ok then
        UI.ready = false
        print("|cffff6666ODYN UI error:|r")
        print(err)
    end
end)
