Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions apps/site-web/static/assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2241,11 +2241,13 @@ const WOWPOP = (() => {
if (pv.honor_level > 0 || pv.honorable_kills > 0 || (pv.rated || []).length) sections.push(`<section><h3>PvP</h3><table class="cp-tb">${pv.honor_level != null ? `<tr><td>Honor level</td><td class="n">${nf.format(pv.honor_level)}</td></tr>` : ""}${pv.honorable_kills != null ? `<tr><td>Honorable kills</td><td class="n">${nf.format(pv.honorable_kills)}</td></tr>` : ""}${(pv.rated || []).map(b => `<tr><td>${esc(b.bracket)}</td><td class="n">${nf.format(b.rating)}</td></tr>`).join("")}</table></section>`);
if (co.mounts != null || co.pets != null) sections.push(`<section><h3>Collections</h3><table class="cp-tb">${co.mounts != null ? `<tr><td>Mounts</td><td class="n">${nf.format(co.mounts)}</td></tr>` : ""}${co.pets != null ? `<tr><td>Pets</td><td class="n">${nf.format(co.pets)}</td></tr>` : ""}</table></section>`);
if (pr.length) sections.push(`<section><h3>Professions</h3><table class="cp-tb">${pr.map(x => `<tr><td>${esc(x.name)}${x.tier && x.tier !== x.name ? `<small class="sub"> ${esc(x.tier)}</small>` : ""}</td><td class="n">${x.max ? `${x.skill}/${x.max}` : x.skill != null ? x.skill : ""}</td></tr>`).join("")}</table></section>`);
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 `<div class="cp-hd${d.render ? " has-rd" : ""}" style="--cc:${col}">${d.render ? `<div class="cp-rw" aria-hidden="true"><img class="cp-rd" alt="" hidden></div>` : ""}<div class="cp-who">
${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 ? `<div class="cp-ti">${esc(d.title.replace("{name}", d.name))}</div>` : ""}<h2 style="color:${col}">${esc(d.name)}</h2>
<div class="sub">${d.level != null ? `Level ${d.level} ` : ""}${esc([d.race, d.spec, d.class].filter(Boolean).join(" "))}</div>
<div class="sub">${esc([d.realm, forever ? "Forever" : "Retail", (d.region || c.region || "").toUpperCase()].filter(Boolean).join(" · "))}${factionCrest(d.faction || c.faction)}</div>
${d.title ? `<div class="cp-ti">${esc(d.title.replace("{name}", d.name))}</div>` : ""}<h2 class="cp-nm" style="color:${col}">${esc(d.name)}${factionCrest(d.faction || c.faction)}</h2>
<div class="cp-id">${d.level != null ? `<span class="cp-lv">Level ${esc(d.level)}</span>` : ""}${raceSpec ? ` ${raceSpec}` : ""}${d.class ? ` <span class="cp-cl">${esc(d.class)}</span>` : ""}</div>
<div class="cp-srv">${d.realm ? `<b class="cp-realm">${esc(d.realm)}</b>` : ""}<span class="cp-ver">${d.realm ? " · " : ""}${esc(verLine)}</span></div>
${d.guild ? `<div class="cp-gu">&lt;${esc(d.guild.name)}&gt;${d.guild.rank != null ? ` · ${d.guild.rank === 0 ? "Guild Master" : "Rank " + d.guild.rank}` : ""}</div>` : ""}
<div class="cp-sts">${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))) : ""}</div></div></div>
${sections.length ? `<div class="cp-bd">${sections.join("")}</div>` : ""}
Expand Down
11 changes: 9 additions & 2 deletions apps/site-web/static/assets/site.css
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
97 changes: 95 additions & 2 deletions apps/site-web/tests/wow-popup.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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')?`<img class="faction-crest" alt="${f==='HORDE'?'Horde':'Alliance'}" src="/assets/wow-factions/${f.toLowerCase()}-64.webp">`:'';},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);
Expand Down Expand Up @@ -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</b>'));
assert.ok(html.includes('class="cp-ver"> · Forever · US</span>'));
assert.ok(html.includes("The Armory doesn't list this character yet."));
assert.ok(!html.includes('View on Armory'));
assert.ok(html.includes('>Share</button>'));
});

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('<Ashen Host> · Guild Master') || text.includes('&lt;Ashen Host&gt; · Guild Master') || html.includes('&lt;Ashen Host&gt; · Guild Master'));
assert.ok(html.includes('class="cp-cl">Paladin</span>'));
assert.ok(html.includes('class="cp-realm">PvP</b>'));
assert.match(html,/<h2 class="cp-nm"[^>]*>Lucrona<img class="faction-crest"/);
assert.ok(!html.includes('cp-ver">') || !/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</b>'));
assert.ok(html.includes('class="cp-ver"> · Retail · US</span>'));
assert.ok(html.includes('class="cp-cl">Evoker</span>'));
assert.match(html,/<h2 class="cp-nm"[^>]*>Lucroena<img class="faction-crest" alt="Alliance"/);
});
Loading