Full revamp of /quickref.phtml into a Pokemon-card style layout, themed with Neopets h5 assets. Standalone (no dependency on the other scripts in this collection). Embedded theme list, default theme stored locally, each pet card can be assigned its own theme via the bottom carousel arrows. Right-click an arrow to reset that pet to the default theme. Also tracks per-pet stat history and shows a small evolution graph.
// ==UserScript==
// @name ⑤ Neopets — QuickRef Pet Card
// @namespace neopets-qol
// @version 1.0.1
// @author marius@clraik
// @license MIT
// @description Full revamp of /quickref.phtml into a Pokemon-card style layout, themed with Neopets h5 assets. Standalone (no dependency on the other scripts in this collection). Embedded theme list, default theme stored locally, each pet card can be assigned its own theme via the bottom carousel arrows. Right-click an arrow to reset that pet to the default theme. Also tracks per-pet stat history and shows a small evolution graph.
// @match *://www.neopets.com/quickref.phtml*
// @run-at document-start
// @grant none
// ==/UserScript==
(function () {
'use strict';
if (window.top !== window) return;
/* =========================
CONFIG
========================= */
const CFG = {
HEAL_GIF: 'https://media1.giphy.com/media/v1.Y2lkPTc5MGI3NjExMmdwczVnemRzM2I0MjU5ZDZreW9rbzB3N3g5ZXRqZjlkam1rOGh1MiZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9cw/WQTfxKgnEycoi8eRLX/giphy.gif',
HEAL_LINK: 'https://www.neopets.com/safetydeposit.phtml?obj_name=Cooling+Ointment',
POSE_SWAP: true,
};
const STATS_LS = 'jb_np_petstats_v1';
const HSD_CAP = 850; // cap officiel Str/Def pour le score HSD
/* Palette pastel par stat (lisible sur fond clair) */
const STAT_COLOR = {
level: '#d889b3', // rose pastel
hpMax: '#b59ad6', // lila pastel
str: '#d88989', // rouge pastel (corail)
def: '#5e7eb0', // bleu foncé pastel (dusty)
move: '#7fc7be', // turquoise pastel
hsd: '#a07cd8', // lila moyen (accent HSD)
};
/* Couleurs UI (DA Neopets sobre) */
const UI_TEXT_DIM = '#888';
const UI_GRID = '#e5e5e5';
/* =========================
THÈMES par couleur de pet (carte d'identité)
p = primary (bandeau, bordure)
s = secondary (gradient, accents)
a = accent (chips, hover)
light = bg tinté très pâle
========================= */
const COLOUR_THEMES = {
// Holidays / Special
christmas: { p:'#c1121f', s:'#2d6a4f', a:'#ffd700', light:'#fff5f0' },
halloween: { p:'#e85d04', s:'#3d2645', a:'#ffd700', light:'#fff5e6' },
valentine: { p:'#ec4899', s:'#c41e6e', a:'#fff5f9', light:'#fff0f5' },
// Magical
faerie: { p:'#ec80c8', s:'#a07cd8', a:'#ffd700', light:'#fdf2fa' },
royal: { p:'#7a3fbb', s:'#d4af37', a:'#fff', light:'#f4ecff' },
royalgirl: { p:'#7a3fbb', s:'#d4af37', a:'#fff', light:'#f4ecff' },
royalboy: { p:'#5e7eb0', s:'#d4af37', a:'#fff', light:'#f0f5fc' },
glowing: { p:'#ffd700', s:'#ff9500', a:'#3d2645', light:'#fffbe0' },
// Cute / Pastel
baby: { p:'#f9b8c5', s:'#a8d8e0', a:'#fff', light:'#fff5f7' },
plushie: { p:'#f5b5d4', s:'#a8c5e0', a:'#fff', light:'#fff0f8' },
transparent:{ p:'#a8d5f5', s:'#dcb8ff', a:'#fff', light:'#fafcff' },
invisible: { p:'#cccccc', s:'#a8d5f5', a:'#dcb8ff', light:'#fafafa' },
// Dark
shadow: { p:'#3d2645', s:'#5e4565', a:'#a07cd8', light:'#f0eef5' },
darigan: { p:'#3d2645', s:'#a07cd8', a:'#c1121f', light:'#f0eef5' },
zombie: { p:'#5d8a17', s:'#3d2645', a:'#a8df3c', light:'#f0f5e8' },
wraith: { p:'#3d2645', s:'#8b6fbb', a:'#fff', light:'#f4ecff' },
grave: { p:'#3d2645', s:'#666', a:'#a07cd8', light:'#f0eef5' },
skunk: { p:'#2d2d2d', s:'#fff', a:'#a07cd8', light:'#f5f5f5' },
// Nature / Ghost
ghost: { p:'#a8d5f5', s:'#d8d8e8', a:'#fff', light:'#f5fbff' },
cloud: { p:'#7eb8d4', s:'#fff', a:'#dcb8ff', light:'#f5fcff' },
snow: { p:'#bcd8e8', s:'#a8d5f5', a:'#fff', light:'#f5fafe' },
ice: { p:'#5e7eb0', s:'#a8d5f5', a:'#fff', light:'#f5fbff' },
// Fire / Heat
fire: { p:'#e85d04', s:'#c1121f', a:'#ffd700', light:'#fff5e6' },
magma: { p:'#e85d04', s:'#3d2645', a:'#ffd700', light:'#fff5e6' },
// Water
water: { p:'#5e7eb0', s:'#a8d5f5', a:'#fff', light:'#f0f5fc' },
maraquan: { p:'#5e7eb0', s:'#7fc7be', a:'#a8d5f5', light:'#f0fcfa' },
// Mutant / Alien
mutant: { p:'#5d8a17', s:'#a8df3c', a:'#3d2645', light:'#f5fbe5' },
alien: { p:'#a07cd8', s:'#a8df3c', a:'#fff', light:'#f4ecff' },
// Tribal / Ancient
tyrannian: { p:'#a0826d', s:'#5d3a1f', a:'#c1121f', light:'#fcf8f2' },
pirate: { p:'#a0826d', s:'#c1121f', a:'#d4af37', light:'#fcf8f2' },
desert: { p:'#d4a574', s:'#a0826d', a:'#fff', light:'#fcf8f2' },
island: { p:'#7fc7be', s:'#a0826d', a:'#ffd700', light:'#f0fcfa' },
// Mechanical / Steampunk
robot: { p:'#5e7eb0', s:'#888', a:'#a8d5f5', light:'#f0f2f5' },
steampunk: { p:'#a0826d', s:'#d4af37', a:'#5e7eb0', light:'#fcf8f2' },
// Solid colours
blue: { p:'#5e7eb0', s:'#7fb3d4', a:'#fff', light:'#f0f5fc' },
red: { p:'#c1121f', s:'#d88989', a:'#fff', light:'#fff5f5' },
green: { p:'#5ca817', s:'#7fc7be', a:'#fff', light:'#f5fcf0' },
yellow: { p:'#e6c000', s:'#f5a623', a:'#5d3a1f', light:'#fffce0' },
orange: { p:'#e85d04', s:'#f5a623', a:'#fff', light:'#fff5e6' },
pink: { p:'#ec80c8', s:'#f5b5d4', a:'#fff', light:'#fdf2fa' },
purple: { p:'#7a3fbb', s:'#a07cd8', a:'#fff', light:'#f4ecff' },
white: { p:'#bbb', s:'#a8d5f5', a:'#666', light:'#fafafa' },
black: { p:'#2d2d2d', s:'#666', a:'#a07cd8', light:'#f5f5f5' },
brown: { p:'#8d6e4d', s:'#5d3a1f', a:'#fff', light:'#fcf8f2' },
grey: { p:'#666', s:'#a8a8a8', a:'#fff', light:'#f0f0f0' },
gray: { p:'#666', s:'#a8a8a8', a:'#fff', light:'#f0f0f0' },
silver: { p:'#9ba0a8', s:'#5e7eb0', a:'#fff', light:'#f0f2f5' },
gold: { p:'#d4af37', s:'#ffd700', a:'#5d3a1f', light:'#fffce0' },
// Patterns
speckled: { p:'#d4a574', s:'#5d3a1f', a:'#fff', light:'#fcf8f2' },
spotted: { p:'#e6c000', s:'#5d3a1f', a:'#fff', light:'#fffce0' },
striped: { p:'#5e7eb0', s:'#e85d04', a:'#fff', light:'#f0f5fc' },
checkered: { p:'#2d2d2d', s:'#fff', a:'#a07cd8', light:'#f5f5f5' },
camouflage:{ p:'#5d8a17', s:'#5d3a1f', a:'#a8df3c', light:'#f5fcf0' },
disco: { p:'#ec80c8', s:'#a07cd8', a:'#7fc7be', light:'#fdf2fa' },
jelly: { p:'#f5b5d4', s:'#a8d5f5', a:'#a8df3c', light:'#fff5fa' },
sponge: { p:'#f5a623', s:'#ffd700', a:'#fff', light:'#fffce0' },
// Special
eventide: { p:'#7a3fbb', s:'#7fc7be', a:'#ffd700', light:'#f4ecff' },
sketch: { p:'#5e5e5e', s:'#fff', a:'#a07cd8', light:'#fafafa' },
elderly: { p:'#a8a8a8', s:'#888', a:'#a07cd8', light:'#f5f5f5' },
nostalgic: { p:'#a07cd8', s:'#f5b5d4', a:'#ffd700', light:'#f4ecff' },
};
const DEFAULT_THEME = { p:'#555', s:'#383838', a:'#a07cd8', light:'#f5f5f5' };
/* =========================
THÈMES NEOPETS — AUTONOME (pas de dépendance à ① CORE ni ③ BG).
Chaque entrée : id (folder name H5), label (display name), ext (extension
des icônes : .svg ou .png varie par thème), iconPrefix (sous-chemin optionnel
dans /images/, ex: "v3/" pour basic). Liste maintenue manuellement — si
un thème H5 sort, on l'ajoute ici. Le thème par défaut (utilisé pour les
cards SANS override per-pet) est stocké en LS et modifiable via le bouton
carousel : la 1ère card sur laquelle on clique définit aussi le défaut.
========================= */
const LOCAL_THEMES = [
{ id:'winterholiday', label:'Winter Holiday', ext:'png' },
{ id:'basic', label:'Basic', ext:'svg', iconPrefix:'v3/' },
{ id:'premium', label:'Premium', ext:'svg' },
{ id:'altadorcup', label:'Altador Cup', ext:'png' },
{ id:'constellations', label:'Constellations', ext:'svg' },
{ id:'birthday', label:'Birthday', ext:'png' },
{ id:'destroyedfestival', label:'Faerie Festival', ext:'svg' },
{ id:'neggs', label:'Festival of Neggs', ext:'svg' },
{ id:'grey', label:'Grey Day', ext:'png' },
{ id:'newyears', label:'New Year', ext:'png' },
{ id:'hauntedwoods', label:'Haunted Woods', ext:'svg' },
{ id:'meridell', label:'Meridell', ext:'svg' },
{ id:'mysteryisland', label:'Mystery Island', ext:'png' },
{ id:'neopiantimes', label:'Neopian Times', ext:'svg' },
{ id:'neggsneovia', label:'Neovian Neggs', ext:'svg' },
{ id:'tistheseason', label:'Tis the Season', ext:'png' },
{ id:'tyrannia', label:'Tyrannia', ext:'svg' },
{ id:'valentines', label:'Valentines', ext:'svg' },
];
const FALLBACK_THEME = 'altadorcup';
const QR_DEFAULT_THEME_LS = 'jb_np_quickref_defaultTheme_v1';
function getDefaultTheme(){
try {
const v = localStorage.getItem(QR_DEFAULT_THEME_LS);
if (v && LOCAL_THEMES.some(t => t.id === v)) return v;
} catch {}
return FALLBACK_THEME;
}
function setDefaultTheme(theme){
try { localStorage.setItem(QR_DEFAULT_THEME_LS, theme); } catch {}
}
/* Résout l'URL d'une icône (ex: 'mypets', 'petcentral') pour un thème donné.
Utilise la liste embarquée LOCAL_THEMES pour gérer les variations
d'extension/sous-chemin par thème. */
function localIconUrl(iconName, themeId){
const t = LOCAL_THEMES.find(x => x.id === themeId)
|| LOCAL_THEMES.find(x => x.id === FALLBACK_THEME);
const ext = (t && t.ext) || 'svg';
const prefix = (t && t.iconPrefix) || '';
return `https://images.neopets.com/themes/h5/${themeId}/images/${prefix}${iconName}-icon.${ext}`;
}
// Alias historique — utilisé par effectivePetTheme comme fallback "thème courant"
function getCurrentNeoTheme(){ return getDefaultTheme(); }
/* ============================================================
ASSET PROBING — les thèmes H5 utilisent des noms variables
(ex: pattern-header.png OU header-pattern.png OU .svg).
On teste plusieurs candidats par asset et on garde le 1er
qui répond OK. Cache LS par (theme, asset).
============================================================ */
const ASSET_CACHE_LS = 'jb_np_assetcache_v4';
const ASSET_CANDIDATES = {
patternHeader: ['pattern-header.png', 'pattern-header.svg', 'header-pattern.png', 'pattern-header.gif'],
bgPattern: ['bg-pattern.png', 'bg-pattern.svg', 'pattern.png', 'background-pattern.png', 'background.png'],
navBottom: ['nav-pattern-bottom.svg', 'nav-bottom-pattern.svg', 'nav-pattern-bottom.png', 'nav-bottom.svg'],
footerPattern: ['footer-pattern.png', 'pattern-footer.png', 'footer-pattern.svg', 'pattern-footer.svg', 'nav-pattern-top.svg'],
hpNameplate: ['hp-nameplate.svg', 'hp-nameplate.png', 'nameplate.svg', 'nameplate.png'],
hpBgTop: ['hp-bg-top.png', 'hp-bg-top.svg', 'hp-bg.png'],
hpBgBottom: ['hp-bg-bottom.png', 'hp-bg-bottom.svg', 'hp-bg-bot.png'],
carouselArrow: ['carouselarrow-right.svg', 'carouselarrow-right.png', 'carousel-arrow-right.svg', 'arrow-right.svg'],
};
function loadAssetCache(){
try { return JSON.parse(localStorage.getItem(ASSET_CACHE_LS) || '{}') || {}; }
catch { return {}; }
}
function saveAssetCache(c){
try { localStorage.setItem(ASSET_CACHE_LS, JSON.stringify(c)); } catch {}
}
function imageLoads(url){
return new Promise(resolve => {
let done = false;
const img = new Image();
const finish = (ok) => { if (done) return; done = true; resolve(ok); };
img.onload = () => finish(true);
img.onerror = () => finish(false);
img.src = url;
setTimeout(() => finish(false), 4500);
});
}
async function resolveAsset(theme, key){
const cache = loadAssetCache();
const ck = `${theme}/${key}`;
if (ck in cache) return cache[ck] || null;
const base = `https://images.neopets.com/themes/h5/${theme}/images`;
for (const name of ASSET_CANDIDATES[key]){
const url = `${base}/${name}`;
if (await imageLoads(url)){
cache[ck] = url;
saveAssetCache(cache);
return url;
}
}
cache[ck] = '';
saveAssetCache(cache);
return null;
}
function setCssUrl(varName, url){
const v = url ? `url('${url}')` : 'none';
document.documentElement.style.setProperty(varName, v);
}
function applyGlobalNeoTheme(){
const theme = getCurrentNeoTheme();
const base = `https://images.neopets.com/themes/h5/${theme}/images`;
const root = document.documentElement.style;
root.setProperty('--neo-theme', theme);
// === Pose immédiate du candidat #1 (rendu rapide), puis probe async pour upgrade ===
setCssUrl('--neo-nameplate', `${base}/${ASSET_CANDIDATES.hpNameplate[0]}`);
setCssUrl('--neo-hp-bg-top', `${base}/${ASSET_CANDIDATES.hpBgTop[0]}`);
setCssUrl('--neo-hp-bg-bot', `${base}/${ASSET_CANDIDATES.hpBgBottom[0]}`);
setCssUrl('--neo-pattern-header', `${base}/${ASSET_CANDIDATES.patternHeader[0]}`);
setCssUrl('--neo-bg-pattern', `${base}/${ASSET_CANDIDATES.bgPattern[0]}`);
setCssUrl('--neo-nav-bottom', `${base}/${ASSET_CANDIDATES.navBottom[0]}`);
setCssUrl('--neo-footer-pattern', `${base}/${ASSET_CANDIDATES.footerPattern[0]}`);
setCssUrl('--neo-carousel-arrow', `${base}/${ASSET_CANDIDATES.carouselArrow[0]}`);
// === Probe async chaque asset, met à jour le var quand le bon candidat est trouvé ===
resolveAsset(theme, 'hpNameplate') .then(u => setCssUrl('--neo-nameplate', u));
resolveAsset(theme, 'hpBgTop') .then(u => setCssUrl('--neo-hp-bg-top', u));
resolveAsset(theme, 'hpBgBottom') .then(u => setCssUrl('--neo-hp-bg-bot', u));
resolveAsset(theme, 'patternHeader').then(u => setCssUrl('--neo-pattern-header', u));
resolveAsset(theme, 'bgPattern') .then(u => setCssUrl('--neo-bg-pattern', u));
resolveAsset(theme, 'navBottom') .then(u => setCssUrl('--neo-nav-bottom', u));
resolveAsset(theme, 'footerPattern').then(u => setCssUrl('--neo-footer-pattern', u));
resolveAsset(theme, 'carouselArrow').then(u => setCssUrl('--neo-carousel-arrow', u));
// === Icônes : résolution locale (autonome) via LOCAL_THEMES ===
setCssUrl('--neo-mypets-icon', localIconUrl('mypets', theme));
setCssUrl('--neo-corner-icon', localIconUrl('petcentral', theme));
document.documentElement.dataset.jbNeoTheme = theme;
}
// ⑧ QUICKREF est autonome : pas d'écoute des events de ① CORE / ③ BG.
// Le thème par défaut est lu depuis LS (QR_DEFAULT_THEME_LS). Boot direct.
applyGlobalNeoTheme();
/* ============================================================
THÈME PER-PET — chaque pet card peut avoir son propre thème
stocké en LS (jb_np_petTheme_<petName>). Override les CSS
vars sur le petDiv → cascade vers les descendants seulement.
============================================================ */
const PET_THEME_LS_PREFIX = 'jb_np_petTheme_';
function getPetThemeOverride(petName){
try { return localStorage.getItem(PET_THEME_LS_PREFIX + petName); }
catch { return null; }
}
function setPetThemeOverride(petName, theme){
try {
if (theme) localStorage.setItem(PET_THEME_LS_PREFIX + petName, theme);
else localStorage.removeItem(PET_THEME_LS_PREFIX + petName);
} catch {}
}
function effectivePetTheme(petName){
return getPetThemeOverride(petName) || getCurrentNeoTheme();
}
/* Applique les CSS vars du thème courant (per-pet OU global) sur le petDiv.
Cascade : les enfants utilisent ces vars en priorité sur celles du :root. */
function applyPetCardTheme(petDiv, petName){
const theme = effectivePetTheme(petName);
const base = `https://images.neopets.com/themes/h5/${theme}/images`;
const setVar = (n, url) => petDiv.style.setProperty(n, url ? `url('${url}')` : 'none');
// Pose immédiate des candidats #1
setVar('--neo-nameplate', `${base}/${ASSET_CANDIDATES.hpNameplate[0]}`);
setVar('--neo-hp-bg-top', `${base}/${ASSET_CANDIDATES.hpBgTop[0]}`);
setVar('--neo-hp-bg-bot', `${base}/${ASSET_CANDIDATES.hpBgBottom[0]}`);
setVar('--neo-pattern-header', `${base}/${ASSET_CANDIDATES.patternHeader[0]}`);
setVar('--neo-bg-pattern', `${base}/${ASSET_CANDIDATES.bgPattern[0]}`);
setVar('--neo-nav-bottom', `${base}/${ASSET_CANDIDATES.navBottom[0]}`);
setVar('--neo-footer-pattern', `${base}/${ASSET_CANDIDATES.footerPattern[0]}`);
setVar('--neo-carousel-arrow', `${base}/${ASSET_CANDIDATES.carouselArrow[0]}`);
// Icône mypets : résolution locale (autonome)
setVar('--neo-mypets-icon', localIconUrl('mypets', theme));
// Probe async → upgrade au vrai bon candidat
resolveAsset(theme, 'hpNameplate') .then(u => setVar('--neo-nameplate', u));
resolveAsset(theme, 'hpBgTop') .then(u => setVar('--neo-hp-bg-top', u));
resolveAsset(theme, 'hpBgBottom') .then(u => setVar('--neo-hp-bg-bot', u));
resolveAsset(theme, 'patternHeader').then(u => setVar('--neo-pattern-header', u));
resolveAsset(theme, 'bgPattern') .then(u => setVar('--neo-bg-pattern', u));
resolveAsset(theme, 'navBottom') .then(u => setVar('--neo-nav-bottom', u));
resolveAsset(theme, 'footerPattern').then(u => setVar('--neo-footer-pattern', u));
resolveAsset(theme, 'carouselArrow').then(u => setVar('--neo-carousel-arrow', u));
// Attribut pour les CSS overrides dark-theme per-card
petDiv.dataset.jbNeoTheme = theme;
}
/* Cycle thèmes pour cette carte (via la liste embarquée LOCAL_THEMES — autonome).
dir = +1 (suivant) ou -1 (précédent). On en profite pour adopter ce thème
comme nouveau défaut quickref (pratique : la dernière flèche cliquée pour
une card devient le thème par défaut des futures cards sans override). */
function cyclePetTheme(petDiv, petName, dir){
const themes = LOCAL_THEMES.map(t => t.id);
if (!themes.length) return;
const current = effectivePetTheme(petName);
const idx = themes.indexOf(current);
const n = themes.length;
const next = themes[((idx + dir) % n + n) % n];
setPetThemeOverride(petName, next);
setDefaultTheme(next);
applyPetCardTheme(petDiv, petName);
}
/* Boutons "carousel arrow" left + right en bas de chaque pet card */
function addThemeCarouselButton(petDiv, petName){
if (petDiv.querySelector('.jbThemeCarousel')) return;
const mkBtn = (cls, dir, title) => {
const b = document.createElement('button');
b.className = 'jbThemeCarousel ' + cls;
b.type = 'button';
b.title = title;
b.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
cyclePetTheme(petDiv, petName, dir);
});
b.addEventListener('contextmenu', (ev) => {
ev.preventDefault();
setPetThemeOverride(petName, null);
applyPetCardTheme(petDiv, petName);
});
return b;
};
petDiv.appendChild(mkBtn('jbThemeCarouselLeft', -1, 'Thème précédent (right-click = reset)'));
petDiv.appendChild(mkBtn('jbThemeCarouselRight', +1, 'Thème suivant (right-click = reset)'));
}
function getColourTheme(colour){
if (!colour) return DEFAULT_THEME;
const key = colour.trim().toLowerCase().replace(/[\s_\-]+/g, '');
return COLOUR_THEMES[key] || DEFAULT_THEME;
}
/* Calcule la luminance d'un hex pour choisir texte clair/foncé en surimpression */
function isLightColor(hex){
const r = parseInt(hex.slice(1,3), 16);
const g = parseInt(hex.slice(3,5), 16);
const b = parseInt(hex.slice(5,7), 16);
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.62;
}
function setThemeVars(petDiv, theme){
const textOnPrimary = isLightColor(theme.p) ? '#2d2d2d' : '#fff';
petDiv.style.setProperty('--pet-primary', theme.p);
petDiv.style.setProperty('--pet-secondary', theme.s);
petDiv.style.setProperty('--pet-accent', theme.a);
petDiv.style.setProperty('--pet-light', theme.light);
petDiv.style.setProperty('--pet-text', textOnPrimary);
}
/* Convert helpers */
function rgbToHex({r,g,b}){
return '#' + [r,g,b].map(n => Math.max(0,Math.min(255,Math.round(n))).toString(16).padStart(2,'0')).join('');
}
function lighten(rgb, amt){ return { r: rgb.r + (255-rgb.r)*amt, g: rgb.g + (255-rgb.g)*amt, b: rgb.b + (255-rgb.b)*amt }; }
function darken(rgb, amt){ return { r: rgb.r * (1-amt), g: rgb.g * (1-amt), b: rgb.b * (1-amt) }; }
/* Construit un thème (p/s/a/light) à partir d'un RGB dominant */
function themeFromRgb(rgb){
if (!rgb) return null;
return {
p: rgbToHex(rgb),
s: rgbToHex(darken(rgb, 0.25)),
a: rgbToHex(lighten(rgb, 0.60)),
light: rgbToHex(lighten(rgb, 0.75)), // moins blanc → fond plus visible
};
}
/* Extrait la couleur dominante d'une image (avec cache localStorage par sci).
Tente d'éviter blanc/noir/grayscale qui pollueraient la moyenne. */
/* Extracteur amélioré : center-weight (le pet est au centre, le background
occupe les bords) + filtre saturation plus strict + score sat dominant. */
function pickDominantColor(data, w, h){
const cx = w / 2, cy = h / 2;
const maxDist = Math.sqrt(cx*cx + cy*cy);
const buckets = new Map();
for (let y = 0; y < h; y++){
for (let x = 0; x < w; x++){
const i = (y * w + x) * 4;
const a = data[i+3];
if (a < 200) continue;
const r = data[i], g = data[i+1], b = data[i+2];
const max = Math.max(r,g,b), min = Math.min(r,g,b);
const sat = max - min;
if (max > 230 && sat < 30) continue; // near-white / pale background
if (max < 40) continue; // near-black
if (sat < 35) continue; // grayscale = ombres / background
// Center weight : pet = centre, ignore les bords (background)
const dist = Math.sqrt((x-cx)*(x-cx) + (y-cy)*(y-cy));
const cw = Math.max(0.15, 1 - (dist / maxDist) * 0.9);
// Bucket plus fin (16 par canal au lieu de 8)
const key = (Math.floor(r/16) << 12) | (Math.floor(g/16) << 6) | Math.floor(b/16);
const cur = buckets.get(key) || { r:0, g:0, b:0, n:0 };
cur.r += r * cw; cur.g += g * cw; cur.b += b * cw; cur.n += cw;
buckets.set(key, cur);
}
}
if (!buckets.size) return null;
const entries = [...buckets.values()].sort((a,b) => b.n - a.n).slice(0, 8);
let best = null, bestScore = 0;
entries.forEach(b => {
const r = b.r/b.n, g = b.g/b.n, bb = b.b/b.n;
const sat = Math.max(r,g,bb) - Math.min(r,g,bb);
// Score : count × saturation² (favorise très fort la saturation)
const score = b.n * (sat * sat / 100 + 10);
if (score > bestScore){
bestScore = score;
best = { r: Math.round(r), g: Math.round(g), b: Math.round(bb) };
}
});
return best;
}
function extractPetImageColor(sci){
return new Promise(resolve => {
if (!sci) return resolve(null);
const cacheKey = 'jb_np_petcolor_' + sci;
try {
const cached = JSON.parse(localStorage.getItem(cacheKey) || 'null');
if (cached) return resolve(cached);
} catch {}
const img = new Image();
img.crossOrigin = 'anonymous';
let done = false;
const finish = (rgb) => {
if (done) return;
done = true;
if (rgb){
try { localStorage.setItem(cacheKey, JSON.stringify(rgb)); } catch {}
}
resolve(rgb);
};
img.onload = () => {
try {
const SIZE = 100;
const c = document.createElement('canvas');
c.width = SIZE; c.height = SIZE;
const ctx = c.getContext('2d');
ctx.drawImage(img, 0, 0, SIZE, SIZE);
const data = ctx.getImageData(0, 0, SIZE, SIZE).data;
finish(pickDominantColor(data, SIZE, SIZE));
} catch { finish(null); /* canvas CORS-tainted */ }
};
img.onerror = () => finish(null);
setTimeout(() => finish(null), 5000);
img.src = `https://pets.neopets.com/cp/${sci}/1/1.png`;
});
}
/* Applique d'abord un thème de secours basé sur la Colour name, puis
upgrade async au thème basé sur la couleur dominante de l'image. */
function applyPetTheme(petDiv, colour, sci){
if (petDiv.dataset.jbThemed === '1' && !sci) return;
// Les assets Neopets (nameplate / bg-top / bg-bottom) sont posés en CSS
// vars sur :root par applyGlobalNeoTheme() — global, pas per-pet.
// 1) Fallback synchrone : couleur primary/secondary du pet (par Colour name)
setThemeVars(petDiv, getColourTheme(colour));
petDiv.dataset.jbThemed = '1';
// 2) Upgrade async via image dominante (si sci dispo et pas déjà fait)
if (sci && petDiv.dataset.jbImgThemed !== '1'){
petDiv.dataset.jbImgThemed = 'pending';
extractPetImageColor(sci).then(rgb => {
const t = themeFromRgb(rgb);
if (t){
setThemeVars(petDiv, t);
petDiv.dataset.jbImgThemed = '1';
} else {
petDiv.dataset.jbImgThemed = 'failed';
}
});
}
}
/* =========================
CSS — revamp page + widget stats
========================= */
const CSS = `
/* ============== Font Neopets ============== */
@font-face {
font-family: 'CafeteriaBlack';
src: url('https://images.neopets.com/js/fonts/cafeteria-black.otf') format('opentype');
font-display: swap;
}
/* ============== KILL parasites ============== */
td.content > p:first-of-type,
td.content > p:nth-of-type(2) { display:none !important; }
td.content > div[style*="float: right"][style*="width: 310px"] { display:none !important; }
td.content > br{ display:none !important; }
/* ============== Sidebar ============== */
td.sidebar { padding:0 16px 0 0 !important; vertical-align:top !important; }
.sidebarModule{
background:#fff !important;
border:1px solid #d4d4d4 !important;
border-radius:8px !important;
overflow:hidden !important;
box-shadow:0 1px 3px rgba(0,0,0,.08) !important;
margin-bottom:10px !important;
}
.sidebarModule .sidebarTable{ width:100% !important; }
.sidebarModule .sidebarHeader{
background:linear-gradient(180deg, #555 0%, #383838 100%) !important;
color:#fff !important;
padding:7px 12px !important;
font:800 11px/1.2 'CafeteriaBlack','Trebuchet MS',Verdana,sans-serif !important;
text-transform:uppercase; letter-spacing:.06em;
border-bottom:2px solid #1f1f1f !important;
text-shadow:1px 1px 0 rgba(0,0,0,.45);
}
.sidebarModule .sidebarHeader a,
.sidebarModule .sidebarHeader b{
color:#fff !important; text-decoration:none !important;
}
.sidebarModule td{
background:transparent !important;
color:#2d2d2d !important;
padding:6px 12px !important;
border:none !important;
}
.sidebarModule .activePet{ padding:6px !important; text-align:center !important; }
.sidebarModule .activePet img{
border-radius:6px !important;
border:2px solid #e5e5e5 !important;
max-width:100% !important; height:auto !important;
background:#fafafa !important;
}
.sidebarModule .activePet a{ color:#2d2d2d !important; }
.sidebarModule .activePetInfo table{ margin:0 auto !important; }
.sidebarModule .activePetInfo td{
font-size:11px !important;
padding:2px 4px !important;
}
.sidebarModule .activePetInfo td:first-child{
color:#888 !important;
text-align:right !important;
}
.sidebarModule .activePetInfo td:nth-child(2){
color:#2d2d2d !important;
font-weight:700 !important;
}
.sidebarModule font[color="yellow"] b{ color:#d4a900 !important; }
.sidebarModule font[color="green"] b{ color:#5ca817 !important; }
.sidebarModule font[color="red"] b{ color:#c1121f !important; }
.sidebarModule .neofriend{
padding:10px 12px !important;
color:#666 !important;
font-size:11px !important;
text-align:center !important;
}
.sidebarModule input[type="text"]{
background:#fff !important;
border:1px solid #c8c8c8 !important;
color:#2d2d2d !important;
border-radius:4px !important;
padding:5px 8px !important;
font-size:12px !important;
}
.sidebarModule input[type="submit"]{
background:linear-gradient(180deg, #6b6b6b 0%, #4a4a4a 100%) !important;
color:#fff !important;
border:1px solid #2d2d2d !important;
border-radius:14px !important;
padding:5px 14px !important;
font:800 12px/1 'CafeteriaBlack','Trebuchet MS',sans-serif !important;
text-shadow:1px 1px 0 rgba(0,0,0,.4) !important;
cursor:pointer !important;
margin-top:4px !important;
box-shadow:inset 0 1px 0 rgba(255,255,255,.2), 0 1px 2px rgba(0,0,0,.18) !important;
}
.sidebarModule input[type="submit"]:hover{
background:linear-gradient(180deg, #b8a0d8 0%, #8b6fbb 100%) !important;
border-color:#5d3a8f !important;
}
.sidebarModule input[type="submit"]:hover{ filter:brightness(1.05); }
/* ============== Pet nav (top thumbnails) — neutre, sans BG ============== */
#nav{
margin:14px auto !important;
float:none !important;
display:flex !important;
gap:10px !important;
justify-content:center !important;
flex-wrap:wrap !important;
padding:10px 14px !important;
border:none !important;
box-shadow:none !important;
background:transparent !important;
position:relative !important;
z-index:2 !important;
}
#nav tbody, #nav tr, #nav td{
all:revert;
display:block !important;
background:transparent !important;
}
#nav tr{ display:flex !important; gap:10px !important; flex-wrap:wrap !important; justify-content:center !important; }
#nav td{ padding:0 !important; position:relative !important; }
#nav td .pet_toggler img{
width:56px !important; height:56px !important;
border-radius:8px !important;
border:2px solid #d4d4d4 !important;
transition:transform .14s ease, border-color .14s ease, box-shadow .14s ease !important;
cursor:pointer !important;
background-size:cover !important;
background-position:center !important;
background-color:#fafafa !important;
}
#nav td.active_pet .pet_toggler img{
border-color:#a07cd8 !important;
box-shadow:0 0 0 2px rgba(160,124,216,.35) !important;
}
#nav td .pet_toggler:hover img{
transform:translateY(-2px) !important;
border-color:#666 !important;
}
#nav td .pet_menu_launcher{
display:block !important;
width:14px !important; height:14px !important;
position:absolute !important;
right:-3px; bottom:-3px;
background:#fff !important;
border:1px solid #a07cd8 !important;
border-radius:50% !important;
cursor:pointer !important;
transition:transform .14s !important;
}
#nav td .pet_menu_launcher::before{
content:'⋯';
display:flex; align-items:center; justify-content:center;
width:100%; height:100%;
color:#a07cd8; font-size:11px; font-weight:900; line-height:1;
}
#nav td .pet_menu_launcher:hover{ background:#a07cd8 !important; }
#nav td .pet_menu_launcher:hover::before{ color:#fff; }
#nav td ul.pet_menu_hide{ display:none !important; }
#nav td ul[id$="_menu"]{
position:absolute !important;
top:62px !important; left:50% !important;
transform:translateX(-50%) !important;
background:#fff !important;
border:1px solid #d4d4d4 !important;
border-radius:6px !important;
padding:4px !important;
list-style:none !important;
margin:0 !important;
min-width:170px !important;
z-index:1000 !important;
box-shadow:0 4px 12px rgba(0,0,0,.16) !important;
}
#nav td ul[id$="_menu"] li{
list-style:none !important;
padding:5px 9px !important;
border-radius:4px !important;
font:600 12px/1.3 'Trebuchet MS',Verdana,sans-serif !important;
color:#444 !important;
cursor:pointer !important;
}
#nav td ul[id$="_menu"] li:hover{
background:#FFD800 !important;
color:#3a2c00 !important;
}
#nav td ul[id$="_menu"] li a{
color:inherit !important;
text-decoration:none !important;
display:block !important;
}
#nav td ul[id$="_menu"] li span.pointer{ display:none !important; }
/* ============== Pet card (contentModule) — fond neutre, zones internes stylées ============== */
div[id$="_details"].contentModule{
background-color:#fafafa !important;
border:3px solid #000 !important;
border-radius:12px !important;
box-shadow:0 6px 20px rgba(0,0,0,.18),
inset 0 0 0 1px rgba(255,255,255,.5) !important;
overflow:hidden !important;
margin:28px 0 !important; /* + d'espace entre les cartes de pets */
position:relative !important;
}
.contentModuleTable{
width:100% !important;
border-collapse:separate !important;
border-spacing:0 !important;
}
/* ============== Bannière (header) ==============
2 BG layered :
- nav-pattern-bottom collé en haut (strip horizontal, repeat-x)
- pattern-header en image unique, NON répété verticalement (1× rempli en height)
Tout le texte en noir. Bord noir bas pour cohérence carte. */
.contentModule th.contentModuleHeader,
.contentModule th.contentModuleHeaderAlt{
background-color:#fff !important;
background-image:var(--neo-pattern-header, none) !important;
background-repeat:repeat-x !important;
background-position:center center !important;
background-size:auto 100% !important;
color:#000 !important;
padding:18px 380px 18px 22px !important; /* HP@right:150 (210 wide) + switcher@right:8 (~100 wide) + buffer */
font:800 28px/1.1 'CafeteriaBlack','Trebuchet MS',Verdana,sans-serif !important;
text-align:left !important;
border:none !important;
border-bottom:3px solid #000 !important;
display:flex !important;
flex-direction:row !important;
flex-wrap:nowrap !important;
align-items:center !important;
gap:14px !important;
letter-spacing:.03em;
text-shadow:none !important;
position:relative !important;
min-height:0;
white-space:nowrap !important;
overflow:hidden !important;
}
/* Nom du pet en grand */
.contentModule th a{
font-size:30px !important;
letter-spacing:.03em;
flex-shrink:0;
}
.contentModule th a{
color:#000 !important;
text-decoration:none !important;
}
.contentModule th a:hover{ color:#000 !important; text-decoration:underline !important; }
/* === Override texte BLANC pour les thèmes au pattern-header sombre ===
Marche pour le thème global ([data-jb-neo-theme] sur <html>) ET pour
l'override per-pet (data-jb-neo-theme sur .contentModule). */
.contentModule[data-jb-neo-theme="hauntedwoods"] th,
.contentModule[data-jb-neo-theme="hauntedwoods"] th a,
.contentModule[data-jb-neo-theme="hauntedwoods"] .jbHeaderHp .hpLabel,
.contentModule[data-jb-neo-theme="constellations"] th,
.contentModule[data-jb-neo-theme="constellations"] th a,
.contentModule[data-jb-neo-theme="constellations"] .jbHeaderHp .hpLabel,
.contentModule[data-jb-neo-theme="destroyedfestival"] th,
.contentModule[data-jb-neo-theme="destroyedfestival"] th a,
.contentModule[data-jb-neo-theme="destroyedfestival"] .jbHeaderHp .hpLabel,
.contentModule[data-jb-neo-theme="grey"] th,
.contentModule[data-jb-neo-theme="grey"] th a,
.contentModule[data-jb-neo-theme="grey"] .jbHeaderHp .hpLabel,
.contentModule[data-jb-neo-theme="neggsneovia"] th,
.contentModule[data-jb-neo-theme="neggsneovia"] th a,
.contentModule[data-jb-neo-theme="neggsneovia"] .jbHeaderHp .hpLabel,
.contentModule[data-jb-neo-theme="tyrannia"] th,
.contentModule[data-jb-neo-theme="tyrannia"] th a,
.contentModule[data-jb-neo-theme="tyrannia"] .jbHeaderHp .hpLabel,
.contentModule[data-jb-neo-theme="winterholiday"] th,
.contentModule[data-jb-neo-theme="winterholiday"] th a,
.contentModule[data-jb-neo-theme="winterholiday"] .jbHeaderHp .hpLabel,
.contentModule[data-jb-neo-theme="newyears"] th,
.contentModule[data-jb-neo-theme="newyears"] th a,
.contentModule[data-jb-neo-theme="newyears"] .jbHeaderHp .hpLabel,
.contentModule[data-jb-neo-theme="tistheseason"] th,
.contentModule[data-jb-neo-theme="tistheseason"] th a,
.contentModule[data-jb-neo-theme="tistheseason"] .jbHeaderHp .hpLabel{
color:#fff !important;
text-shadow:1px 1px 0 rgba(0,0,0,.55) !important;
}
.contentModule[data-jb-neo-theme="hauntedwoods"] .jbHeaderAvatar,
.contentModule[data-jb-neo-theme="constellations"] .jbHeaderAvatar,
.contentModule[data-jb-neo-theme="destroyedfestival"] .jbHeaderAvatar,
.contentModule[data-jb-neo-theme="grey"] .jbHeaderAvatar,
.contentModule[data-jb-neo-theme="neggsneovia"] .jbHeaderAvatar,
.contentModule[data-jb-neo-theme="tyrannia"] .jbHeaderAvatar,
.contentModule[data-jb-neo-theme="winterholiday"] .jbHeaderAvatar,
.contentModule[data-jb-neo-theme="newyears"] .jbHeaderAvatar,
.contentModule[data-jb-neo-theme="tistheseason"] .jbHeaderAvatar{
filter:drop-shadow(0 1px 1px rgba(0,0,0,.6));
}
/* Icône mypets du thème — flex item normal, centré vertical avec le nom */
.jbHeaderAvatar{
position:static !important;
display:inline-block !important;
width:52px; height:52px;
background-image:var(--neo-mypets-icon, url('https://images.neopets.com/themes/h5/basic/images/v3/mypets-icon.svg'));
background-size:contain;
background-repeat:no-repeat;
background-position:center;
flex-shrink:0;
flex-grow:0;
pointer-events:none;
vertical-align:middle;
margin:0;
transform:none !important;
left:auto !important;
top:auto !important;
}
/* HP bar Pokemon-style — positionné en absolute en haut à droite (sous le strip nav)
pour libérer le flex flow horizontal (nom / étiquette / petpets en colonnes).
Décalé à gauche pour respirer entre le contenu et le switcher. */
.jbHeaderHp{
position:absolute;
right:150px;
top:50%;
transform:translateY(-50%);
display:flex; flex-direction:column;
align-items:flex-end;
gap:3px;
width:210px;
}
.jbHeaderHp .hpLabel{
font:800 13px/1 'CafeteriaBlack','Trebuchet MS',Verdana,sans-serif;
color:#000;
text-shadow:none;
letter-spacing:.04em;
}
/* Le MAX (total HP) est mis en avant : plus gros et bold.
Le CUR (HP restants) et le séparateur sont fadés → l'attention va
sur la valeur totale. */
.jbHeaderHp .hpLabel .hpMax{
font-size:22px;
vertical-align:baseline;
font-weight:800;
}
.jbHeaderHp .hpLabel .hpCur{
font-size:14px;
opacity:.55;
vertical-align:baseline;
font-weight:700;
}
.jbHeaderHp .hpLabel .hpSep{
opacity:.45;
vertical-align:baseline;
}
.jbHeaderHp .hpBar{
width:100%;
height:9px;
background:rgba(0,0,0,.4);
border-radius:5px;
overflow:hidden;
border:1px solid rgba(0,0,0,.5);
box-shadow:inset 0 1px 2px rgba(0,0,0,.4);
}
.jbHeaderHp .hpFill{
height:100%;
background:linear-gradient(180deg, #8de24a 0%, #5ca817 100%);
transition:width .3s ease;
}
.jbHeaderHp.warn .hpFill{ background:linear-gradient(180deg, #ffd84a 0%, #f5a623 100%); }
.jbHeaderHp.danger .hpFill{ background:linear-gradient(180deg, #ff6b6b 0%, #c1121f 100%); }
/* Étiquette à côté du nom (même ligne) : cadre semi-transparent simple */
.jbHeaderChips{
display:inline-flex !important;
align-items:center;
justify-content:flex-start;
gap:10px;
padding:5px 14px !important;
margin:0;
min-height:0;
width:auto;
flex-shrink:0;
background:rgba(255,255,255,.55) !important;
border:1.5px solid #000 !important;
border-radius:14px !important;
box-shadow:0 1px 2px rgba(0,0,0,.18) !important;
}
.jbChip{
display:inline-block;
padding:0;
background:transparent;
border:none;
border-radius:0;
font:700 11px/1.4 'Trebuchet MS',Verdana,sans-serif;
color:#000;
text-transform:uppercase;
letter-spacing:.06em;
vertical-align:middle;
text-shadow:none;
box-shadow:none;
}
.jbChip + .jbChip{
padding-left:10px;
border-left:1px solid rgba(0,0,0,.45);
}
.jbHeaderSub{
align-self:flex-start;
font:400 11px/1.3 'Trebuchet MS',Verdana,sans-serif;
color:#000;
letter-spacing:.02em;
margin-top:2px;
text-shadow:none;
}
/* Petpet / Petpetpet en sous-ligne dans la bannière (texte noir) */
.jbHeaderPetpets{
align-self:flex-start;
display:flex;
flex-wrap:wrap;
gap:8px;
margin-top:2px;
font:700 11px/1.2 'Trebuchet MS',Verdana,sans-serif;
color:#000;
text-shadow:none;
}
.jbHeaderPetpets .pp{
display:inline-flex;
align-items:center;
gap:6px;
padding:2px 10px 2px 4px;
background:rgba(255,255,255,.78);
border:1px solid #000;
border-radius:14px;
}
.jbHeaderPetpets .pp .ppLbl{
font:700 8px/1 'Trebuchet MS',Verdana,sans-serif;
text-transform:uppercase;
letter-spacing:.08em;
opacity:.65;
color:#000;
}
.jbHeaderPetpets .pp img{
width:22px; height:22px;
border-radius:50%;
background:#fff;
padding:1px;
border:1px solid #000;
flex-shrink:0;
}
/* === Zone info générale : 3 colonnes égales avec BG Background Top du thème === */
.contentModuleTable > tbody > tr > td{
padding:18px !important;
vertical-align:top !important;
border:none !important;
border-bottom:3px solid #000 !important;
display:grid !important;
gap:18px !important; /* aligné sur le padding du td = espacements harmonisés (padding extérieur = gap inter-cols) */
grid-template-columns:repeat(3, minmax(0, 1fr)) !important;
grid-template-areas:"image info notices" !important;
grid-auto-rows:1fr !important;
align-items:stretch !important;
font-family: 'Trebuchet MS', Verdana, sans-serif !important;
color:#000 !important;
background-color:#f5efe1 !important;
background-image:var(--neo-hp-bg-top, none) !important;
background-repeat:repeat-x !important;
background-position:top center !important;
background-size:auto 100% !important;
}
.pet_info{
grid-area:info;
float:none !important;
width:auto !important;
height:100% !important;
margin:0 !important;
align-self:stretch !important;
min-width:0;
display:flex !important;
flex-direction:column !important;
gap:0 !important;
}
/* Reset au cas où le margin-bottom legacy traîne encore */
.pet_info > *{ margin-bottom:0 !important; }
/* Col 1 : image du pet — cadre noir + bords arrondis, cover pour remplir
sans marges blanches L/R (la scène du pet remplit toute la case) */
.pet_image{
grid-area:image;
float:none !important;
width:100% !important;
max-width:100% !important;
min-height:0 !important;
aspect-ratio:1 / 1 !important; /* carrée dans les 2 modes (profil + log) */
background-size:cover !important;
background-repeat:no-repeat !important;
background-position:center !important;
height:auto !important;
margin:0 !important;
position:relative !important;
overflow:hidden !important;
border-radius:8px !important;
background-color:transparent !important;
border:2px solid #000 !important;
box-shadow:none !important;
align-self:start !important; /* override le stretch hérité — sinon aspect-ratio ignoré */
box-sizing:border-box !important;
}
/* Col 3 : notices — contour noir, fond blanc semi-transparent.
Flex column pour que le bloc Total gain (dernier child) soit ancré en
bas via margin-top:auto → même position visuelle qu'en mode log (où
le gain est aussi en bas de la col 3, sous la carte stats). */
.pet_more{
grid-area:notices;
align-self:stretch !important;
display:flex !important;
flex-direction:column !important;
width:100% !important;
max-width:100% !important;
box-sizing:border-box !important;
height:auto !important;
min-height:300px !important;
margin:0 !important;
padding:14px 14px !important;
background:rgba(255,255,255,.85) !important;
border:2px solid #000 !important;
border-radius:8px !important;
box-shadow:none !important;
color:#000 !important;
float:none !important;
clear:none !important;
overflow:hidden !important;
position:relative !important;
}
/* Bloc gain en bas de pet_more grâce à margin-top:auto */
.pet_more > .jbStatsGain.jbGainInNotices{
margin-top:auto !important;
}
/* Bloc gain : même typo / même structure visuelle dans les 2 modes
(parent = .pet_more en profile, ou .jbStatsSide en log). Séparateur
dotted au-dessus pour cohérence avec les notices. */
.jbStatsGain.jbGainInNotices{
position:static !important;
display:flex !important;
flex-direction:column !important;
align-items:center !important;
margin:0 !important;
padding:10px 0 6px !important;
border-top:1px dotted rgba(0,0,0,.4) !important;
}
.jbStatsGain.jbGainInNotices .gainBreak{
order:1 !important; /* texte AVANT le bouton */
width:100% !important;
text-align:left !important;
font:400 12px/1.45 'Trebuchet MS',Verdana,sans-serif !important;
color:#000 !important;
opacity:1 !important;
margin:0 0 8px 0 !important;
padding:0 !important;
}
.jbStatsGain.jbGainInNotices .gainBreak b{
font-weight:700 !important;
}
.jbStatsGain.jbGainInNotices .gainBig{
order:2 !important;
margin:2px 0 0 0 !important;
}
/* Si pas de notices, on laisse la colonne en placeholder pour garder
l'équilibre visuel 3 colonnes (pas de display:none) */
.pet_more:has(.pet_notices:empty)::before{
content:'Aucune notice';
display:block;
text-align:center;
color:#aaa;
font-style:italic;
font-size:11px;
padding:18px 0;
}
.pet_more:has(.pet_notices:empty) h3{ display:none !important; }
/* Garde une taille mini correcte pour images dans les notices */
.pet_notices .sf img{ max-width:100% !important; height:auto !important; }
.pet_more h3{
color:#000 !important; opacity:.7 !important;
font:800 10px/1.2 'CafeteriaBlack','Trebuchet MS',Verdana,sans-serif !important;
text-transform:uppercase; letter-spacing:.08em;
margin:0 !important;
padding:0 0 2px !important;
background:transparent !important;
display:block !important;
border:none !important;
text-align:left !important;
}
.pet_notices{
display:block !important;
width:auto !important;
height:auto !important;
min-height:0 !important;
color:#000 !important;
font-size:12px !important;
line-height:1.4 !important;
background:transparent !important;
}
/* Chaque .sf à l'intérieur = une notice. On vire toute image de fond
pour ne garder que le texte (harmonisation). Séparateur dotted top sur
chaque item, y compris le premier (= ligne juste sous le titre Notices). */
.pet_notices .sf{
display:block !important;
width:auto !important;
min-height:0 !important;
background:none !important;
background-image:none !important;
background-color:transparent !important;
padding:10px 0 !important;
margin:0 !important;
color:#000 !important;
font:400 12px/1.45 'Trebuchet MS',Verdana,sans-serif !important;
border-top:1px dotted rgba(0,0,0,.4) !important;
box-sizing:border-box !important;
}
/* Le séparateur du premier .sf est conservé (= ligne juste sous le titre Notices) */
/* Au cas où une img inline traîne dans une notice : neutralisée */
.pet_notices .sf img{ display:none !important; }
.pet_notices .sf b{ color:#000 !important; }
.pet_notices .sf a{ color:#000 !important; font-weight:700 !important; text-decoration:underline !important; }
.pet_notices .sf a:hover{ color:#000 !important; }
.pet_notices .sf font[color="green"]{ color:#000 !important; }
.pet_notices .sf font[color="red"]{ color:#000 !important; }
/* Le spacer entre 2 notices (généré par Neopets) on le neutralise */
.pet_notice_spacer{ display:none !important; }
/* Le tableau original est complètement caché — on reconstruit en divs */
table.pet_stats{ display:none !important; }
/* ============== Stats grid (col 2) : liste verticale qui remplit toute la
hauteur de la colonne (= hauteur pet image / notices). ============== */
.jbStatsGrid{
display:flex !important;
flex-direction:column !important;
gap:8px !important;
width:100% !important;
height:100% !important;
flex:1 1 auto !important;
box-sizing:border-box !important;
margin:0 !important;
}
/* Stat card : flex:1 sur chaque pour distribuer l'espace vertical équitablement */
.jbStatCard{
display:flex;
flex-direction:row;
flex-wrap:wrap;
align-items:center;
gap:6px;
background:rgba(255,255,255,.85);
border:2px solid #000;
border-radius:6px;
padding:8px 14px;
min-width:0;
flex:1 1 0;
box-sizing:border-box;
box-shadow:none;
color:#000;
line-height:1.3;
}
.jbStatCard .jbStatLabel{
color:#000;
font:800 11px/1.2 'CafeteriaBlack','Trebuchet MS',Verdana,sans-serif;
text-transform:uppercase; letter-spacing:.05em;
opacity:.8;
flex-shrink:0;
}
.jbStatCard .jbStatLabel::after{ content:' :'; }
.jbStatCard .jbStatValue{
color:#000;
font:700 12px/1.3 'Trebuchet MS',Verdana,sans-serif;
word-break:break-word;
overflow-wrap:anywhere;
flex:1 1 auto;
min-width:0;
}
/* Images inline (petpet etc.) à taille raisonnable */
.jbStatCard .jbStatValue img{
vertical-align:middle;
max-height:24px;
width:auto;
margin-right:3px;
}
/* Pas de saut de ligne intempestif sur les <br> Neopets dans la valeur */
.jbStatCard .jbStatValue br{ display:none !important; }
.jbStatCard .jbStatValue a{ color:#000; text-decoration:underline; }
.jbStatCard .jbStatValue a:hover{ color:#000; }
.jbStatCard font[color="yellow"] b,
.jbStatCard font[color="green"] b,
.jbStatCard font[color="red"] b{ color:#000; }
/* Ancien wrapper (visit petpage) — gardé via .jbVisitOnly. Pas d'affichage par défaut */
.jbPetpetCard{ display:none !important; }
/* === Carte dédiée Petpet / Petpetpet : GROSSE case sous Fishing === */
.jbPetpetBigCard{
display:flex !important;
flex-direction:column !important;
gap:14px !important;
width:100% !important;
box-sizing:border-box !important;
background:rgba(255,255,255,.92) !important;
border:2px solid #000 !important;
border-radius:10px !important;
padding:16px 18px !important;
margin:0 !important;
}
.jbPetpetBigCard .jbPetpetRow{
display:flex !important;
align-items:center !important;
gap:14px !important;
width:100% !important;
min-height:0 !important;
overflow:hidden !important;
}
.jbPetpetBigCard .jbPetpetRow + .jbPetpetRow{
border-top:1px dashed rgba(0,0,0,.35) !important;
padding-top:14px !important;
}
/* Image petpet : DIV (pas img) avec background-image → taille 100%
contrôlée par le CSS, aucune chance de déborder car la div n'a pas
de dimension intrinsèque comme un img. */
.jbPetpetBigCard .jbPetpetImg{
width:100px !important;
height:100px !important;
min-width:100px !important;
min-height:100px !important;
max-width:100px !important;
max-height:100px !important;
flex-shrink:0 !important;
flex-grow:0 !important;
background-color:#fff !important;
background-repeat:no-repeat !important;
background-position:center !important;
background-size:contain !important;
border:2px solid #000 !important;
border-radius:10px !important;
padding:6px !important;
overflow:hidden !important;
box-sizing:border-box !important;
box-shadow:0 1px 3px rgba(0,0,0,.15) !important;
/* Petit "inset" simulé : background-origin content-box pour que l'image
respecte le padding (sinon elle passe sous le padding). */
background-origin:content-box !important;
background-clip:content-box !important;
}
.jbPetpetBigCard .jbPetpetNoImg{
display:flex !important;
align-items:center !important;
justify-content:center !important;
background-image:none !important;
background-color:#f0f0f0 !important;
color:#000;
opacity:.5;
font:800 40px/1 'CafeteriaBlack','Trebuchet MS',sans-serif;
}
.jbPetpetBigCard .jbPetpetMeta{
display:flex;
flex-direction:column;
gap:4px;
min-width:0;
flex:1 1 auto;
}
.jbPetpetBigCard .jbPetpetLbl{
font:800 12px/1.2 'CafeteriaBlack','Trebuchet MS',Verdana,sans-serif;
text-transform:uppercase;
letter-spacing:.08em;
color:#000;
opacity:.7;
}
.jbPetpetBigCard .jbPetpetName{
font:800 19px/1.3 'CafeteriaBlack','Trebuchet MS',Verdana,sans-serif;
color:#000;
word-break:break-word;
letter-spacing:.02em;
}
.jbPetpetCard.jbVisitOnly{
display:flex !important;
flex-wrap:wrap !important;
align-items:center !important;
gap:10px !important;
width:100% !important;
max-width:100% !important;
box-sizing:border-box !important;
background:rgba(255,255,255,.85) !important;
border:2px solid #000 !important;
border-radius:6px !important;
padding:10px 14px !important;
margin:0 !important;
text-align:left !important;
color:#000 !important;
font:700 13px/1.4 'Trebuchet MS',Verdana,sans-serif !important;
flex:1 1 0 !important;
}
.jbPetpetCard.jbVisitOnly b{ font-weight:800 !important; }
.jbPetpetCard.jbVisitOnly div,
.jbPetpetCard.jbVisitOnly b{
display:inline !important;
margin:0 !important;
}
.jbPetpetCard.jbVisitOnly br{ display:none !important; }
.jbPetpetCard.jbVisitOnly img{
vertical-align:middle !important;
width:auto !important;
height:auto !important;
max-width:none !important;
max-height:none !important;
margin:0 4px 0 0 !important;
display:inline-block !important;
padding:0 !important;
border:none !important;
background:transparent !important;
}
.jbPetpetCard a{ color:#000 !important; text-decoration:underline !important; }
.jbPetpetCard a:hover{ color:#000 !important; }
/* Neutralise <div style="width:240px; overflow:hidden"> et <b> legacy */
.jbPetpetCard b,
.jbPetpetCard div{
display:block !important;
width:auto !important;
max-width:100% !important;
overflow:visible !important;
margin:0 auto !important;
padding:0 !important;
font-weight:normal !important;
text-align:center !important;
float:none !important;
position:static !important;
background:transparent !important;
}
.jbPetpetCard img{
width:54px !important; height:54px !important;
margin:2px !important;
border-radius:4px !important;
background:#fff !important;
padding:3px !important;
border:1px solid #e5e5e5 !important;
vertical-align:middle !important;
display:inline-block !important;
}
/* Date de naissance (inline avec parenthèses, dans la valeur Age) */
.jbBirthday{
display:inline;
font:400 11px/1.3 'Trebuchet MS',Verdana,sans-serif;
color:#000; opacity:.7;
margin:0 0 0 4px;
font-style:italic;
}
/* ============== HP bar ============== */
.jbHpBar{
margin:0 0 8px !important;
padding:7px 10px !important;
background:rgba(255,255,255,.78) !important;
border-radius:5px !important;
border:1px solid rgba(0,0,0,.08) !important;
border-left:3px solid #b59ad6 !important;
}
.jbHpBar .jbHpHead{
display:flex; justify-content:space-between; align-items:baseline;
font:700 9px/1.2 'Trebuchet MS',Verdana,sans-serif; color:#888;
text-transform:uppercase; letter-spacing:.06em;
margin-bottom:5px;
}
.jbHpBar .jbHpHead b{ color:#2d2d2d; font-size:13px; }
.jbHpBar .jbHpTrack{
height:10px; border-radius:5px;
background:#e0e0e0;
overflow:hidden;
border:1px solid #d0d0d0;
}
.jbHpBar .jbHpFill{
height:100%;
background:linear-gradient(180deg, #8de24a 0%, #5ca817 100%);
transition:width .3s ease;
}
.jbHpBar.warn .jbHpFill{ background:linear-gradient(180deg, #ffd84a 0%, #f5a623 100%); }
.jbHpBar.danger .jbHpFill{ background:linear-gradient(180deg, #ff6b6b 0%, #c1121f 100%); }
/* ============== Actions : footer centré avec BG footer-pattern (pas de gradient coloré) ============== */
div[id$="_details"].contentModule > .jbPetActions{
display:flex !important;
flex-wrap:wrap !important;
justify-content:center !important;
align-items:center !important;
gap:8px !important;
margin:0 !important;
padding:10px 20px !important; /* slim */
border:none !important; /* le td au-dessus a déjà border-bottom:3px, pas besoin de double */
background-color:#2d2d2d !important;
background-image:var(--neo-footer-pattern, none) !important;
background-repeat:repeat !important;
background-position:center !important;
background-size:auto !important;
grid-area:auto !important;
}
.jbPetActions a{
display:inline-flex; align-items:center; gap:6px;
padding:7px 16px;
background:#fff;
border:2px solid #000;
border-bottom-width:3px;
border-radius:16px;
color:#000 !important;
text-decoration:none !important;
font:800 11px/1.2 'CafeteriaBlack','Trebuchet MS',Verdana,sans-serif;
letter-spacing:.02em;
transition:all .12s ease;
box-shadow:0 2px 0 rgba(0,0,0,.30), inset 0 1px 0 rgba(255,255,255,.6);
}
.jbPetActions a:hover{
background:#000;
color:#fff !important;
text-shadow:none;
transform:translateY(-1px);
border-bottom-width:2px;
box-shadow:0 3px 6px rgba(0,0,0,.35), inset 0 1px 0 rgba(255,255,255,.2);
}
.jbPetActions a:active{
transform:translateY(1px);
border-bottom-width:1px;
box-shadow:0 1px 2px rgba(0,0,0,.15);
}
.jbPetActions a.disabled{
opacity:.5; pointer-events:none;
background:linear-gradient(180deg, #b8b8b8 0%, #999 100%);
color:#fff !important;
filter:saturate(0);
}
/* ============== Boutons "carousel arrow" left/right : cycle thème per-pet
— en bas gauche / bas droit de la carte, par-dessus le
footer actions. Flèches agrandies mais bounding box mini
pour ne pas casser le layout des actions. ============== */
.jbThemeCarousel{
position:absolute !important;
bottom:6px !important;
width:56px !important;
height:56px !important;
padding:0 !important;
border:none !important;
background-color:transparent !important;
background-image:var(--neo-carousel-arrow, none) !important;
background-repeat:no-repeat !important;
background-position:center !important;
background-size:contain !important;
cursor:pointer !important;
z-index:5 !important;
filter:drop-shadow(0 2px 3px rgba(0,0,0,.6));
transition:transform .12s ease, filter .12s ease;
appearance:none !important;
}
.jbThemeCarouselRight{ right:8px !important; }
/* Flèche left = même SVG flippé horizontalement */
.jbThemeCarouselLeft{
left:8px !important;
transform:scaleX(-1);
}
.jbThemeCarouselRight:hover{
transform:translateX(3px) scale(1.08);
}
.jbThemeCarouselLeft:hover{
transform:scaleX(-1) translateX(3px) scale(1.08);
}
.jbThemeCarousel:hover{ filter:drop-shadow(0 3px 5px rgba(0,0,0,.75)); }
.jbThemeCarouselRight:active{ transform:translateX(3px) scale(.96); }
.jbThemeCarouselLeft:active{ transform:scaleX(-1) translateX(3px) scale(.96); }
.jbThemeCarousel:focus{ outline:none !important; }
/* ============== Heal overlay ============== */
#jb-heal-overlay{
position:absolute !important;
inset:0 !important;
z-index:9999 !important;
display:block !important;
cursor:pointer !important;
border-radius:6px;
overflow:hidden;
}
#jb-heal-overlay::after{
content:'Need Cooling Ointment →';
position:absolute;
bottom:8px; left:50%;
transform:translateX(-50%);
background:#c1121f;
color:#fff;
padding:4px 12px;
border-radius:11px;
font:800 11px/1 'CafeteriaBlack','Trebuchet MS',sans-serif;
text-transform:uppercase; letter-spacing:.05em;
white-space:nowrap;
box-shadow:0 2px 6px rgba(0,0,0,.3);
}
#jb-heal-overlay img{
width:100% !important;
height:100% !important;
display:block !important;
object-fit:cover !important;
object-position:50% 0% !important;
}
/* ============== Widget BD stats ==============
Le widget est maintenant un grid item du td parent (span sur toutes
les colonnes en mode profile, OR display:contents en mode log pour
que .jbStatsSide / .jbStatsMain deviennent eux-mêmes grid items du td).
BG = Background Bottom (body-bg-bottom) du thème. */
div[id$="_details"].contentModule .jbStatsWidget{
grid-column:1 / -1;
margin:0 !important;
background-color:#f0eadb !important;
background-image:var(--neo-hp-bg-bot, none) !important;
background-repeat:repeat-x !important;
background-position:bottom center !important;
background-size:auto 100% !important;
border:none !important;
border-radius:0 !important;
box-shadow:none !important;
color:#000;
font:13px/1.3 'Trebuchet MS',Verdana,sans-serif;
overflow:visible;
position:relative;
}
/* Override grid-auto-rows:1fr du td pour permettre la row 2 (widget)
en hauteur naturelle. */
.contentModuleTable > tbody > tr > td{
grid-auto-rows:auto !important;
}
/* ============== View toggle (profile vs log icon) ==============
Position absolute totalement à droite de la bannière (right:8px), la
HP bar est décalée à right:150px → respire entre contenu et switcher.
Hauteur calquée sur la bannière. Actif = fond blanc. */
.jbViewToggle{
position:absolute !important;
right:8px;
top:50%;
transform:translateY(-50%);
display:flex !important;
flex-direction:row;
gap:2px;
align-items:stretch;
padding:4px;
background:rgba(255,255,255,.55) !important;
border:1.5px solid #000 !important;
border-radius:16px !important;
box-shadow:0 1px 2px rgba(0,0,0,.18) !important;
box-sizing:border-box;
height:60px;
}
.jbViewToggle button{
display:inline-flex;
align-items:center;
justify-content:center;
width:44px;
height:100%;
padding:0;
background:transparent;
border:0;
border-radius:12px;
cursor:pointer;
opacity:.45;
transition:all .12s ease;
}
.jbViewToggle button:hover{ opacity:.9; }
.jbViewToggle button.active{
background:#fff;
opacity:1;
box-shadow:0 1px 2px rgba(0,0,0,.25);
}
.jbViewToggle button img{
width:28px; height:28px;
display:block;
pointer-events:none;
}
/* ============== Mode PROFILE : cache complètement le widget BD stats ==============
En profile mode on ne voit QUE image | info | notices.
Le bloc Total gain (.jbStatsGain) a été déplacé dans .pet_more avant ce
masquage → il reste visible dans la col notices. */
.jb-view-profile .jbStatsWidget{ display:none !important; }
/* ============== Mode LOG : image | chart | stats sur une row ==============
3 colonnes STRICTEMENT identiques au mode profile : repeat(3, 1fr).
Même gap (18px), mêmes marges → la carte totale (td) a la MÊME taille
et les MÊMES marges dans les 2 modes.
Le widget, le body ET la side passent en display:contents → c'est la
SUMMARY (.jbStatsSummary) qui devient directement le grid-item "stats"
et le main qui devient grid-item "chart". Le bloc gain est appendé
DANS la summary (cf syncGainLocation) → il hérite naturellement de la
card stats (border, fond .85, padding) comme il le ferait dans
.pet_more en mode profile. */
.jb-view-log .contentModuleTable > tbody > tr > td{
grid-template-columns:repeat(3, minmax(0, 1fr)) !important;
grid-template-areas:"image chart stats" !important;
grid-template-rows:auto !important;
}
.jb-view-log .pet_info,
.jb-view-log .pet_more{ display:none !important; }
.jb-view-log .jbStatsWidget{ display:contents !important; }
.jb-view-log .jbStatsBody{ display:contents !important; }
.jb-view-log .jbStatsSide{ display:contents !important; }
/* La summary devient elle-même le grid-item "stats" (la side passe en
display:contents). align-self:start + min-height:0 + overflow:hidden
laissent la box prendre exactement la hauteur set par JS (= image
height). Le JS dans applyViewMode mesure .pet_image et applique
inline height en px sur .jbStatsSummary et .jbStatsMain. */
/* box-sizing:border-box → le height en px que le JS pose ici inclut
padding + border. Sans ça, summary.padding (10+10) et main.padding
(38+10) + leurs borders s'ajoutent par-dessus, et les cards dépassent
l'image de 24px (summary) ou 52px (main). */
.jb-view-log .jbStatsSummary{
grid-area:stats !important;
align-self:start !important;
min-height:0 !important;
overflow:hidden !important;
box-sizing:border-box !important;
}
.jb-view-log .jbStatsMain{
grid-area:chart !important;
min-width:0 !important;
align-self:start !important;
min-height:0 !important;
box-sizing:border-box !important;
}
/* Condense les rows de stats en log mode pour que les 5 lignes + la
nameplate logent dans la hauteur de la card (= hauteur image).
Profile mode garde sa typo intacte (overrides préfixés .jb-view-log). */
.jb-view-log .jbStatsSummary .stat{
padding:3px 4px !important;
min-height:20px !important;
}
.jb-view-log .jbStatsSummary .stat .lbl{
font-size:9px !important;
}
.jb-view-log .jbStatsSummary .stat .val{
font-size:12px !important;
}
/* En log mode on cache le texte "Today, X gained Lv ±0 · HP +2 · ..."
(.gainBreak) pour libérer de la place — on garde uniquement la nameplate
+N à taille standard (240×84). PAS de margin-top:auto → la nameplate
est juste sous les stats (pas de gros espace vide qui pousse la card
au-delà de l'image). L'espace résiduel se retrouve sous la nameplate. */
.jb-view-log .jbStatsSummary > .jbStatsGain.jbGainInNotices > .gainBreak{
display:none !important;
}
.jb-view-log .jbStatsSummary > .jbStatsGain.jbGainInNotices{
margin-top:0 !important;
padding:8px 0 4px !important;
}
/* Plus de .jbStatsHead — les tabs sont maintenant dans .jbStatsMain top-right.
(Les sélecteurs .jbStatsHead / .jbStatsTitle restent ignorés s'ils existent) */
.jbStatsHead, .jbStatsTitle{ display:none !important; }
/* Tabs : toggle pill positionné en absolute top-right de la zone chart */
.jbStatsMain > .jbStatsTabs{
position:absolute;
top:8px;
right:8px;
z-index:2;
}
.jbStatsTabs{
display:inline-flex;
gap:0;
background:rgba(255,255,255,.85);
border:1.5px solid #000;
border-radius:14px;
padding:2px;
}
.jbStatsTabs button{
padding:3px 12px;
font:700 10px/1.2 'Trebuchet MS',Verdana,sans-serif;
letter-spacing:.06em; text-transform:uppercase;
background:transparent;
color:#000;
opacity:.55;
border:0;
border-radius:12px;
cursor:pointer;
transition:all .12s ease;
}
.jbStatsTabs button:hover{ opacity:1; }
.jbStatsTabs button.active{
background:#000;
color:#fff;
opacity:1;
box-shadow:0 1px 2px rgba(0,0,0,.25);
text-shadow:none;
}
/* Body en grille calquée sur le td parent (image | info | notices) :
3 colonnes égales, gap 14px. Le widget est maintenant un grid item du
td → padding 0 (le td a déjà son padding 18px). Côté gauche = stats
(col 1, largeur image), côté droit = chart (cols 2+3, largeur info+notices). */
.jbStatsBody{
display:grid;
grid-template-columns:repeat(3, minmax(0, 1fr));
gap:14px;
padding:0;
align-items:stretch;
}
.jbStatsSide{ grid-column:1; display:flex; flex-direction:column; gap:10px; min-width:0; }
/* (Anciennement on cachait .jbStatsGain.jbGainInNotices quand il était
dans .jbStatsSide pour éviter une trace vide, mais maintenant en log
mode on le RÉAFFICHE sous la carte stats → règle retirée.) */
.jbStatsMain{
grid-column:2 / 4;
display:flex; flex-direction:column; gap:6px; min-width:0; min-height:0;
position:relative;
background:rgba(255,255,255,.85);
border:2px solid #000;
border-radius:8px;
padding:38px 10px 10px; /* top:38px pour laisser place aux tabs en absolute */
overflow:hidden;
}
/* Total gain : pas de fond / pas de bordure → on laisse vivre le nameplate */
.jbStatsGain{
display:flex;
flex-direction:column;
align-items:center;
gap:4px;
padding:4px 0;
background:transparent !important;
border:none !important;
box-shadow:none !important;
color:#000;
}
.jbStatsGain .gainLabel{
font:700 9px/1.2 'Trebuchet MS',Verdana,sans-serif;
text-transform:uppercase; letter-spacing:.06em;
color:#000; opacity:.7;
text-align:center;
}
/* Le chiffre est posé SUR le nameplate du thème actif. background-size:auto
→ le SVG reste à sa taille native (pas de déformation). Le conteneur
accueille tout le SVG (assez de hauteur pour pas crop). */
.jbStatsGain .gainBig{
display:inline-flex;
align-items:center;
justify-content:center;
min-width:240px;
min-height:84px;
padding:14px 40px;
box-sizing:content-box;
background-image:var(--neo-nameplate, none);
background-repeat:no-repeat;
background-position:center;
background-size:auto;
font:800 28px/1 'CafeteriaBlack','Trebuchet MS',sans-serif;
font-variant-numeric:tabular-nums;
color:#000;
letter-spacing:.02em;
filter:drop-shadow(0 1px 2px rgba(0,0,0,.20));
}
.jbStatsGain .gainBig.flat,
.jbStatsGain .gainBig.down{ color:#000; }
.jbStatsGain .gainBreak{
margin-top:4px;
font-size:10px; color:#000; opacity:.8;
font-variant-numeric:tabular-nums;
line-height:1.4;
text-align:center;
}
.jbStatsGain .gainBreak b{ color:#000; font-weight:700; opacity:1; }
/* Chart : flex:1 dans jbStatsMain (col flex). min-height:0 + svg en
position:absolute → le svg ne dicte plus la hauteur naturelle, donc la
grille .jbStatsBody est sizée par la sidebar stats (et le svg s'adapte
via preserveAspectRatio="xMidYMid meet"). */
.jbStatsChart{
display:flex; justify-content:center; align-items:center;
background:rgba(255,255,255,.85);
border:1px solid #e5e5e5;
border-radius:6px;
flex:1 1 0;
min-height:0;
position:relative;
overflow:hidden;
}
.jbStatsChart svg{
display:block;
position:absolute;
inset:8px;
width:calc(100% - 16px);
height:calc(100% - 16px);
max-width:calc(100% - 16px);
max-height:calc(100% - 16px);
}
/* Quand renderChart affiche le placeholder "Pas encore d'historique",
le .jbStatsEmpty doit rester en flux normal (pas dans le SVG absolu). */
.jbStatsChart .jbStatsEmpty{ position:relative; }
.jbStatsLegend{
display:flex; justify-content:center; gap:14px; flex-wrap:wrap;
font-size:10px; font-weight:700;
}
/* Summary BD : liste de stats sur fond body-bg-bottom (hérité du widget parent).
Contour noir cohérent, séparateurs sombres entre lignes, texte noir. */
.jbStatsSummary{
display:flex; flex-direction:column; gap:0;
padding:10px 14px;
background:rgba(255,255,255,.85); /* aligné sur l'opacité des cards habituelles (.pet_more, etc.) */
border:2px solid #000;
border-radius:8px;
}
.jbStatsSummary .stat{
display:flex; align-items:center; justify-content:space-between;
gap:8px;
background:transparent;
border:none;
border-bottom:1px solid rgba(0,0,0,.25);
border-radius:0;
padding:7px 4px;
min-height:28px;
position:relative;
color:#000;
}
.jbStatsSummary .stat:last-child{ border-bottom:none; }
.jbStatsSummary .stat .lbl{
color:#000; opacity:.7; font:700 9px/1.2 'Trebuchet MS',Verdana,sans-serif;
text-transform:uppercase; letter-spacing:.06em;
display:flex; align-items:center; gap:6px;
}
/* Plus de pastille colorée (cohérence noir) */
.jbStatsSummary .stat .lbl::before{ display:none; }
/* Plus de bordure latérale colorée */
/* Fishing : carte info standard (contour noir) */
.jbStatCard[data-stat="fishing"]{
flex-direction:row !important;
justify-content:space-between !important;
align-items:center !important;
}
.jbStatCard[data-stat="fishing"] .jbStatLabel{ font-size:10px !important; }
.jbStatCard[data-stat="fishing"] .jbStatValue{ font-size:14px !important; }
.jbStatsSummary .stat .val{
color:#000; font-weight:800; font-size:13px;
font-variant-numeric:tabular-nums;
display:flex; align-items:baseline; gap:6px;
}
.jbStatsSummary .stat .delta{ font-size:10px; font-weight:700; color:#000; opacity:.65; }
.jbStatsSummary .stat .delta.up,
.jbStatsSummary .stat .delta.down,
.jbStatsSummary .stat .delta.flat{ color:#000; }
.jbStatsEmpty{
padding:18px 8px; text-align:center;
color:#000; opacity:.65; font-size:12px; font-style:italic;
}
.jbStatsFoot{
display:flex; justify-content:space-between; align-items:center;
padding:6px 14px;
background:rgba(255,255,255,.65);
border-top:1px solid #000;
font-size:10px; color:#000; opacity:.75;
}
.jbStatsFoot button{
background:transparent; border:0; padding:0; cursor:pointer;
color:#000;
font:inherit; text-decoration:underline;
}
.jbStatsFoot button:hover{ color:#000; }
/* ============== Liens internes par défaut — tout en noir ============== */
.contentModule a{ color:#000; text-decoration:underline; }
.contentModule a:hover{ color:#000; }
.contentModule .pet_info, .contentModule .pet_more { color:#000; }
/* ============== Responsive ============== */
@media (max-width: 1100px){
.contentModuleTable > tbody > tr > td{
grid-template-columns:minmax(220px, 1fr) minmax(0, 1fr) !important;
grid-template-areas:
"image info"
"notices notices" !important;
}
.pet_image, .pet_more{ min-height:240px !important; }
.jbStatsBody{ grid-template-columns:1fr 1fr !important; }
.jbStatsMain{ grid-column:2 / 3 !important; }
.jb-view-log .contentModuleTable > tbody > tr > td{
grid-template-areas:
"image stats"
"chart chart" !important;
}
}
@media (max-width: 760px){
.contentModule th.contentModuleHeader,
.contentModule th.contentModuleHeaderAlt{
padding:14px 110px 14px 16px !important; /* moins de place réservée à droite sur mobile */
}
.jbHeaderHp{ right:106px !important; width:160px !important; }
.jbViewToggle{ height:44px !important; padding:3px !important; }
.jbViewToggle button{ width:36px !important; }
.jbViewToggle button img{ width:22px !important; height:22px !important; }
.contentModuleTable > tbody > tr > td{
grid-template-columns:1fr !important;
grid-template-areas:
"image"
"info"
"notices" !important;
}
.pet_image{ max-width:240px !important; margin:0 auto !important; min-height:220px !important; }
.pet_more{ min-height:0 !important; }
.jbStatsBody{ grid-template-columns:1fr !important; }
.jbStatsSide{ grid-column:1 !important; }
.jbStatsMain{ grid-column:1 !important; }
.jb-view-log .contentModuleTable > tbody > tr > td{
grid-template-columns:1fr !important;
grid-template-areas:
"image"
"stats"
"chart" !important;
}
}
`;
function injectStyle(){
if (document.getElementById('jb-quickref-css')) return;
const st = document.createElement('style');
st.id = 'jb-quickref-css';
st.textContent = CSS;
(document.head || document.documentElement).appendChild(st);
}
injectStyle();
/* =========================
POSE SWAP
========================= */
function swapPose(root = document){
if (!CFG.POSE_SWAP) return;
root.querySelectorAll("img[src*='/1/4.png']").forEach(img => {
img.src = img.src.replace('/1/4.png', '/1/5.png');
});
root.querySelectorAll("div[style*='/1/4.png']").forEach(div => {
if (div.style?.backgroundImage){
div.style.backgroundImage = div.style.backgroundImage.replace('/1/4.png', '/1/5.png');
}
});
}
/* =========================
STORAGE STATS
========================= */
function loadStats(){
try { return JSON.parse(localStorage.getItem(STATS_LS) || '{}') || {}; }
catch { return {}; }
}
function saveStats(d){
try { localStorage.setItem(STATS_LS, JSON.stringify(d || {})); } catch {}
}
function dayKeyNST(d = new Date()){
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/Los_Angeles',
year:'numeric', month:'2-digit', day:'2-digit',
}).format(d);
}
/* =========================
SCRAPING — extraction des stats depuis chaque pet
========================= */
function intOr(text, fallbackRegex){
if (text == null) return null;
let m = text.match(/\(([\d,]+)\)/);
if (!m && fallbackRegex) m = text.match(fallbackRegex);
if (!m) m = text.match(/[\d,]+/);
if (!m) return null;
const n = parseInt(String(m[1] || m[0]).replace(/,/g, ''), 10);
return isFinite(n) ? n : null;
}
function parseStatsTable(tbl){
const stats = {};
[...tbl.querySelectorAll('tr')].forEach(tr => {
const th = tr.querySelector('th');
const td = tr.querySelector('td');
if (!th || !td) return;
const key = th.textContent.trim().replace(/:$/, '').toLowerCase();
stats[key] = td.textContent.trim();
});
return stats;
}
function scrapeAllPets(){
const pets = {};
document.querySelectorAll('div[id$="_details"].contentModule').forEach(div => {
const name = div.id.replace(/_details$/, '');
const tbl = div.querySelector('.pet_stats');
if (!tbl) return;
const s = parseStatsTable(tbl);
let hpMax = null;
if (s.health){
const m = s.health.match(/(\d+)\s*\/\s*(\d+)/);
if (m) hpMax = parseInt(m[2], 10);
}
pets[name] = {
level: intOr(s.level),
hpMax,
str: intOr(s.strength),
def: intOr(s.defence),
move: intOr(s.move),
};
});
return pets;
}
function recordSnapshot(){
const today = dayKeyNST();
const pets = scrapeAllPets();
const all = loadStats();
for (const [name, s] of Object.entries(pets)){
if (!Object.values(s).some(v => v !== null)) continue;
if (!all[name]) all[name] = [];
const list = all[name];
const last = list[list.length - 1];
if (last && last.date === today){
Object.assign(last, s);
} else {
list.push({ date: today, ...s });
}
if (list.length > 365) list.splice(0, list.length - 365);
}
saveStats(all);
}
/* =========================
UTILS NUMÉRIQUES + HSD
========================= */
function fmtN(n){ return (n == null ? '—' : n.toLocaleString()); }
function fmtDelta(n){
if (n == null) return '—';
if (n === 0) return '±0';
return (n > 0 ? '+' : '') + n.toLocaleString();
}
// HSD = HP + min(Str, 850) + min(Def, 850)
function hsdSum(snap){
if (!snap) return null;
const a = snap.hpMax, b = snap.str, c = snap.def;
if (a == null || b == null || c == null) return null;
return a + Math.min(b, HSD_CAP) + Math.min(c, HSD_CAP);
}
function deltaCls(d){
if (d == null) return 'flat';
if (d > 0) return 'up';
if (d < 0) return 'down';
return 'flat';
}
/* =========================
CHART SVG
========================= */
function renderChart(container, history, mode){
if (!history.length){
container.innerHTML = `<div class="jbStatsEmpty">Pas encore d'historique. Reviens visiter ta quickref chaque jour pour voir la courbe se construire.</div>`;
return;
}
let series;
let isDeltaMode = false;
if (mode === 'all'){
// Mode "All stats" : on plotte la VARIATION depuis le 1er snapshot
// (= delta), sinon les courbes apparaissent plates parce que les ordres de
// grandeur écrasent les variations (+2 Level invisible face à 5000 HP).
isDeltaMode = true;
const base = history[0];
const deltaOf = (key) => history.map((h, i) => ({
x: i,
y: (h[key] != null && base[key] != null) ? (h[key] - base[key]) : null,
}));
series = [
{ key:'level', label:'Level', color:STAT_COLOR.level, data: deltaOf('level') },
{ key:'hpMax', label:'HP', color:STAT_COLOR.hpMax, data: deltaOf('hpMax') },
{ key:'str', label:'Str', color:STAT_COLOR.str, data: deltaOf('str') },
{ key:'def', label:'Def', color:STAT_COLOR.def, data: deltaOf('def') },
{ key:'move', label:'Move', color:STAT_COLOR.move, data: deltaOf('move') },
];
} else {
series = [{
key:'hsd', label:'HSD', color:STAT_COLOR.hsd,
data: history.map((h,i) => ({ x:i, y: hsdSum(h) })),
}];
}
/* viewBox dynamique = dimensions pixel réelles de l'aire SVG visible
(= container - border 1px chaque côté - inset 8px chaque côté = -18px
par axe), pour que le SVG remplisse toute la card sans distorsion et
que le texte garde une taille screen-stable (1 unité viewBox = 1px
écran). Fallback 520×200 si le container n'a pas encore de taille
(widget initialement caché en mode profile). */
const _rect = container.getBoundingClientRect();
const _cw = Math.round(_rect.width) - 18;
const _ch = Math.round(_rect.height) - 18;
const W = (_cw > 100 ? _cw : 520);
const H = (_ch > 100 ? _ch : 200);
const padL = 54, padR = 14, padT = 12, padB = 24;
const chartW = W - padL - padR;
const chartH = H - padT - padB;
let yMin = Infinity, yMax = -Infinity;
for (const s of series) for (const p of s.data) {
if (p.y == null) continue;
if (p.y < yMin) yMin = p.y;
if (p.y > yMax) yMax = p.y;
}
if (!isFinite(yMin) || !isFinite(yMax)){
container.innerHTML = `<div class="jbStatsEmpty">Pas de données numériques disponibles.</div>`;
return;
}
// En mode delta on ne floor pas à 0 (peut être négatif), et on étend
// un peu la range pour rendre les courbes plates en bas du rectangle visibles
if (yMin === yMax){
if (isDeltaMode){ yMin = yMin - 1; yMax = yMax + 1; }
else { yMin = Math.max(0, yMin - 1); yMax = yMax + 1; }
}
const padRng = Math.max((yMax - yMin) * 0.15, 1);
const yLo = isDeltaMode ? Math.floor(yMin - padRng) : Math.max(0, Math.floor(yMin - padRng));
const yHi = Math.ceil(yMax + padRng);
const yRange = yHi - yLo;
const n = history.length;
const sx = i => padL + (n > 1 ? (i / (n - 1)) * chartW : chartW / 2);
const sy = v => padT + (1 - (v - yLo) / yRange) * chartH;
const fmtY = (v) => {
const rounded = Math.round(v);
if (isDeltaMode){
if (rounded === 0) return '0';
return (rounded > 0 ? '+' : '') + rounded.toLocaleString();
}
return rounded.toLocaleString();
};
let svg = `<svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">`;
const GRID_LINES = 4;
for (let i = 0; i <= GRID_LINES; i++){
const y = padT + (i / GRID_LINES) * chartH;
const v = yHi - (i / GRID_LINES) * yRange;
// Ligne du zéro plus visible en mode delta (référence baseline)
const isZero = isDeltaMode && Math.round(v) === 0;
const stroke = isZero ? '#888' : UI_GRID;
const sw = isZero ? '1.2' : '1';
svg += `<line x1="${padL}" y1="${y.toFixed(1)}" x2="${padL + chartW}" y2="${y.toFixed(1)}" stroke="${stroke}" stroke-width="${sw}" stroke-dasharray="${isZero ? '3,2' : '0'}"/>`;
svg += `<text x="${padL - 6}" y="${(y + 3).toFixed(1)}" font-size="9" fill="${UI_TEXT_DIM}" text-anchor="end" font-family="Trebuchet MS, Verdana, sans-serif">${fmtY(v)}</text>`;
}
svg += `<text x="${padL}" y="${H - 6}" font-size="9" fill="${UI_TEXT_DIM}" font-family="Trebuchet MS, Verdana, sans-serif">${history[0].date}</text>`;
if (n > 1){
svg += `<text x="${padL + chartW}" y="${H - 6}" font-size="9" fill="${UI_TEXT_DIM}" text-anchor="end" font-family="Trebuchet MS, Verdana, sans-serif">${history[n-1].date}</text>`;
}
for (const s of series){
const pts = s.data
.filter(p => p.y != null)
.map(p => `${sx(p.x).toFixed(1)},${sy(p.y).toFixed(1)}`)
.join(' ');
if (!pts) continue;
svg += `<polyline fill="none" stroke="${s.color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" points="${pts}"/>`;
for (const p of s.data){
if (p.y == null) continue;
svg += `<circle cx="${sx(p.x).toFixed(1)}" cy="${sy(p.y).toFixed(1)}" r="2.5" fill="${s.color}"/>`;
}
}
svg += `</svg>`;
container.innerHTML = svg;
}
function renderLegend(container, mode){
if (mode !== 'all'){ container.innerHTML = ''; return; }
container.innerHTML = `
<span style="color:#000"><span style="color:${STAT_COLOR.level}">●</span> Level</span>
<span style="color:#000"><span style="color:${STAT_COLOR.hpMax}">●</span> HP</span>
<span style="color:#000"><span style="color:${STAT_COLOR.str}">●</span> Str</span>
<span style="color:#000"><span style="color:${STAT_COLOR.def}">●</span> Def</span>
<span style="color:#000"><span style="color:${STAT_COLOR.move}">●</span> Move</span>
<span style="color:#000; opacity:.6; font-weight:400; font-style:italic; margin-left:8px;">Δ depuis 1er snapshot</span>`;
}
function renderGain(container, history, mode, petName){
const name = petName || 'this pet';
if (history.length < 2){
container.innerHTML = `
<div class="gainBig flat">—</div>
<div class="gainBreak">Pas encore assez d'historique pour ${name}.<br>Reviens demain.</div>`;
return;
}
const last = history[history.length - 1];
const prev = history[history.length - 2];
if (mode === 'hsd'){
const d = (hsdSum(last) ?? 0) - (hsdSum(prev) ?? 0);
const cls = deltaCls(d);
const sign = d > 0 ? '+' : (d < 0 ? '' : '±');
container.innerHTML = `
<div class="gainBig ${cls}">${sign}${Math.abs(d).toLocaleString()}</div>
<div class="gainBreak">
Today, ${name} gained
HP <b>${fmtDelta((last.hpMax ?? 0) - (prev.hpMax ?? 0))}</b>
· Str <b>${fmtDelta((last.str ?? 0) - (prev.str ?? 0))}</b>
· Def <b>${fmtDelta((last.def ?? 0) - (prev.def ?? 0))}</b>
</div>`;
} else {
const totalNow = (last.level ?? 0) + (last.hpMax ?? 0) + (last.str ?? 0) + (last.def ?? 0) + (last.move ?? 0);
const totalPrev = (prev.level ?? 0) + (prev.hpMax ?? 0) + (prev.str ?? 0) + (prev.def ?? 0) + (prev.move ?? 0);
const d = totalNow - totalPrev;
const cls = deltaCls(d);
const sign = d > 0 ? '+' : (d < 0 ? '' : '±');
container.innerHTML = `
<div class="gainBig ${cls}">${sign}${Math.abs(d).toLocaleString()}</div>
<div class="gainBreak">
Today, ${name} gained
Lv <b>${fmtDelta((last.level ?? 0) - (prev.level ?? 0))}</b>
· HP <b>${fmtDelta((last.hpMax ?? 0) - (prev.hpMax ?? 0))}</b>
· Str <b>${fmtDelta((last.str ?? 0) - (prev.str ?? 0))}</b>
· Def <b>${fmtDelta((last.def ?? 0) - (prev.def ?? 0))}</b>
· Mv <b>${fmtDelta((last.move ?? 0) - (prev.move ?? 0))}</b>
</div>`;
}
}
function renderSummary(container, history, mode){
/* Non-destructif : on retire uniquement les anciens .stat (et le
placeholder éventuel) — on PRÉSERVE les autres enfants comme le
bloc .jbStatsGain qu'on appendChild'e ici en mode log (cf
syncGainLocation). Sinon le rerender sur toggle écrase le gain. */
container.querySelectorAll(':scope > .stat, :scope > .jbStatsEmpty').forEach(el => el.remove());
if (!history.length) return;
const last = history[history.length - 1];
const prev = history.length > 1 ? history[history.length - 2] : null;
function card(key, label, now, before){
const d = (now != null && before != null) ? now - before : null;
const cls = deltaCls(d);
const deltaTxt = (d == null) ? '' : `<span class="delta ${cls}">${fmtDelta(d)}</span>`;
return `
<div class="stat" data-stat="${key}">
<div class="lbl">${label}</div>
<div class="val">${fmtN(now)} ${deltaTxt}</div>
</div>`;
}
const html = (mode === 'hsd')
? card('hsd', 'HSD Total', hsdSum(last), hsdSum(prev)) +
card('hpMax', 'HP Max', last.hpMax, prev?.hpMax) +
card('str', 'Strength', last.str, prev?.str) +
card('def', 'Defence', last.def, prev?.def)
: card('level', 'Level', last.level, prev?.level) +
card('hpMax', 'HP Max', last.hpMax, prev?.hpMax) +
card('str', 'Strength', last.str, prev?.str) +
card('def', 'Defence', last.def, prev?.def) +
card('move', 'Move', last.move, prev?.move);
/* Insert au DÉBUT de la summary → les .stat restent au-dessus, le
bloc gain (s'il a été appendé ici par syncGainLocation) reste en
bas et garde son margin-top:auto. */
container.insertAdjacentHTML('afterbegin', html);
}
/* =========================
STATS WIDGET
========================= */
function buildStatsWidget(petName){
const wrap = document.createElement('div');
wrap.className = 'jbStatsWidget';
wrap.innerHTML = `
<div class="jbStatsBody">
<div class="jbStatsSide">
<div class="jbStatsGain"></div>
<div class="jbStatsSummary"></div>
</div>
<div class="jbStatsMain">
<div class="jbStatsTabs">
<button data-mode="all" class="active">All stats</button>
<button data-mode="hsd">HSD</button>
</div>
<div class="jbStatsChart"></div>
<div class="jbStatsLegend"></div>
</div>
</div>
`;
const gainEl = wrap.querySelector('.jbStatsGain');
const chartEl = wrap.querySelector('.jbStatsChart');
const legendEl = wrap.querySelector('.jbStatsLegend');
const summaryEl = wrap.querySelector('.jbStatsSummary');
function getHistory(){
const all = loadStats();
return (all[petName] || []).slice();
}
function render(mode){
const history = getHistory();
renderGain(gainEl, history, mode, petName);
renderChart(chartEl, history, mode);
renderLegend(legendEl, mode);
renderSummary(summaryEl, history, mode);
}
let currentMode = 'all';
render(currentMode);
wrap.querySelectorAll('.jbStatsTabs button').forEach(btn => {
btn.addEventListener('click', (ev) => {
ev.preventDefault();
currentMode = btn.dataset.mode;
wrap.querySelectorAll('.jbStatsTabs button').forEach(b => b.classList.toggle('active', b === btn));
render(currentMode);
});
});
/* Re-render auto quand la taille du chart container change (toggle de
view mode, resize window). viewBox dynamique → on doit recalculer
pour utiliser toute la hauteur de la card sans écraser le graphique. */
let _lastW = 0, _lastH = 0;
try {
const ro = new ResizeObserver(entries => {
const r = entries[0]?.contentRect;
if (!r) return;
const cw = Math.round(r.width), ch = Math.round(r.height);
// Anti-bruit : ignore les variations < 4px
if (Math.abs(cw - _lastW) < 4 && Math.abs(ch - _lastH) < 4) return;
_lastW = cw; _lastH = ch;
renderChart(chartEl, getHistory(), currentMode);
});
ro.observe(chartEl);
} catch {}
/* Hook externe pour rerender après toggle de view mode (setViewMode
déclenche un requestAnimationFrame puis appelle __jbRerender). */
wrap.__jbRerender = () => render(currentMode);
return wrap;
}
/* =========================
PIECES DU REVAMP PAR PET
========================= */
function buildActions(petDiv){
const name = petDiv.id.replace(/_details$/, '');
if (petDiv.querySelector('.jbPetActions')) return;
const menu = document.getElementById(`${name}_menu`);
if (!menu) return;
const wrap = document.createElement('div');
wrap.className = 'jbPetActions';
[...menu.querySelectorAll('li')].forEach(li => {
const a = li.querySelector('a');
const txt = (li.textContent || '').replace(/^\s*»\s*/, '').trim();
if (!a){
const span = document.createElement('a');
span.className = 'disabled';
span.textContent = txt;
span.href = 'javascript:void(0)';
wrap.appendChild(span);
return;
}
const link = document.createElement('a');
link.href = a.getAttribute('href');
link.textContent = txt;
wrap.appendChild(link);
});
// Les actions vont en BAS de la pet card (sous le widget Progression)
// → append direct au contentModule (petDiv), pas dans le td du grid
petDiv.appendChild(wrap);
}
/* HP est désormais dans le bandeau header (decorateHeader). Cette fonction
se contente de masquer la row Health du tableau original (sinon doublon
dans la stats grid reconstruite). */
function hideHealthRow(petDiv){
const tbl = petDiv.querySelector('.pet_stats');
if (!tbl) return;
[...tbl.querySelectorAll('tr')].forEach(tr => {
const th = tr.querySelector('th');
if (!th) return;
if (/Health/i.test(th.textContent || '')){
tr.classList.add('jbHideRow');
}
});
}
function petNeedsCure(petDiv){
return /Find the cure at the NeoHospital!/i.test(petDiv.textContent || '');
}
function addHealOverlay(petDiv){
const petBox = petDiv.querySelector('.pet_image');
if (!petBox) return;
const has = petDiv.querySelector('#jb-heal-overlay');
if (!petNeedsCure(petDiv)){
if (has) has.remove();
return;
}
if (has) return;
const a = document.createElement('a');
a.id = 'jb-heal-overlay';
a.href = CFG.HEAL_LINK;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.title = 'Find Cooling Ointment';
const img = document.createElement('img');
img.src = CFG.HEAL_GIF;
img.alt = '';
img.loading = 'lazy';
a.appendChild(img);
petBox.appendChild(a);
}
function injectStatsWidget(petDiv){
if (petDiv.querySelector('.jbStatsWidget')) return;
const name = petDiv.id.replace(/_details$/, '');
const td = petDiv.querySelector('table.contentModuleTable tbody > tr > td');
if (td){
// Widget = grid item du td (row 2 en mode profile, OR display:contents
// en mode log pour que side/main deviennent eux-mêmes grid items).
td.appendChild(buildStatsWidget(name));
} else {
petDiv.appendChild(buildStatsWidget(name));
}
}
/* Place le bloc Total gain dans la col 3 :
- profile : dernier child de .pet_more (col 3 = notices)
- log : dernier child de .jbStatsSummary (col 3 = carte stats)
Dans les 2 cas, le parent est un flex column avec margin-top:auto sur
le gain → bouton ancré en bas de la card. Même col, même position,
même look (style hérité de `.jbStatsGain.jbGainInNotices`). */
function syncGainLocation(petDiv){
const mode = loadViewMode(petNameOf(petDiv));
const gain = petDiv.querySelector('.jbStatsGain');
if (!gain) return;
gain.classList.add('jbGainInNotices');
const more = petDiv.querySelector('.pet_more');
const summary = petDiv.querySelector('.jbStatsSummary');
if (mode === 'log'){
// appendChild force le déplacement en DERNIÈRE position dans .jbStatsSummary
// (à l'init le gain est dans .jbStatsSide → on le déplace dans la card stats).
if (summary && (gain.parentElement !== summary || gain.nextElementSibling !== null)){
summary.appendChild(gain);
}
if (more) more.classList.remove('jbHasGain');
} else {
if (more && (gain.parentElement !== more || gain.nextElementSibling !== null)){
more.appendChild(gain);
}
if (more) more.classList.add('jbHasGain');
}
}
/* ===== View mode (profile vs log) — PER-PET (chaque carte a son propre
état persisté, indépendamment des autres cartes du compte). Stocké en
localStorage sous la clé `jb_view_mode_<petName>`. ===== */
const VIEW_MODE_LS_PREFIX = 'jb_view_mode_';
function petNameOf(petDiv){
return petDiv && petDiv.id ? petDiv.id.replace(/_details$/, '') : '';
}
function loadViewMode(petName){
if (!petName) return 'profile';
try { return localStorage.getItem(VIEW_MODE_LS_PREFIX + petName) === 'log' ? 'log' : 'profile'; }
catch { return 'profile'; }
}
function saveViewMode(petName, mode){
if (!petName) return;
try { localStorage.setItem(VIEW_MODE_LS_PREFIX + petName, mode === 'log' ? 'log' : 'profile'); } catch {}
}
/* En log mode, force la hauteur des cards summary et main d'UN pet =
hauteur de l'image (mesurée après layout). Retry jusqu'à 5 fois si
l'image n'a pas encore de hauteur (layout pas stable). */
function syncLogCardHeightsFor(petDiv, retries){
if (retries == null) retries = 5;
if (!petDiv) return;
if (petDiv.classList.contains('jb-view-log')){
const img = petDiv.querySelector('.pet_image');
if (!img) return;
const h = Math.round(img.getBoundingClientRect().height);
if (h <= 0){
if (retries > 0) requestAnimationFrame(() => syncLogCardHeightsFor(petDiv, retries - 1));
return;
}
petDiv.querySelectorAll('.jbStatsSummary, .jbStatsMain').forEach(el => {
el.style.height = h + 'px';
el.style.maxHeight = h + 'px';
});
} else {
// Profile mode : retire les hauteurs forcées (au cas où on revient du log)
petDiv.querySelectorAll('.jbStatsSummary, .jbStatsMain').forEach(el => {
el.style.height = '';
el.style.maxHeight = '';
});
}
}
function syncLogCardHeightsAll(){
document.querySelectorAll('div[id$="_details"].contentModule').forEach(pd => {
syncLogCardHeightsFor(pd);
});
}
/* Applique le mode (profile/log) à UN seul pet — pas tous. Le mode est
loadé per-pet via petName. */
function applyViewModeFor(petDiv){
if (!petDiv) return;
const mode = loadViewMode(petNameOf(petDiv));
petDiv.classList.toggle('jb-view-log', mode === 'log');
petDiv.classList.toggle('jb-view-profile', mode !== 'log');
syncGainLocation(petDiv);
// Le toggle de CE pet seulement reflète son mode
petDiv.querySelectorAll('.jbViewToggle button').forEach(b => {
b.classList.toggle('active', b.dataset.view === mode);
});
// Layout stable puis sync des hauteurs
requestAnimationFrame(() => requestAnimationFrame(() => syncLogCardHeightsFor(petDiv)));
}
function applyViewModeAll(){
document.querySelectorAll('div[id$="_details"].contentModule').forEach(applyViewModeFor);
}
function setViewModeFor(petDiv, mode){
if (!petDiv) return;
saveViewMode(petNameOf(petDiv), mode);
applyViewModeFor(petDiv);
// Re-render le chart de CE pet pour qu'il s'adapte à la nouvelle taille
requestAnimationFrame(() => {
const w = petDiv.querySelector('.jbStatsWidget');
if (w && typeof w.__jbRerender === 'function') w.__jbRerender();
});
}
function addViewToggle(petDiv){
const th = petDiv.querySelector('th.contentModuleHeader, th.contentModuleHeaderAlt');
if (!th || th.querySelector('.jbViewToggle')) return;
const toggle = document.createElement('div');
toggle.className = 'jbViewToggle';
toggle.innerHTML = `
<button data-view="profile" type="button" aria-label="Vue profil" title="Vue profil">
<img src="https://images.neopets.com/themes/h5/altadorcup/images/profile-icon.png" alt="">
</button>
<button data-view="log" type="button" aria-label="Vue BD stats" title="Vue BD stats">
<img src="https://images.neopets.com/themes/h5/altadorcup/images/transferlog-icon.png" alt="">
</button>
`;
toggle.querySelectorAll('button').forEach(b => {
b.addEventListener('click', (ev) => {
ev.preventDefault();
// Toggle affecte SEULEMENT cette carte (recherche du .contentModule ancêtre)
const targetPet = b.closest('div[id$="_details"].contentModule');
setViewModeFor(targetPet, b.dataset.view);
});
});
th.appendChild(toggle);
}
/* =========================
HEADER : ajoute chips Species + Colour
========================= */
function getStatsByLabel(tbl){
const map = {};
if (!tbl) return map;
[...tbl.querySelectorAll('tr')].forEach(tr => {
const th = tr.querySelector('th');
const td = tr.querySelector('td');
if (!th || !td) return;
const lbl = (th.textContent || '').trim().replace(/:$/, '');
map[lbl] = { row: tr, value: td.textContent.trim() };
});
return map;
}
function decorateHeader(petDiv){
const th = petDiv.querySelector('th.contentModuleHeader, th.contentModuleHeaderAlt');
if (!th) return;
const tbl = petDiv.querySelector('.pet_stats');
const stats = getStatsByLabel(tbl);
const species = stats.Species?.value || '';
const colour = stats.Colour?.value || '';
// Récupère le sci (id de design du pet) pour extraire couleur dominante de l'image
const sciNow = petDiv.querySelector('.pet_image')?.dataset?.sci;
// Applique le thème : fallback synchrone par Colour name, upgrade async via image
applyPetTheme(petDiv, colour, sciNow);
// === HP bar Pokemon-style dans le header (idempotent) ===
if (!th.querySelector('.jbHeaderHp')){
const hpRaw = stats.Health?.value || '';
const m = hpRaw.match(/(\d+)\s*\/\s*(\d+)/);
if (m){
const cur = parseInt(m[1], 10);
const max = parseInt(m[2], 10);
const pct = max > 0 ? (cur / max) * 100 : 0;
const cls = pct < 25 ? 'danger' : (pct < 50 ? 'warn' : '');
const hpBlock = document.createElement('div');
hpBlock.className = 'jbHeaderHp ' + cls;
hpBlock.innerHTML = `
<div class="hpLabel">HP <span class="hpCur">${cur.toLocaleString()}</span><span class="hpSep"> / </span><b class="hpMax">${max.toLocaleString()}</b></div>
<div class="hpBar"><div class="hpFill" style="width:${pct.toFixed(1)}%"></div></div>
`;
th.appendChild(hpBlock);
}
}
if (th.dataset.jbDecorated === '1') return;
const a = th.querySelector('a');
if (!a) return;
// Strip les text-nodes directs du th (legacy "with X the Y...", etc.)
[...th.childNodes].forEach(n => {
if (n.nodeType === Node.TEXT_NODE && n.textContent.trim()){
n.remove();
}
});
// Strip aussi les <sub>/<br> directs (parfois utilisés par Neopets)
[...th.children].forEach(c => {
if (c === a) return;
if (/^(SUB|BR|FONT|SMALL)$/i.test(c.tagName) && !c.querySelector('a')){
c.remove();
}
});
// Icône mypets du thème (avatar décoratif) — sera un flex item normal
if (!th.querySelector('.jbHeaderAvatar')){
const av = document.createElement('span');
av.className = 'jbHeaderAvatar';
th.insertBefore(av, th.firstChild);
}
// Étiquette sous le nom : cadre simple semi-transparent (CSS) + chips Couleur/Espèce
if ((species || colour) && !th.querySelector('.jbHeaderChips')){
const chips = document.createElement('span');
chips.className = 'jbHeaderChips';
if (colour) chips.insertAdjacentHTML('beforeend', `<span class="jbChip jbColour">${colour}</span>`);
if (species) chips.insertAdjacentHTML('beforeend', `<span class="jbChip jbSpecies">${species}</span>`);
a.insertAdjacentElement('afterend', chips);
}
th.dataset.jbDecorated = '1';
}
/* Extrait petpet / petpetpet (nom + image) du tableau pet_stats.
1) Cherche les <th>Petpet</th> standards (cas idéal)
2) Fallback : scan les <td colspan> pour les <img> /items/ (cas quickref
où petpet/petpetpet sont dans un bloc unique avec "Click here to visit X!")
→ extrait le nom depuis alt / title / texte adjacent. */
function extractPetpets(petDiv){
const out = [];
const tbl = petDiv.querySelector('.pet_stats');
if (!tbl) return out;
// 1) Rows standards <th>Petpet</th> / <th>Petpetpet</th>
[...tbl.querySelectorAll('tr')].forEach(tr => {
const th = tr.querySelector('th');
const td = tr.querySelector('td');
if (!th || !td) return;
const lbl = (th.textContent || '').trim().replace(/:$/, '');
if (!/^Petpet(pet)?$/i.test(lbl)) return;
const img = td.querySelector('img');
const name = (td.textContent || '').trim();
if (!name) return;
out.push({ lbl, name, img: img ? img.src : null });
});
if (out.length) return out;
// 2) Fallback : <td colspan> contenant des imgs /items/
[...tbl.querySelectorAll('tr')].forEach(tr => {
const td = tr.querySelector('td[colspan]');
if (!td) return;
const imgs = td.querySelectorAll('img[src*="/items/"], img[src*="/petpets/"]');
if (!imgs.length) return;
imgs.forEach((img, i) => {
// Nom depuis alt / title sinon texte text-node après l'img
let name = (img.alt || img.title || '').trim();
if (!name){
let next = img.nextSibling;
while (next && next.nodeType !== Node.TEXT_NODE) next = next.nextSibling;
if (next && next.textContent){
name = next.textContent.trim().split(/[\n\r]|Click here/i)[0].trim();
}
}
// Fallback ultime : parse depuis le src de l'image (slug)
if (!name){
const m = img.src.match(/\/items\/([^.\/]+)\./i);
if (m) name = m[1].replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
const lbl = (i === 0) ? 'Petpet' : 'Petpetpet';
if (name) out.push({ lbl, name, img: img.src });
});
});
return out;
}
/* S'assure que pet_more est un enfant direct du <td> (sibling de pet_info,
pet_image) pour que grid-area:notices fonctionne. */
function ensureNoticesAsSibling(petDiv){
const more = petDiv.querySelector('.pet_more');
const info = petDiv.querySelector('.pet_info');
if (!more || !info) return;
const td = info.parentElement;
if (!td) return;
if (more.parentElement !== td){
td.appendChild(more);
}
}
/* Masque les lignes du tableau original (utilisé avant rebuild pour
définir quelles stats vont dans le widget Progression vs la card) */
function hideRedundantRows(petDiv){
const tbl = petDiv.querySelector('.pet_stats');
if (!tbl) return;
[...tbl.querySelectorAll('tr')].forEach(tr => {
const th = tr.querySelector('th');
if (!th) return;
const lbl = (th.textContent || '').trim().replace(/:$/, '');
if (/^(Species|Colour|Gender|Level|Strength|Defence|Move|Petpet|Petpetpet)$/i.test(lbl)){
tr.classList.add('jbHideRow');
}
});
}
/* Calcule la date de naissance depuis Age (en jours) */
function birthdayFromAge(td){
const m = (td.textContent || '').match(/([\d,]+)/);
if (!m) return null;
const days = parseInt(m[1].replace(/,/g, ''), 10);
if (!isFinite(days) || days <= 0) return null;
return new Date(Date.now() - days * 86400000);
}
function fmtBirthday(date){
return new Intl.DateTimeFormat('fr-FR', { day:'2-digit', month:'long', year:'numeric' }).format(date);
}
/* =========================
FISHING SKILL (et autres extras du lookup) — fetch + cache journalier
========================= */
function loadPetExtra(petName){
try { return JSON.parse(localStorage.getItem('jb_np_petextra_' + petName) || 'null'); }
catch { return null; }
}
function savePetExtra(petName, payload){
try { localStorage.setItem('jb_np_petextra_' + petName, JSON.stringify(payload)); } catch {}
}
async function fetchPetExtra(petName){
if (!petName) return null;
const cached = loadPetExtra(petName);
if (cached && cached.date === dayKeyNST()) return cached.data;
try {
const res = await fetch(`/petlookup.phtml?pet=${encodeURIComponent(petName)}`, { credentials: 'include' });
if (!res.ok) return cached?.data || null;
const html = await res.text();
const m = html.match(/Fishing\s+Skill:?\s*<\/b>\s*([\d,]+)/i);
const fishing = m ? parseInt(m[1].replace(/,/g, ''), 10) : null;
const data = { fishing };
savePetExtra(petName, { date: dayKeyNST(), data });
return data;
} catch {
return cached?.data || null;
}
}
/* Injecte la card Fishing dans la stats grid après fetch (async, non bloquant) */
async function addFishingCard(petDiv){
const name = petDiv.id.replace(/_details$/, '');
if (!name) return;
const grid = petDiv.querySelector('.jbStatsGrid');
if (!grid) return;
if (grid.querySelector('[data-stat="fishing"]')) return;
const extra = await fetchPetExtra(name);
if (!extra || extra.fishing == null) return;
if (grid.querySelector('[data-stat="fishing"]')) return; // re-check après await
const card = document.createElement('div');
card.className = 'jbStatCard';
card.dataset.stat = 'fishing';
card.innerHTML = `<span class="jbStatLabel">Fishing</span><span class="jbStatValue">${extra.fishing.toLocaleString()}</span>`;
// Insère Fishing AVANT la card Petpet dédiée si elle existe (pour que
// l'ordre final soit : Age / Mood / Hunger / Intel / Visit / Fishing / Petpet).
const petpetBigCard = grid.querySelector('.jbPetpetBigCard');
if (petpetBigCard){
grid.insertBefore(card, petpetBigCard);
} else {
grid.appendChild(card);
}
}
/* Reconstruit complètement les stats en divs natifs (.jbStatsGrid) à la
place du <table class='pet_stats'> qui est caché. Plus aucune dépendance
au hack display:grid/display:contents sur table/tbody. */
function rebuildStatsGrid(petDiv){
const tbl = petDiv.querySelector('table.pet_stats');
if (!tbl) return;
if (tbl.dataset.jbRebuilt === '1') return;
const grid = document.createElement('div');
grid.className = 'jbStatsGrid';
let petpetHtml = null;
[...tbl.querySelectorAll('tr')].forEach(tr => {
if (tr.classList.contains('jbHideRow')) return;
const colspanTd = tr.querySelector('td[colspan]');
if (colspanTd){
petpetHtml = colspanTd.innerHTML;
return;
}
const th = tr.querySelector('th');
const td = tr.querySelector('td');
if (!th || !td) return;
const label = th.textContent.trim().replace(/:$/, '');
const card = document.createElement('div');
card.className = 'jbStatCard';
const lbl = document.createElement('span');
lbl.className = 'jbStatLabel';
lbl.textContent = label;
const val = document.createElement('span');
val.className = 'jbStatValue';
val.innerHTML = td.innerHTML;
// Birthday calculé pour la card Age (inline avec parenthèses)
if (/^Age/i.test(label)){
const birth = birthdayFromAge(td);
if (birth){
const sub = document.createElement('span');
sub.className = 'jbBirthday';
sub.textContent = `(Né·e le ${fmtBirthday(birth)})`;
val.appendChild(sub);
}
}
card.appendChild(lbl);
card.appendChild(val);
grid.appendChild(card);
});
// Card colspan "[icones petpet/petpetpet] Click here to visit X!" — on garde
// celle-ci telle quelle (elle marche bien et reste cliquable).
if (petpetHtml){
const card = document.createElement('div');
card.className = 'jbPetpetCard jbVisitOnly';
card.innerHTML = petpetHtml;
grid.appendChild(card);
}
// Insère la grid avant le <table>, qui sera caché par CSS
tbl.parentNode.insertBefore(grid, tbl);
tbl.dataset.jbRebuilt = '1';
}
/* Construit la carte dédiée Petpet/Petpetpet.
Technique : on n'utilise PAS de <img> mais une <div> avec background-image.
Une div n'a aucune taille intrinsèque → elle prend exactement la taille
qu'on lui donne en CSS. Plus aucune surprise possible. */
function buildPetpetCard(petpets){
const card = document.createElement('div');
card.className = 'jbPetpetBigCard';
petpets.forEach(p => {
const row = document.createElement('div');
row.className = 'jbPetpetRow';
// Échappement minimal pour url() (les guillemets simples dans le src)
const safeUrl = p.img ? p.img.replace(/'/g, "\\'") : '';
const imgInner = p.img
? `<div class="jbPetpetImg" style="background-image:url('${safeUrl}')"></div>`
: '<div class="jbPetpetImg jbPetpetNoImg">?</div>';
row.innerHTML = `
${imgInner}
<div class="jbPetpetMeta">
<span class="jbPetpetLbl">${p.lbl}</span>
<span class="jbPetpetName">${p.name}</span>
</div>
`;
card.appendChild(row);
});
return card;
}
/* =========================
APPLY ALL
========================= */
function revampPet(petDiv){
const name = petDiv.id.replace(/_details$/, '');
applyPetCardTheme(petDiv, name); // thème per-pet (ou global si pas d'override)
decorateHeader(petDiv); // header + HP block Pokemon-style + thème
hideRedundantRows(petDiv);
hideHealthRow(petDiv); // Health est dans le header maintenant
rebuildStatsGrid(petDiv); // grid de divs + petpet card en bas
addFishingCard(petDiv); // async fetch lookup
ensureNoticesAsSibling(petDiv);
addHealOverlay(petDiv);
injectStatsWidget(petDiv); // widget BD stats DANS le td (grid item)
addViewToggle(petDiv); // bouton switch profil/log dans le header
buildActions(petDiv); // actions APRES le widget (= dernier child)
addThemeCarouselButton(petDiv, name); // bouton cycle thème bas-droit
}
function applyAll(){
swapPose();
document.querySelectorAll('div[id$="_details"].contentModule').forEach(revampPet);
// Applique le view mode PER-PET (chaque carte a son mode propre persisté)
// → déplace le bloc Total gain, synchronise les boutons toggle, set les
// hauteurs des cards en log.
applyViewModeAll();
}
/* =========================
BOOT
========================= */
let scheduled = false;
function scheduleApply(){
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => { scheduled = false; applyAll(); });
}
function boot(){
// Enregistre la snapshot du jour AVANT le rendering (au cas où les stats
// sont scrapées juste après que le DOM est prêt)
recordSnapshot();
scheduleApply();
}
if (document.readyState === 'loading'){
document.addEventListener('DOMContentLoaded', boot, { once: true });
} else {
boot();
}
const root = document.querySelector('td.content') || document.body || document.documentElement;
new MutationObserver(scheduleApply).observe(root, { subtree:true, childList:true });
// Resize : le col_w change → image height change → faut re-sync les
// hauteurs des cards summary et main en log mode.
let _resizeTO = null;
window.addEventListener('resize', () => {
if (_resizeTO) cancelAnimationFrame(_resizeTO);
_resizeTO = requestAnimationFrame(syncLogCardHeightsAll);
});
})();