mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-10 18:59:56 +00:00
feat(career): passport visuals pack — tilt, emerging ink, gold foil (#944)
* feat(career): passport visuals pack — tilt, emerging ink, gold foil Career v2, WS4. All CSS + a rAF-throttled pointer handler, no deps: - Trading-card tilt on EARNED artifacts only (shelf covers + the badge page stamp): pointer-tracked perspective rotateX/Y with a glint sweep following the pointer. The cover's jitter rotation moves into a CSS var so the tilt transform composes with it; the blanket cover-hover translate is scoped :not(.pp-tilt) so it can't fight the tilt. Hover-capable pointers only; off under prefers-reduced-motion. - Emerging-stamp ink: the ghost stamp fills by qualifying/required via a conic-gradient — the stamp visibly carves in, no numbers added. - Gold foil preview: the "coming" note gains a dashed foil chip with a periodic shimmer — honest, never earnable-looking. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(career): tilt rAF race + freshly-slammed stamp becomes a card CodeRabbit on #944: a queued tilt frame closed over the departed card and re-applied vars after pointerleave; and the just-slammed stamp never regained pp-tilt until the next open. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
4027c31a61
commit
b85496fe58
@@ -124,7 +124,7 @@
|
||||
text-align: center;
|
||||
}
|
||||
.pp-cover { transition: transform 0.15s ease, box-shadow 0.15s ease; }
|
||||
.pp-cover:hover { transform: translateY(-4px) !important; box-shadow: 0 10px 22px rgba(0, 0, 0, 0.55); }
|
||||
.pp-cover:not(.pp-tilt):hover { transform: translateY(-4px) !important; box-shadow: 0 10px 22px rgba(0, 0, 0, 0.55); }
|
||||
.pp-cover-title {
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
@@ -475,3 +475,87 @@
|
||||
margin-top: 0.75rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Visuals pack: trading-card tilt, emerging ink, gold foil ──────────── */
|
||||
|
||||
/* Trading-card tilt (earned artifacts; JS feeds --pp-tilt-* on hover-capable
|
||||
pointers only). */
|
||||
.pp-tilt {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
will-change: transform;
|
||||
}
|
||||
.pp-cover.pp-tilt {
|
||||
transform: perspective(700px)
|
||||
rotateX(var(--pp-tilt-x, 0deg)) rotateY(var(--pp-tilt-y, 0deg))
|
||||
rotate(var(--pp-cover-rot, 0deg));
|
||||
transition: transform 0.12s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.pp-cover.pp-tilt:hover { box-shadow: 0 12px 26px rgba(0, 0, 0, 0.6); }
|
||||
.pp-stamp-page.pp-tilt {
|
||||
overflow: visible;
|
||||
transform: perspective(600px)
|
||||
rotateX(var(--pp-tilt-x, 0deg)) rotateY(var(--pp-tilt-y, 0deg))
|
||||
rotate(var(--pp-rot));
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
.pp-tilt::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(105deg,
|
||||
transparent calc(var(--pp-glint-x, 50%) - 14%),
|
||||
rgba(255, 255, 255, 0.16) var(--pp-glint-x, 50%),
|
||||
transparent calc(var(--pp-glint-x, 50%) + 14%));
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.pp-tilt:hover::after { opacity: 1; }
|
||||
|
||||
/* Emerging-stamp ink: the ghost fills as qualifying songs land. */
|
||||
.pp-stamp-ghost::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 7%;
|
||||
border-radius: 999px;
|
||||
background: conic-gradient(rgba(154, 91, 22, 0.16) var(--pp-fill, 0%), transparent 0);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Gold foil preview — honest "coming", never earnable-looking. */
|
||||
.pp-gold-foil {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 0.9rem;
|
||||
padding: 0.28rem 0.85rem;
|
||||
border-radius: 999px;
|
||||
border: 2px dashed #c8b273;
|
||||
color: #a8946d;
|
||||
font-size: 0.58rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.32em;
|
||||
}
|
||||
.pp-gold-foil::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(100deg, transparent 40%, rgba(255, 223, 128, 0.35) 50%, transparent 60%);
|
||||
transform: translateX(-120%);
|
||||
animation: pp-foil 3.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pp-foil {
|
||||
0%, 55% { transform: translateX(-120%); }
|
||||
100% { transform: translateX(120%); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pp-gold-foil::after { animation: none; }
|
||||
.pp-cover.pp-tilt, .pp-stamp-page.pp-tilt { transition: none; }
|
||||
/* The hover glint is motion theatrics too — not just the JS tilt. */
|
||||
.pp-tilt::after { display: none; }
|
||||
}
|
||||
|
||||
@@ -479,12 +479,17 @@
|
||||
|
||||
function ppCoverHTML(inst, p) {
|
||||
const rot = ppJitter(inst + p.genre_key, 1.6).toFixed(2);
|
||||
const stamp = p.badge === 'earned'
|
||||
const earned = p.badge === 'earned';
|
||||
const stamp = earned
|
||||
? `<span class="pp-stamp pp-stamp-mini" style="--pp-rot:${ppJitter(p.genre_key, 8).toFixed(1)}deg">BRONZE</span>`
|
||||
: '';
|
||||
const stubs = p.qualifying_count === 1 ? '1 stub' : `${p.qualifying_count} stubs`;
|
||||
const hours = fmtHours(p.seconds_total);
|
||||
return `<button class="pp-cover pp-leather-${esc(inst)}" data-pp-open="${esc(p.genre_key)}" style="transform:rotate(${rot}deg)">
|
||||
// Earned covers are trading cards: rotation moves into a CSS var so
|
||||
// the pointer-tracked tilt transform can compose with it.
|
||||
const style = earned
|
||||
? `--pp-cover-rot:${rot}deg` : `transform:rotate(${rot}deg)`;
|
||||
return `<button class="pp-cover${earned ? ' pp-tilt' : ''} pp-leather-${esc(inst)}" data-pp-open="${esc(p.genre_key)}" style="${style}">
|
||||
<span class="pp-cover-title">${esc(p.genre.toUpperCase())}</span>
|
||||
<span class="pp-cover-inst">${esc(ppLabel(inst))} passport</span>
|
||||
${stamp}
|
||||
@@ -560,6 +565,15 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Emerging-stamp ink: how much of the ghost stamp has "carved in".
|
||||
// Song progress toward the bar only — the invite line stays the words.
|
||||
function ppFillFraction(p) {
|
||||
if (!p || p.badge !== 'in_progress') return 0;
|
||||
const need = Number((p.requirement || {}).songs) || 0;
|
||||
if (need <= 0) return 0;
|
||||
return Math.max(0, Math.min(1, (p.qualifying_count || 0) / need));
|
||||
}
|
||||
|
||||
function ppBookHTML(inst, p, pendingSlam) {
|
||||
const req = p.requirement || {};
|
||||
const need = Math.max(0, (req.songs || 0) - p.qualifying_count);
|
||||
@@ -582,13 +596,15 @@
|
||||
if (p.badge === 'shown_not_judged') {
|
||||
badgeArea = `<div class="pp-snj">Shown, not judged — your ${esc(ppLabel(inst).toLowerCase())} repertoire speaks for itself.</div>`;
|
||||
} else if (p.badge === 'earned') {
|
||||
badgeArea = `<div class="pp-stamp pp-stamp-page${pendingSlam ? ' pp-stamp-hidden' : ''}" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg">
|
||||
badgeArea = `<div class="pp-stamp pp-stamp-page${pendingSlam ? ' pp-stamp-hidden' : ' pp-tilt'}" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg">
|
||||
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
|
||||
<span class="pp-stamp-tier">BRONZE</span>
|
||||
</div>
|
||||
<div class="pp-gold-foil" aria-hidden="true">GOLD</div>
|
||||
<div class="pp-gold-note">Gold rung coming — improvise it, verified.</div>`;
|
||||
} else {
|
||||
badgeArea = `<div class="pp-stamp pp-stamp-page pp-stamp-ghost" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg">
|
||||
const fill = (ppFillFraction(p) * 100).toFixed(0);
|
||||
badgeArea = `<div class="pp-stamp pp-stamp-page pp-stamp-ghost" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg; --pp-fill:${fill}%">
|
||||
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
|
||||
<span class="pp-stamp-tier">BRONZE</span>
|
||||
</div>
|
||||
@@ -658,6 +674,7 @@
|
||||
if (!stamp) return;
|
||||
stamp.classList.remove('pp-stamp-hidden');
|
||||
stamp.classList.add('pp-slam');
|
||||
stamp.classList.add('pp-tilt'); // freshly slammed = trading card too
|
||||
if (book) book.classList.add('pp-shake');
|
||||
sfx('stamp');
|
||||
markBadgeSeen(inst, gkey);
|
||||
@@ -708,6 +725,52 @@
|
||||
}, 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() {
|
||||
// Cancel any queued frame: it closes over the departed card and would
|
||||
// re-apply tilt vars after the pointer has left.
|
||||
if (_tiltRaf) { cancelAnimationFrame(_tiltRaf); _tiltRaf = 0; }
|
||||
if (_tiltEl) { resetTilt(_tiltEl); _tiltEl = null; }
|
||||
}
|
||||
|
||||
function openGenre(inst, genre) {
|
||||
fetch(`${API}/passports/open`, {
|
||||
method: 'POST',
|
||||
@@ -798,7 +861,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 +886,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') {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user