mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-13 04:09:26 +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
@@ -38,6 +38,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
top-tier clean pass in one key (`keysCleared`), any depth rung, or
|
top-tier clean pass in one key (`keysCleared`), any depth rung, or
|
||||||
mastery — rather than only the maxed-speed depth flips. Genres without a
|
mastery — rather than only the maxed-speed depth flips. Genres without a
|
||||||
curated drill stay songs-only.
|
curated drill stay songs-only.
|
||||||
|
- **Career passport visuals pack** — earned covers and badge stamps become
|
||||||
|
trading cards (pointer-tracked tilt + light glint, hover-capable devices
|
||||||
|
only); the ghost stamp visibly "carves in" as qualifying songs land (a
|
||||||
|
conic ink fill, no numbers added); the Gold rung preview is a small foil
|
||||||
|
chip with a shimmer sweep, still honestly labeled coming. All theatrics
|
||||||
|
disabled under `prefers-reduced-motion`.
|
||||||
- **Career passports (backend)** — the badge-journey layer on top of career stars.
|
- **Career passports (backend)** — the badge-journey layer on top of career stars.
|
||||||
New career-plugin endpoints: `GET /api/plugins/career/passports` (per-instrument
|
New career-plugin endpoints: `GET /api/plugins/career/passports` (per-instrument
|
||||||
passport walls: genre badges computed on read from `song_stats` × the library's
|
passport walls: genre badges computed on read from `song_stats` × the library's
|
||||||
|
|||||||
@@ -124,7 +124,7 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.pp-cover { transition: transform 0.15s ease, box-shadow 0.15s ease; }
|
.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 {
|
.pp-cover-title {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -475,3 +475,87 @@
|
|||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
font-variant-numeric: tabular-nums;
|
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) {
|
function ppCoverHTML(inst, p) {
|
||||||
const rot = ppJitter(inst + p.genre_key, 1.6).toFixed(2);
|
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>`
|
? `<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 stubs = p.qualifying_count === 1 ? '1 stub' : `${p.qualifying_count} stubs`;
|
||||||
const hours = fmtHours(p.seconds_total);
|
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-title">${esc(p.genre.toUpperCase())}</span>
|
||||||
<span class="pp-cover-inst">${esc(ppLabel(inst))} passport</span>
|
<span class="pp-cover-inst">${esc(ppLabel(inst))} passport</span>
|
||||||
${stamp}
|
${stamp}
|
||||||
@@ -560,6 +565,15 @@
|
|||||||
</div>`;
|
</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) {
|
function ppBookHTML(inst, p, pendingSlam) {
|
||||||
const req = p.requirement || {};
|
const req = p.requirement || {};
|
||||||
const need = Math.max(0, (req.songs || 0) - p.qualifying_count);
|
const need = Math.max(0, (req.songs || 0) - p.qualifying_count);
|
||||||
@@ -582,13 +596,15 @@
|
|||||||
if (p.badge === 'shown_not_judged') {
|
if (p.badge === 'shown_not_judged') {
|
||||||
badgeArea = `<div class="pp-snj">Shown, not judged — your ${esc(ppLabel(inst).toLowerCase())} repertoire speaks for itself.</div>`;
|
badgeArea = `<div class="pp-snj">Shown, not judged — your ${esc(ppLabel(inst).toLowerCase())} repertoire speaks for itself.</div>`;
|
||||||
} else if (p.badge === 'earned') {
|
} 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-genre">${esc(p.genre.toUpperCase())}</span>
|
||||||
<span class="pp-stamp-tier">BRONZE</span>
|
<span class="pp-stamp-tier">BRONZE</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pp-gold-foil" aria-hidden="true">GOLD</div>
|
||||||
<div class="pp-gold-note">Gold rung coming — improvise it, verified.</div>`;
|
<div class="pp-gold-note">Gold rung coming — improvise it, verified.</div>`;
|
||||||
} else {
|
} 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-genre">${esc(p.genre.toUpperCase())}</span>
|
||||||
<span class="pp-stamp-tier">BRONZE</span>
|
<span class="pp-stamp-tier">BRONZE</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -658,6 +674,7 @@
|
|||||||
if (!stamp) return;
|
if (!stamp) return;
|
||||||
stamp.classList.remove('pp-stamp-hidden');
|
stamp.classList.remove('pp-stamp-hidden');
|
||||||
stamp.classList.add('pp-slam');
|
stamp.classList.add('pp-slam');
|
||||||
|
stamp.classList.add('pp-tilt'); // freshly slammed = trading card too
|
||||||
if (book) book.classList.add('pp-shake');
|
if (book) book.classList.add('pp-shake');
|
||||||
sfx('stamp');
|
sfx('stamp');
|
||||||
markBadgeSeen(inst, gkey);
|
markBadgeSeen(inst, gkey);
|
||||||
@@ -708,6 +725,52 @@
|
|||||||
}, 1500);
|
}, 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) {
|
function openGenre(inst, genre) {
|
||||||
fetch(`${API}/passports/open`, {
|
fetch(`${API}/passports/open`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -798,7 +861,11 @@
|
|||||||
|
|
||||||
function boot() {
|
function boot() {
|
||||||
const screen = document.getElementById('plugin-career');
|
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;
|
const sm = window.feedBack;
|
||||||
if (sm && typeof sm.on === 'function') {
|
if (sm && typeof sm.on === 'function') {
|
||||||
// New song stats can add stars → thresholds may cross mid-session.
|
// New song stats can add stars → thresholds may cross mid-session.
|
||||||
@@ -819,7 +886,7 @@
|
|||||||
// the badge-diff logic; nothing here touches the DOM.
|
// the badge-diff logic; nothing here touches the DOM.
|
||||||
window.__careerPassportTest = {
|
window.__careerPassportTest = {
|
||||||
ppKey, ppJitter, ppLabel, detectNewBadges, seenBadges, markBadgeSeen,
|
ppKey, ppJitter, ppLabel, detectNewBadges, seenBadges, markBadgeSeen,
|
||||||
fmtHours,
|
fmtHours, ppFillFraction,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
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(null), '');
|
||||||
assert.equal(fmtHours('junk'), '');
|
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