Gold rung coming — improvise it, verified.
+ const fill = (ppFillFraction(p) * 100).toFixed(0);
+ badgeArea = `
${esc(p.genre.toUpperCase())}
BRONZE
@@ -708,6 +724,49 @@
}, 1500);
}
+ // ── Trading-card tilt (earned artifacts only) ─────────────────────────
+ let _tiltRaf = 0;
+ let _tiltEl = null;
+
+ function tiltAllowed() {
+ try {
+ return window.matchMedia('(hover: hover)').matches &&
+ !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ } catch (_) { return false; }
+ }
+
+ function resetTilt(el) {
+ if (!el) return;
+ el.style.removeProperty('--pp-tilt-x');
+ el.style.removeProperty('--pp-tilt-y');
+ el.style.removeProperty('--pp-glint-x');
+ }
+
+ function onTiltMove(e) {
+ if (!tiltAllowed()) return;
+ const card = e.target && e.target.closest ? e.target.closest('.pp-tilt') : null;
+ if (_tiltEl && _tiltEl !== card) { resetTilt(_tiltEl); _tiltEl = null; }
+ if (!card) return;
+ _tiltEl = card;
+ if (_tiltRaf) return;
+ const x = e.clientX;
+ const y = e.clientY;
+ _tiltRaf = requestAnimationFrame(() => {
+ _tiltRaf = 0;
+ const r = card.getBoundingClientRect();
+ if (!r.width || !r.height) return;
+ const px = (x - r.left) / r.width;
+ const py = (y - r.top) / r.height;
+ card.style.setProperty('--pp-tilt-x', `${((0.5 - py) * 10).toFixed(2)}deg`);
+ card.style.setProperty('--pp-tilt-y', `${((px - 0.5) * 12).toFixed(2)}deg`);
+ card.style.setProperty('--pp-glint-x', `${(px * 100).toFixed(1)}%`);
+ });
+ }
+
+ function onTiltLeave() {
+ if (_tiltEl) { resetTilt(_tiltEl); _tiltEl = null; }
+ }
+
function openGenre(inst, genre) {
fetch(`${API}/passports/open`, {
method: 'POST',
@@ -798,7 +857,11 @@
function boot() {
const screen = document.getElementById('plugin-career');
- if (screen) screen.addEventListener('click', onClick);
+ if (screen) {
+ screen.addEventListener('click', onClick);
+ screen.addEventListener('pointermove', onTiltMove);
+ screen.addEventListener('pointerleave', onTiltLeave);
+ }
const sm = window.feedBack;
if (sm && typeof sm.on === 'function') {
// New song stats can add stars → thresholds may cross mid-session.
@@ -819,7 +882,7 @@
// the badge-diff logic; nothing here touches the DOM.
window.__careerPassportTest = {
ppKey, ppJitter, ppLabel, detectNewBadges, seenBadges, markBadgeSeen,
- fmtHours,
+ fmtHours, ppFillFraction,
};
if (document.readyState === 'loading') {
diff --git a/plugins/career/tests/passports.test.js b/plugins/career/tests/passports.test.js
index 108f3f2..e270cc8 100644
--- a/plugins/career/tests/passports.test.js
+++ b/plugins/career/tests/passports.test.js
@@ -138,3 +138,15 @@ test('fmtHours: silent under a minute, minutes under an hour, tenths after', ()
assert.equal(fmtHours(null), '');
assert.equal(fmtHours('junk'), '');
});
+
+test('ppFillFraction: song progress toward the bar, in-progress only', () => {
+ const { ppFillFraction } = load().__careerPassportTest;
+ const p = (badge, q, songs) => ({ badge, qualifying_count: q, requirement: { songs } });
+ assert.equal(ppFillFraction(p('in_progress', 3, 5)), 0.6);
+ assert.equal(ppFillFraction(p('in_progress', 0, 5)), 0);
+ assert.equal(ppFillFraction(p('in_progress', 7, 5)), 1); // clamped
+ assert.equal(ppFillFraction(p('earned', 5, 5)), 0); // no fill once earned
+ assert.equal(ppFillFraction(p('shown_not_judged', 3, 5)), 0);
+ assert.equal(ppFillFraction(p('in_progress', 3, 0)), 0); // no bar → no fill
+ assert.equal(ppFillFraction(null), 0);
+});