feat(career): profile passport wall, home career card, shareable PNG card (#955)

* feat(career): profile passport wall, home career card, shareable PNG card

Career v3, WS2. The identity artifact leaves the plugin tab:

- Profile: #v3-profile-passports-mount (core, one div) filled by career
  on v3:profile-rendered — per-instrument shelves of earned covers,
  hours, gig count, open-career link. Absent-not-empty.
- Home: the plugin-count stat tile becomes #v3-dash-career-slot with the
  old stat as fallback content; career replaces it with a trading-card
  tile (leather + foil shine, badge count, hours, closest-stamp ask) on
  the existing v3:dashboard-rendered event.
- Shareable card: static/js/blob-io.js (downloadBlob lifts the idiom
  duplicated verbatim in settings-io/diagnostics-export — both
  refactored; copyImageBlob wraps ClipboardItem, returns false to signal
  the download fallback). Earned passports get Save/Copy card: a
  natively-drawn 480×640 canvas (leather, stamp ring, stubs+hours line);
  copy falls back to download with a notice when the clipboard refuses.
- Mount-point convention documented in docs/plugin-v3-ui.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(career): external surfaces stay absent until a passport exists

CodeRabbit on #955: a bare commitment produced a zero-passport wall and
replaced the dashboard fallback. Docs also now say mounts may hold
fallback content and plugins REPLACE, never append.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos
2026-07-14 01:25:39 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 831117fb96
commit 6272af8d33
10 changed files with 449 additions and 21 deletions
+13
View File
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added
- **Career on the Profile and Home pages** — the Profile gains a passport
wall (earned-badge covers per instrument, hours, gig count; absent until a
passport exists), injected through the same mount-point + rendered-event
seam the achievements plugin uses (now documented in docs/plugin-v3-ui.md).
The home page's plugin-count stat tile becomes a career trading card
(badges, hours, the closest stamp ask, foil shine) with the old stat as the
built-in fallback when career has no state. Earned passports gain **Save
card / Copy card** — a natively-drawn PNG passport card, downloadable or
copied straight to the clipboard for pasting outside the app (shared
`blob-io` helpers replace the download idiom previously duplicated in
settings-io and diagnostics-export).
### Changed ### Changed
- **The full mix is a stem** (#933) — core no longer depends on `original_audio:`, a - **The full mix is a stem** (#933) — core no longer depends on `original_audio:`, a
top-level manifest key this repo invented (#583) that the feedpak spec never had. top-level manifest key this repo invented (#583) that the feedpak spec never had.
+20
View File
@@ -189,3 +189,23 @@ out of the capability graph.
- [ ] `#player` overlays keep `z-index` ≤ the chrome layers (transport/HUD 20, - [ ] `#player` overlays keep `z-index` ≤ the chrome layers (transport/HUD 20,
rail 30, popovers 40). rail 30, popovers 40).
- [ ] Verify at `/` — it and `/v3` serve the same (and only) v3 shell. - [ ] Verify at `/` — it and `/v3` serve the same (and only) v3 shell.
## Injecting into core shells (profile, dashboard)
Core screens that accept plugin sections render **mount points** — usually
empty, sometimes holding core's own **fallback content** (the Dashboard's
career slot ships the plugin-count stat) — and announce each (re)build with a
DOM event, because their `innerHTML` swap wipes anything previously injected.
A plugin listens for the event and **replaces the mount's content** (never
append — a fallback may be present) by id — the same seam every time:
| Shell | Event | Mounts |
| --- | --- | --- |
| Profile | `v3:profile-rendered` | `#v3-profile-passports-mount` (career wall), `#v3-profile-feats-slot`, `#v3-profile-achievements-mount` |
| Dashboard | `v3:dashboard-rendered` | `#v3-dash-career-slot` (career card; core's plugin-count stat is the fallback content a plugin may replace) |
| Settings | `v3:settings-rendered` | per-plugin `settings.html` panels |
Rules: inject on every event (the mount is fresh), keep the section
**absent-not-empty** (no state → leave the mount alone / empty), and guard
re-wired listeners with a `dataset` flag when your own refresh path can run
against an unwiped mount.
+115 -1
View File
@@ -99,7 +99,8 @@
.pp-inst-plus { color: #6b7280; } .pp-inst-plus { color: #6b7280; }
/* Leather covers — per-instrument hue, embossed with layered shadows and a /* Leather covers — per-instrument hue, embossed with layered shadows and a
subtle grain gradient (no image assets). */ subtle grain gradient (no image assets). Keep the hex pairs in sync with
PP_LEATHER_HEX in screen.js (the canvas card draws the same leather). */
.pp-leather-guitar { background: linear-gradient(160deg, #5c2321, #401412); } .pp-leather-guitar { background: linear-gradient(160deg, #5c2321, #401412); }
.pp-leather-bass { background: linear-gradient(160deg, #1f3252, #131f36); } .pp-leather-bass { background: linear-gradient(160deg, #1f3252, #131f36); }
.pp-leather-keys { background: linear-gradient(160deg, #1e4034, #122a21); } .pp-leather-keys { background: linear-gradient(160deg, #1e4034, #122a21); }
@@ -600,3 +601,116 @@
} }
.pp-nearest-row { font-size: 0.7rem; color: #6d5d40; padding: 0.1rem 0; } .pp-nearest-row { font-size: 0.7rem; color: #6d5d40; padding: 0.1rem 0; }
.pp-nearest-row em { color: #3f3428; } .pp-nearest-row em { color: #3f3428; }
/* ── Career surfaces outside the plugin: profile wall + home card ───────── */
.pp-wall { display: flex; flex-direction: column; gap: 0.6rem; }
.pp-wall-head {
display: flex;
align-items: baseline;
justify-content: space-between;
font-weight: 600;
color: #e5e7eb;
font-size: 0.9rem;
}
.pp-wall-meta { color: #9ca3af; font-size: 0.7rem; font-weight: 400; }
.pp-wall-shelf {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.35rem 0;
border-bottom: 1px solid rgba(75, 85, 99, 0.25);
}
.pp-wall-inst {
font-size: 0.62rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #6b7280;
min-width: 3.6rem;
}
.pp-wall-cover {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.1rem;
width: 4.2rem;
height: 5.6rem;
border-radius: 0.3rem 0.45rem 0.45rem 0.3rem;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.07),
inset 0.25rem 0 0.4rem -0.25rem rgba(0, 0, 0, 0.8),
0 3px 8px rgba(0, 0, 0, 0.4);
padding: 0.3rem;
transition: transform 0.15s ease;
}
.pp-wall-cover:hover { transform: translateY(-3px); }
.pp-wall-cover span {
font-size: 0.5rem;
font-weight: 700;
letter-spacing: 0.1em;
color: rgba(240, 226, 195, 0.9);
overflow-wrap: anywhere;
text-align: center;
}
.pp-wall-cover em {
font-size: 0.42rem;
letter-spacing: 0.22em;
font-style: normal;
color: #d9a253;
}
.pp-wall-none { font-size: 0.7rem; color: #6b7280; font-style: italic; }
.pp-wall-link {
align-self: flex-end;
font-size: 0.72rem;
color: #22d3ee;
padding: 0.15rem 0.3rem;
}
.pp-wall-link:hover { text-decoration: underline; }
/* The home-page career card — a trading card among stat tiles. */
.pp-dash-card {
position: relative;
overflow: hidden;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.2rem;
text-align: left;
padding: 1rem;
border-radius: 0.5rem;
border: 1px solid rgba(217, 162, 83, 0.35);
background:
linear-gradient(135deg, rgba(92, 35, 33, 0.85), rgba(30, 27, 34, 0.92)),
linear-gradient(160deg, #2b1414, #17111c);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05), 0 4px 14px rgba(0, 0, 0, 0.35);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.pp-dash-card:hover { transform: translateY(-2px); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.5); }
.pp-dash-shine {
position: absolute;
inset: 0;
background: linear-gradient(105deg, transparent 42%, rgba(255, 223, 128, 0.18) 50%, transparent 58%);
transform: translateX(-130%);
pointer-events: none;
}
.pp-dash-card:hover .pp-dash-shine { animation: pp-foil 1.4s ease-out; }
.pp-dash-head {
font-size: 0.58rem;
letter-spacing: 0.3em;
text-transform: uppercase;
color: #d9a253;
}
.pp-dash-badges { color: #f3ead2; font-size: 1.05rem; }
.pp-dash-badges b { font-weight: 700; margin: 0 0.25rem 0 0.35rem; }
.pp-dash-meta { color: #b5a488; font-size: 0.72rem; }
.pp-dash-ask { color: #8d9aa8; font-size: 0.66rem; }
.pp-dash-ask em { color: #cbd5e1; }
.pp-card-actions { display: flex; gap: 0.5rem; margin-top: 0.9rem; }
@media (prefers-reduced-motion: reduce) {
.pp-dash-card:hover .pp-dash-shine { animation: none; }
.pp-wall-cover, .pp-dash-card { transition: none; }
}
+229 -2
View File
@@ -458,6 +458,8 @@
_pp = view; _pp = view;
detectNewBadges(view); detectNewBadges(view);
renderPassports(); renderPassports();
renderProfileWall();
renderDashCard();
if (!_ppBootstrapped) { if (!_ppBootstrapped) {
_ppBootstrapped = true; _ppBootstrapped = true;
// Sync the local drill snapshot once per session — drill progress // Sync the local drill snapshot once per session — drill progress
@@ -643,7 +645,11 @@
<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-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>
<div class="pp-card-actions">
<button class="career-btn career-btn-ghost" data-pp-card="save">Save card</button>
<button class="career-btn career-btn-ghost" data-pp-card="copy">Copy card</button>
</div>`;
} else { } else {
const fill = (ppFillFraction(p) * 100).toFixed(0); 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}%"> 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}%">
@@ -823,6 +829,217 @@
if (_tiltEl) { resetTilt(_tiltEl); _tiltEl = null; } if (_tiltEl) { resetTilt(_tiltEl); _tiltEl = null; }
} }
// ── Shareable passport card (canvas → PNG, save or clipboard) ─────────
// Keep in sync with the .pp-leather-* gradients in assets/career.css —
// canvas can't consume a CSS class, so the pairs live twice on purpose.
const PP_LEATHER_HEX = {
guitar: ['#5c2321', '#401412'],
bass: ['#1f3252', '#131f36'],
keys: ['#1e4034', '#122a21'],
drums: ['#3f3f46', '#26262b'],
};
function drawPassportCard(inst, p) {
const W = 480;
const H = 640;
const canvas = document.createElement('canvas');
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext('2d');
const [c1, c2] = PP_LEATHER_HEX[inst] || PP_LEATHER_HEX.guitar;
const bg = ctx.createLinearGradient(0, 0, W, H);
bg.addColorStop(0, c1);
bg.addColorStop(1, c2);
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
// Emboss frame
ctx.strokeStyle = 'rgba(240,226,195,0.35)';
ctx.lineWidth = 3;
ctx.strokeRect(18, 18, W - 36, H - 36);
// Genre title
ctx.fillStyle = 'rgba(240,226,195,0.95)';
ctx.textAlign = 'center';
ctx.font = '700 34px Georgia, serif';
ctx.fillText(p.genre.toUpperCase(), W / 2, 92, W - 80);
ctx.font = '400 15px Georgia, serif';
ctx.fillStyle = 'rgba(240,226,195,0.55)';
ctx.fillText(`${ppLabel(inst).toUpperCase()} PASSPORT`, W / 2, 122);
// Stamp ring
const gold = p.badge === 'gold';
const ink = gold ? '#d9a253' : '#b06a2a';
const cy = 330;
ctx.strokeStyle = ink;
ctx.lineWidth = 6;
ctx.beginPath();
ctx.arc(W / 2, cy, 118, 0, Math.PI * 2);
ctx.stroke();
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(W / 2, cy, 106, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = ink;
ctx.font = '800 26px Georgia, serif';
ctx.fillText(p.genre.toUpperCase(), W / 2, cy - 6, 190);
ctx.font = '600 16px Georgia, serif';
ctx.fillText(gold ? 'G O L D' : 'B R O N Z E', W / 2, cy + 28);
// Facts
const stubCount = (p.songs || []).filter((sng) => sng.qualifies).length;
const hours = fmtHours(p.seconds_total);
ctx.fillStyle = 'rgba(240,226,195,0.75)';
ctx.font = '400 17px Georgia, serif';
ctx.fillText(`${stubCount} ticket stub${stubCount === 1 ? '' : 's'}${hours ? ` · ${hours} played` : ''}`, W / 2, 512);
ctx.fillStyle = 'rgba(240,226,195,0.4)';
ctx.font = '400 13px Georgia, serif';
ctx.fillText('fee[dB]ack · career passport', W / 2, H - 44);
return canvas;
}
function exportPassportCard(mode) {
if (!_ppBook || !_pp) return;
const { inst, gkey } = _ppBook;
const p = (((_pp.instruments || {})[inst] || {}).passports || [])
.find((x) => x.genre_key === gkey);
if (!p) return;
const canvas = drawPassportCard(inst, p);
canvas.toBlob(async (blob) => {
const fail = (why) => {
if (window.fbNotify) window.fbNotify.show({ icon: '⚠️', title: 'Card export failed', message: why });
};
if (!blob) { fail('The canvas produced no image.'); return; }
const filename = `passport-${inst}-${gkey.replace(/[^a-z0-9-]+/g, '-')}.png`;
try {
const io = await import('/static/js/blob-io.js');
if (mode === 'copy') {
const ok = await io.copyImageBlob(blob);
if (ok) {
if (window.fbNotify) window.fbNotify.show({ icon: '📋', title: 'Card copied', message: 'Paste it anywhere.' });
return;
}
if (window.fbNotify) window.fbNotify.show({ icon: '💾', title: 'Clipboard unavailable', message: 'Saved the card instead.' });
}
io.downloadBlob(blob, filename);
} catch (e) { fail('Export helper unavailable.'); }
}, 'image/png');
}
// ── Career surfaces outside the plugin screen ─────────────────────────
// Profile passport wall + the home-page career card. Both inject into
// core-owned mounts announced by v3:profile-rendered /
// v3:dashboard-rendered (the achievements seam). Absent-not-empty: with
// no committed instrument they render nothing and the dashboard keeps
// its built-in fallback stat.
function careerTotals() {
if (!_pp) return null;
let badges = 0;
let seconds = 0;
let gigs = 0;
const walls = [];
for (const inst of (_pp.config || {}).instruments || []) {
const d = (_pp.instruments || {})[inst];
// A commitment with no opened passport isn't a wall yet — the
// external surfaces (profile, home card) stay ABSENT until a
// passport exists (absent-not-empty).
if (!d || !d.committed_at || !(d.passports || []).length) continue;
const earned = (d.passports || []).filter((p) => p.badge === 'earned' || p.badge === 'gold');
badges += earned.length;
seconds += (d.passports || []).reduce((t, p) => t + (p.seconds_total || 0), 0);
gigs += d.gig_count || 0;
walls.push({ inst, earned, opened: d.passports.length });
}
if (!walls.length) return null;
return { badges, seconds, gigs, walls };
}
function closestAskHTML() {
if (!_pp) return '';
let best = null;
for (const inst of (_pp.config || {}).instruments || []) {
for (const p of (((_pp.instruments || {})[inst] || {}).passports || [])) {
if (p.badge !== 'in_progress') continue;
const need = Math.max(0, ((p.requirement || {}).songs || 0) - p.qualifying_count);
if (!best || need < best.need) best = { p, need };
}
}
if (!best) return '';
const starGl = '★'.repeat((best.p.requirement || {}).min_stars || 0);
if (best.need > 0) {
return `${esc(best.p.genre)}${best.need === 1 ? `one more ${starGl} song` : `${best.need} more ${starGl} songs`}`;
}
return `${esc(best.p.genre)} — one drill away`;
}
function renderProfileWall() {
const mount = document.getElementById('v3-profile-passports-mount');
if (!mount) return;
const totals = careerTotals();
if (!totals) { mount.innerHTML = ''; return; }
const shelves = totals.walls.map(({ inst, earned, opened }) => {
const covers = earned.map((p) =>
`<button class="pp-wall-cover pp-leather-${esc(inst)}" data-pp-wall-inst="${esc(inst)}" data-pp-wall-gkey="${esc(p.genre_key)}" title="${esc(p.genre)}">
<span>${esc(p.genre.toUpperCase())}</span>
<em>${p.badge === 'gold' ? 'GOLD' : 'BRONZE'}</em>
</button>`).join('');
const line = earned.length
? covers
: `<span class="pp-wall-none">${opened} passport${opened === 1 ? '' : 's'} open — first stamp pending</span>`;
return `<div class="pp-wall-shelf"><span class="pp-wall-inst">${esc(ppLabel(inst))}</span>${line}</div>`;
}).join('');
const hours = fmtHours(totals.seconds);
mount.innerHTML = `<div class="bg-fb-card/80 backdrop-blur rounded-lg p-4 border border-fb-border/50 pp-wall">
<div class="pp-wall-head">
<span>Passport wall</span>
<span class="pp-wall-meta">${totals.badges} badge${totals.badges === 1 ? '' : 's'}${hours ? ` · ${hours} played` : ''}${totals.gigs ? ` · ${totals.gigs} gig${totals.gigs === 1 ? '' : 's'}` : ''}</span>
</div>
${shelves}
<button class="pp-wall-link" data-pp-wall-career="1">Open career →</button>
</div>`;
if (!mount.dataset.ppWired) {
mount.dataset.ppWired = '1';
mount.addEventListener('click', onWallClick);
}
}
function onWallClick(e) {
const open = e.target.closest('[data-pp-wall-inst]');
if (open) {
// Two attributes, not a '/'-joined pair: a genre key may itself
// contain '/' ("drum/bass") and must round-trip intact.
const inst = open.dataset.ppWallInst;
const gkey = open.dataset.ppWallGkey;
lsSet(PP_INST_KEY, inst);
if (window.showScreen) window.showScreen('plugin-career');
showCareerTab('passports');
renderPassports();
openBook(inst, gkey);
return;
}
if (e.target.closest('[data-pp-wall-career]')) {
if (window.showScreen) window.showScreen('plugin-career');
showCareerTab('passports');
}
}
function renderDashCard() {
const slot = document.getElementById('v3-dash-career-slot');
if (!slot) return;
const totals = careerTotals();
if (!totals) return; // keep core's fallback stat card
const hours = fmtHours(totals.seconds);
const ask = closestAskHTML();
slot.innerHTML = `<button class="pp-dash-card" data-pp-wall-career="1">
<span class="pp-dash-shine" aria-hidden="true"></span>
<span class="pp-dash-head">Career</span>
<span class="pp-dash-badges">${'⚡'.repeat(Math.min(totals.badges, 5))}<b>${totals.badges}</b> badge${totals.badges === 1 ? '' : 's'}</span>
<span class="pp-dash-meta">${hours ? `${hours} played` : 'the stage is set'}${totals.gigs ? ` · ${totals.gigs} gig${totals.gigs === 1 ? '' : 's'}` : ''}</span>
${ask ? `<span class="pp-dash-ask">closest: ${ask}</span>` : ''}
</button>`;
if (!slot.dataset.ppWired) {
slot.dataset.ppWired = '1';
slot.addEventListener('click', onWallClick);
}
}
function openGenre(inst, genre) { function openGenre(inst, genre) {
fetch(`${API}/passports/open`, { fetch(`${API}/passports/open`, {
method: 'POST', method: 'POST',
@@ -872,6 +1089,11 @@
closeBook(); closeBook();
return; return;
} }
const cardBtn = e.target.closest('[data-pp-card]');
if (cardBtn) {
exportPassportCard(cardBtn.dataset.ppCard);
return;
}
const dlBtn = e.target.closest('[data-career-download]'); const dlBtn = e.target.closest('[data-career-download]');
const delBtn = e.target.closest('[data-career-delete]'); const delBtn = e.target.closest('[data-career-delete]');
const playBtn = e.target.closest('[data-career-play]'); const playBtn = e.target.closest('[data-career-play]');
@@ -931,6 +1153,10 @@
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && _ppBook) closeBook(); if (e.key === 'Escape' && _ppBook) closeBook();
}); });
// Core re-renders profile/dashboard shells (innerHTML wipe) and
// announces the fresh mount points — same seam achievements uses.
document.addEventListener('v3:profile-rendered', renderProfileWall);
document.addEventListener('v3:dashboard-rendered', renderDashCard);
refresh(); refresh();
} }
@@ -938,7 +1164,8 @@
// 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, ppFillFraction, fmtHours, ppFillFraction, careerTotals, closestAskHTML,
setView(v) { _pp = v; },
}; };
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
+27
View File
@@ -150,3 +150,30 @@ test('ppFillFraction: song progress toward the bar, in-progress only', () => {
assert.equal(ppFillFraction(p('in_progress', 3, 0)), 0); // no bar → no fill assert.equal(ppFillFraction(p('in_progress', 3, 0)), 0); // no bar → no fill
assert.equal(ppFillFraction(null), 0); assert.equal(ppFillFraction(null), 0);
}); });
test('careerTotals / wall + dash card stay absent without commitment', () => {
const w = load();
const t = w.__careerPassportTest;
// No _pp at all → null; committed-less view → null (absent-not-empty).
assert.equal(t.careerTotals(), null);
t.setView({ config: { instruments: ['guitar'] },
instruments: { guitar: { committed_at: null, passports: [] } } });
assert.equal(t.careerTotals(), null);
// Committed but zero passports opened: still absent (no zero-wall).
t.setView({ config: { instruments: ['guitar'] },
instruments: { guitar: { committed_at: 'x', passports: [] } } });
assert.equal(t.careerTotals(), null);
// Committed with an earned badge + hours → totals aggregate.
t.setView({ config: { instruments: ['guitar', 'bass'] },
instruments: {
guitar: { committed_at: 'x', passports: [
{ badge: 'earned', seconds_total: 3600, genre: 'Blues', genre_key: 'blues' },
{ badge: 'in_progress', seconds_total: 120, genre: 'Funk', genre_key: 'funk',
qualifying_count: 4, requirement: { songs: 5, min_stars: 2 } }] },
bass: { committed_at: null, passports: [] },
} });
const totals = t.careerTotals();
assert.equal(totals.badges, 1);
assert.equal(totals.seconds, 3720);
assert.equal(totals.walls.length, 1);
});
+28
View File
@@ -0,0 +1,28 @@
// Blob export helpers — the download idiom that used to be duplicated in
// settings-io.js and diagnostics-export.js, plus image-to-clipboard for
// shareable cards/posters. A LEAF module: imports nothing. Classic-script
// plugins reach it via dynamic import('/static/js/blob-io.js').
export function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Copy an image blob to the system clipboard. Returns true on success, false
// when the Clipboard API is unavailable or refuses (insecure context, no user
// gesture, permission denied) — callers fall back to downloadBlob and say so.
export async function copyImageBlob(blob) {
try {
if (!navigator.clipboard || typeof ClipboardItem === 'undefined') return false;
await navigator.clipboard.write([new ClipboardItem({ [blob.type || 'image/png']: blob })]);
return true;
} catch (_) {
return false;
}
}
+3 -8
View File
@@ -21,6 +21,8 @@
// redact toggles. // redact toggles.
// 3. Stream the returned zip to disk. // 3. Stream the returned zip to disk.
import { downloadBlob } from './blob-io.js';
function _diagIncludeFromUI() { function _diagIncludeFromUI() {
const v = (id) => document.getElementById(id)?.checked !== false; const v = (id) => document.getElementById(id)?.checked !== false;
return { return {
@@ -265,14 +267,7 @@ export async function exportDiagnostics() {
} }
try { try {
const blob = await resp.blob(); const blob = await resp.blob();
const url = URL.createObjectURL(blob); downloadBlob(blob, filename);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
status.textContent = `Exported ${filename}`; status.textContent = `Exported ${filename}`;
} catch (e) { } catch (e) {
status.textContent = `Export failed during download: ${e.message}`; status.textContent = `Export failed during download: ${e.message}`;
+4 -9
View File
@@ -1,6 +1,6 @@
// Settings backup — the export / import bundle. // Settings backup — the export / import bundle.
// //
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing. // Carved verbatim out of static/app.js (R3a). Imports only the blob-io leaf.
// //
// Two entry points, both inline handlers on the Settings screen, so app.js keeps // Two entry points, both inline handlers on the Settings screen, so app.js keeps
// re-exposing them on window. The import is two-phase (server first, atomic; then // re-exposing them on window. The import is two-phase (server first, atomic; then
@@ -29,6 +29,8 @@
// phase 2; the localStorage side is best-effort merge after server // phase 2; the localStorage side is best-effort merge after server
// success. Failures are reported, never silenced. // success. Failures are reported, never silenced.
import { downloadBlob } from './blob-io.js';
export async function exportSettings() { export async function exportSettings() {
const status = document.getElementById('backup-status'); const status = document.getElementById('backup-status');
status.textContent = 'Exporting...'; status.textContent = 'Exporting...';
@@ -66,14 +68,7 @@ export async function exportSettings() {
if (match) filename = match[1]; if (match) filename = match[1];
} }
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' }); const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob); downloadBlob(blob, filename);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
status.textContent = `Exported ${filename}`; status.textContent = `Exported ${filename}`;
} catch (e) { } catch (e) {
status.textContent = `Export failed: ${e.message}`; status.textContent = `Export failed: ${e.message}`;
+6 -1
View File
@@ -208,12 +208,17 @@
'</div></div></div>' + '</div></div></div>' +
continueCard + continueCard +
'</div>' + '</div>' +
// Stats row // Stats row. The third slot belongs to the career plugin (it
// replaces the slot's content on v3:dashboard-rendered); the
// plugin-count stat is the built-in fallback when career is
// absent or has no state yet.
'<div class="grid md:grid-cols-3 gap-6 mt-6">' + '<div class="grid md:grid-cols-3 gap-6 mt-6">' +
audioRoutingCard() + audioRoutingCard() +
statCard(String(songCount), 'songs', 'text-fb-gold') + statCard(String(songCount), 'songs', 'text-fb-gold') +
'<div id="v3-dash-career-slot" class="grid">' +
statCard(String(pluginCount), 'active', 'text-fb-good') + statCard(String(pluginCount), 'active', 'text-fb-good') +
'</div>' + '</div>' +
'</div>' +
recentSection + recentSection +
'</div>'; '</div>';
+4
View File
@@ -191,6 +191,10 @@
'<div class="space-y-6">' + '<div class="space-y-6">' +
headerCard + headerCard +
bestsCard + bestsCard +
// Passport wall — rendered by the career plugin on
// v3:profile-rendered (absent-not-empty: nothing shows until a
// passport exists).
'<div id="v3-profile-passports-mount"></div>' +
// Feats of Power trophy shelf — rendered by the achievements plugin // Feats of Power trophy shelf — rendered by the achievements plugin
// (earned Feats only; hidden-until-earned, so empty when none). // (earned Feats only; hidden-until-earned, so empty when none).
'<div id="v3-profile-feats-slot"></div>' + '<div id="v3-profile-feats-slot"></div>' +