`;
+ 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 = ``;
+ if (!slot.dataset.ppWired) {
+ slot.dataset.ppWired = '1';
+ slot.addEventListener('click', onWallClick);
+ }
+ }
+
function openGenre(inst, genre) {
fetch(`${API}/passports/open`, {
method: 'POST',
@@ -872,6 +1089,11 @@
closeBook();
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 delBtn = e.target.closest('[data-career-delete]');
const playBtn = e.target.closest('[data-career-play]');
@@ -931,6 +1153,10 @@
document.addEventListener('keydown', (e) => {
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();
}
@@ -938,7 +1164,8 @@
// the badge-diff logic; nothing here touches the DOM.
window.__careerPassportTest = {
ppKey, ppJitter, ppLabel, detectNewBadges, seenBadges, markBadgeSeen,
- fmtHours, ppFillFraction,
+ fmtHours, ppFillFraction, careerTotals, closestAskHTML,
+ setView(v) { _pp = v; },
};
if (document.readyState === 'loading') {
diff --git a/plugins/career/tests/passports.test.js b/plugins/career/tests/passports.test.js
index e270cc8..05cdeae 100644
--- a/plugins/career/tests/passports.test.js
+++ b/plugins/career/tests/passports.test.js
@@ -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(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);
+});
diff --git a/static/js/blob-io.js b/static/js/blob-io.js
new file mode 100644
index 0000000..237112f
--- /dev/null
+++ b/static/js/blob-io.js
@@ -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;
+ }
+}
diff --git a/static/js/diagnostics-export.js b/static/js/diagnostics-export.js
index 869eb60..f07482c 100644
--- a/static/js/diagnostics-export.js
+++ b/static/js/diagnostics-export.js
@@ -21,6 +21,8 @@
// redact toggles.
// 3. Stream the returned zip to disk.
+import { downloadBlob } from './blob-io.js';
+
function _diagIncludeFromUI() {
const v = (id) => document.getElementById(id)?.checked !== false;
return {
@@ -265,14 +267,7 @@ export async function exportDiagnostics() {
}
try {
const blob = await resp.blob();
- 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);
+ downloadBlob(blob, filename);
status.textContent = `Exported ${filename}`;
} catch (e) {
status.textContent = `Export failed during download: ${e.message}`;
diff --git a/static/js/settings-io.js b/static/js/settings-io.js
index e04fc83..6c1fb74 100644
--- a/static/js/settings-io.js
+++ b/static/js/settings-io.js
@@ -1,6 +1,6 @@
// 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
// 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
// success. Failures are reported, never silenced.
+import { downloadBlob } from './blob-io.js';
+
export async function exportSettings() {
const status = document.getElementById('backup-status');
status.textContent = 'Exporting...';
@@ -66,14 +68,7 @@ export async function exportSettings() {
if (match) filename = match[1];
}
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' });
- 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);
+ downloadBlob(blob, filename);
status.textContent = `Exported ${filename}`;
} catch (e) {
status.textContent = `Export failed: ${e.message}`;
diff --git a/static/v3/dashboard.js b/static/v3/dashboard.js
index 59e5e7e..a52e237 100644
--- a/static/v3/dashboard.js
+++ b/static/v3/dashboard.js
@@ -208,12 +208,17 @@
'
' +
continueCard +
'' +
- // 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.
'
' +
headerCard +
bestsCard +
+ // Passport wall — rendered by the career plugin on
+ // v3:profile-rendered (absent-not-empty: nothing shows until a
+ // passport exists).
+ '' +
// Feats of Power trophy shelf — rendered by the achievements plugin
// (earned Feats only; hidden-until-earned, so empty when none).
'' +