diff --git a/apps/site-web/static/assets/app.js b/apps/site-web/static/assets/app.js index 47fa29b..ac60408 100644 --- a/apps/site-web/static/assets/app.js +++ b/apps/site-web/static/assets/app.js @@ -2241,11 +2241,13 @@ const WOWPOP = (() => { if (pv.honor_level > 0 || pv.honorable_kills > 0 || (pv.rated || []).length) sections.push(`

PvP

${pv.honor_level != null ? `` : ""}${pv.honorable_kills != null ? `` : ""}${(pv.rated || []).map(b => ``).join("")}
Honor level${nf.format(pv.honor_level)}
Honorable kills${nf.format(pv.honorable_kills)}
${esc(b.bracket)}${nf.format(b.rating)}
`); if (co.mounts != null || co.pets != null) sections.push(`

Collections

${co.mounts != null ? `` : ""}${co.pets != null ? `` : ""}
Mounts${nf.format(co.mounts)}
Pets${nf.format(co.pets)}
`); if (pr.length) sections.push(`

Professions

${pr.map(x => ``).join("")}
${esc(x.name)}${x.tier && x.tier !== x.name ? ` ${esc(x.tier)}` : ""}${x.max ? `${x.skill}/${x.max}` : x.skill != null ? x.skill : ""}
`); + const raceSpec = [d.race, d.spec].filter(Boolean).map(esc).join(" "); + const verLine = [forever ? "Forever" : "Retail", (d.region || c.region || "").toUpperCase()].filter(Boolean).join(" · "); return `
${d.render ? `` : ""}
${WOWAV.image({ ...c, game_id: d.game_id || c.game_id, class: d.class || c.class, race: d.race || c.race, gender: d.gender || c.gender, avatar_src: d.avatar_src }, 'class="cp-avatar" width="64" height="64"')} - ${d.title ? `
${esc(d.title.replace("{name}", d.name))}
` : ""}

${esc(d.name)}

-
${d.level != null ? `Level ${d.level} ` : ""}${esc([d.race, d.spec, d.class].filter(Boolean).join(" "))}
-
${esc([d.realm, forever ? "Forever" : "Retail", (d.region || c.region || "").toUpperCase()].filter(Boolean).join(" · "))}${factionCrest(d.faction || c.faction)}
+ ${d.title ? `
${esc(d.title.replace("{name}", d.name))}
` : ""}

${esc(d.name)}${factionCrest(d.faction || c.faction)}

+
${d.level != null ? `Level ${esc(d.level)}` : ""}${raceSpec ? ` ${raceSpec}` : ""}${d.class ? ` ${esc(d.class)}` : ""}
+
${d.realm ? `${esc(d.realm)}` : ""}${d.realm ? " · " : ""}${esc(verLine)}
${d.guild ? `
<${esc(d.guild.name)}>${d.guild.rank != null ? ` · ${d.guild.rank === 0 ? "Guild Master" : "Rank " + d.guild.rank}` : ""}
` : ""}
${d.ilvl != null ? stat("Item level", nf.format(d.ilvl)) : ""}${d.achievement_points > 0 ? stat("Achievements", nf.format(d.achievement_points)) : ""}${mp.rating != null ? stat("M+ rating", nf.format(Math.round(mp.rating))) : ""}
${sections.length ? `
${sections.join("")}
` : ""} diff --git a/apps/site-web/static/assets/site.css b/apps/site-web/static/assets/site.css index 3555e46..6a594de 100644 --- a/apps/site-web/static/assets/site.css +++ b/apps/site-web/static/assets/site.css @@ -874,9 +874,16 @@ body.cp-lock{overflow:hidden} .cp-rd{position:absolute;left:50%;bottom:-34%;height:195%;width:auto;max-width:none;transform:translateX(-50%)} .cp-hd.has-rd .cp-who{max-width:calc(100% - 250px)} .cp-who{position:relative;padding:18px 20px;max-width:70%} -.cp-who h2{margin:2px 0;font-size:24px;overflow-wrap:anywhere} +.cp-who h2,.cp-nm{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:2px 0;font-size:24px;overflow-wrap:anywhere} .cp-ti{color:var(--label);font-size:12px} -.cp-gu{color:#a7d08c;margin-top:4px} +.cp-id{color:#c8ced6;font-size:13px;line-height:1.35;margin-top:4px} +.cp-lv{font-weight:600} +.cp-cl{color:var(--cc,#eef2f6);font-weight:700} +.cp-srv{margin-top:4px;font-size:13px;line-height:1.35} +.cp-realm{color:#eef2f6;font-weight:600} +.cp-ver{color:#b9bec5;font-weight:400} +.cp-gu{color:#a7d08c;margin-top:6px;font-size:13px} +.cp-who .faction-crest{width:20px;height:22px;margin-left:0;vertical-align:-4px;flex-shrink:0;filter:drop-shadow(0 0 0.6px #f4f7fb) drop-shadow(0 0 0.6px #f4f7fb) drop-shadow(0 1px 1px rgba(0,0,0,.55))} .cp-f.horde{color:#e0574f}.cp-f.alliance{color:#5a9be0} .cp-sts{display:flex;gap:18px;margin-top:12px;flex-wrap:wrap} .cp-st small{display:block;color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.04em}.cp-st b{font-size:18px;color:#eef2f6} diff --git a/apps/site-web/tests/wow-popup.test.cjs b/apps/site-web/tests/wow-popup.test.cjs index 13924e8..6acf4d4 100644 --- a/apps/site-web/tests/wow-popup.test.cjs +++ b/apps/site-web/tests/wow-popup.test.cjs @@ -15,7 +15,7 @@ function harness(data={name:'Sample',region:'us',level:18,gear:[]}, opts={}) { const history={state:null,pushState(s,_,path){this.state=s;calls.push(['push',path]);location.pathname=path},replaceState(s,_,path){this.state=s;calls.push(['replace',path]);location.pathname=path},back(){calls.push(['back']);location.pathname='/u/sample'}}; const inside={innerHTML:''}; const ctx={console,URL,encodeURIComponent,Number,Date,Map,Set,location,history,navigator:{clipboard:{async writeText(s){copied.push(s)}}}, - WOW:{clsIcon:c=>'/class/'+c,raceIcon:()=>'/race/test'},siteBase:()=> 'https://playfraglink.com',esc,nf:new Intl.NumberFormat('en'),plural:(n,s)=>`${n} ${s}`,factionCrest:()=>'',WH:{ok:!!opts.whOk,scan(){}},imgOk:'', + WOW:{clsIcon:c=>'/class/'+c,raceIcon:()=>'/race/test'},siteBase:()=> 'https://playfraglink.com',esc,nf:new Intl.NumberFormat('en'),plural:(n,s)=>`${n} ${s}`,factionCrest:value=>{const f=String(value||'').toUpperCase();return (f==='HORDE'||f==='ALLIANCE')?`${f==='HORDE'?'Horde':'Alliance'}`:'';},WH:{ok:!!opts.whOk,scan(){}},imgOk:'', addEventListener:(name,fn)=>events[name]=fn,document:{createElement(){const node={remove(){},innerHTML:'',className:''};nodes.push(node);return node},body:{appendChild(){},classList:{add(){},remove(){}}},addEventListener(){},removeEventListener(){}}, $:(selector)=>selector==='.cp-x'?{focus(){}}:selector==='.cp-in'?inside:null,$$:()=>[],api:async()=>data,setTimeout:fn=>{timers.push(fn);return 0}}; const {WOWPOP,WOWLINK}=vm.runInNewContext(linkSource+avatarSource+popupSource+';({WOWPOP,WOWLINK})',ctx); @@ -100,8 +100,101 @@ test('Share copies the stable URL for both versions, confirms, then resets',asyn test('Forever characters without an Armory page stay open with a short note',()=>{ const {WOWPOP}=harness(); const html=WOWPOP.body({name:'Lucroena Ashborne',level:4,region:'us',realm:'PvP',game_id:'wow_forever',armory_unavailable:true,gear:[],stats:[]},{game_id:'wow_forever',name:'Lucroena Ashborne'}); - assert.ok(html.includes('PvP · Forever · US') || html.includes('PvP · Forever · us') || /PvP/.test(html) && /Forever/.test(html) && /US/.test(html)); + const text=html.replace(/<[^>]+>/g,''); + assert.ok(text.includes('PvP · Forever · US')); + assert.ok(html.includes('class="cp-realm">PvP')); + assert.ok(html.includes('class="cp-ver"> · Forever · US')); assert.ok(html.includes("The Armory doesn't list this character yet.")); assert.ok(!html.includes('View on Armory')); assert.ok(html.includes('>Share')); }); + +function hexLum(hex){ + const h=hex.replace('#',''); + const n=h.length===3?h.split('').map(c=>c+c).join(''):h; + const rgb=[0,2,4].map(i=>parseInt(n.slice(i,i+2),16)/255); + const lin=c=>c<=0.04045?c/12.92:((c+0.055)/1.055)**2.4; + const [r,g,b]=rgb.map(lin); + return 0.2126*r+0.7152*g+0.0722*b; +} +function contrast(a,b){ + const L1=hexLum(a),L2=hexLum(b),hi=Math.max(L1,L2),lo=Math.min(L1,L2); + return (hi+0.05)/(lo+0.05); +} +function ruleColor(css,sel){ + const m=css.match(new RegExp(sel.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')+'\\s*\\{[^}]*\\}','m')); + assert.ok(m,sel+' rule'); + const c=(m[0].match(/color:([^;}]+)/)||[])[1]; + assert.ok(c,sel+' color'); + const hex=(c.trim().match(/#(?:[0-9a-fA-F]{3}){1,2}/)||[])[0]; + assert.ok(hex,sel+' hex color'); + return hex; +} + +test('popup header uses class colour, places the crest by the name, and keeps AA contrast',()=>{ + const {WOWPOP}=harness(); + const html=WOWPOP.body({ + name:'Lucrona',level:22,race:'Undead',spec:'Retribution',class:'Paladin',class_id:2, + realm:'PvP',region:'us',game_id:'wow_forever',faction:'HORDE', + title:'Lucrona the Kingslayer', + guild:{name:'Ashen Host',rank:0} + },{game_id:'wow_forever',name:'Lucrona',faction:'HORDE'}); + const text=html.replace(/<[^>]+>/g,''); + assert.ok(text.includes('Level 22 Undead Retribution Paladin')); + assert.ok(text.includes('PvP · Forever · US')); + assert.ok(text.includes(' · Guild Master') || text.includes('<Ashen Host> · Guild Master') || html.includes('<Ashen Host> · Guild Master')); + assert.ok(html.includes('class="cp-cl">Paladin')); + assert.ok(html.includes('class="cp-realm">PvP')); + assert.match(html,/

]*>Lucrona') || !/cp-ver">[^<]*faction-crest/.test(html)); + const afterSrv=html.slice(html.indexOf('class="cp-srv"')); + assert.ok(!afterSrv.slice(0,afterSrv.indexOf('cp-gu')).includes('faction-crest'),'crest is not after the server line'); + assert.ok(html.includes('style="--cc:#F48CBA"') || html.includes('style="--cc:#f48cba"')); + + const css=readFileSync(__dirname+'/../static/assets/site.css','utf8'); + const bgs=['#111419','#1a2533']; // popup header gradient ends + const checks={ + '.cp-id':'#c8ced6', + '.cp-realm':'#eef2f6', + '.cp-ver':'#b9bec5', + '.cp-gu':'#a7d08c', + '.cp-ti':'#c99a62', // var(--label) + }; + // --label is a variable; .cp-ti may not have a hex. Handle both. + for(const [sel,expect] of Object.entries(checks)){ + let color; + if(sel==='.cp-ti'){ + const rule=(css.match(/\.cp-ti\{[^}]+\}/)||[])[0]||''; + assert.ok(rule.includes('var(--label)') || rule.includes('#c99a62')); + color='#c99a62'; + } else { + color=ruleColor(css,sel); + assert.equal(color.toLowerCase(),expect); + } + for(const bg of bgs){ + const ratio=contrast(color,bg); + assert.ok(ratio>=4.5,`${sel} ${color} on ${bg} is ${ratio.toFixed(2)}:1`); + } + } + assert.ok(css.includes('.cp-cl{color:var(--cc,#eef2f6)')); + assert.ok(css.includes('.cp-who .faction-crest')); + assert.ok(css.includes('drop-shadow')); + assert.ok(!/cp-who .faction-crest\{[^}]*glow/.test(css)); + const crest=css.match(/\.cp-who \.faction-crest\{[^}]+\}/)[0]; + assert.ok(/width:20px/.test(crest) && /height:22px/.test(crest)); +}); + +test('Retail popup header shows the realm in medium-bold ahead of the version line',()=>{ + const {WOWPOP}=harness(); + const html=WOWPOP.body({ + name:'Lucroena',level:80,race:'Dracthyr',spec:'Devastation',class:'Evoker',class_id:13, + realm:'Frostmourne',region:'us',game_id:'wow',faction:'ALLIANCE' + },{game_id:'wow',name:'Lucroena',faction:'ALLIANCE'}); + const text=html.replace(/<[^>]+>/g,''); + assert.ok(text.includes('Level 80 Dracthyr Devastation Evoker')); + assert.ok(text.includes('Frostmourne · Retail · US')); + assert.ok(html.includes('class="cp-realm">Frostmourne')); + assert.ok(html.includes('class="cp-ver"> · Retail · US')); + assert.ok(html.includes('class="cp-cl">Evoker')); + assert.match(html,/

]*>Lucroena