From 6272af8d33a47209330dedf7cb30a074659b79ec Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Tue, 14 Jul 2026 01:25:39 +0200 Subject: [PATCH] feat(career): profile passport wall, home career card, shareable PNG card (#955) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 13 ++ docs/plugin-v3-ui.md | 20 +++ plugins/career/assets/career.css | 116 ++++++++++++- plugins/career/screen.js | 231 ++++++++++++++++++++++++- plugins/career/tests/passports.test.js | 27 +++ static/js/blob-io.js | 28 +++ static/js/diagnostics-export.js | 11 +- static/js/settings-io.js | 13 +- static/v3/dashboard.js | 7 +- static/v3/profile.js | 4 + 10 files changed, 449 insertions(+), 21 deletions(-) create mode 100644 static/js/blob-io.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d80678..89c3f8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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 - **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. diff --git a/docs/plugin-v3-ui.md b/docs/plugin-v3-ui.md index ebe31ec..9477233 100644 --- a/docs/plugin-v3-ui.md +++ b/docs/plugin-v3-ui.md @@ -189,3 +189,23 @@ out of the capability graph. - [ ] `#player` overlays keep `z-index` ≤ the chrome layers (transport/HUD 20, rail 30, popovers 40). - [ ] 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. diff --git a/plugins/career/assets/career.css b/plugins/career/assets/career.css index 0fbd46c..6fcc12a 100644 --- a/plugins/career/assets/career.css +++ b/plugins/career/assets/career.css @@ -99,7 +99,8 @@ .pp-inst-plus { color: #6b7280; } /* 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-bass { background: linear-gradient(160deg, #1f3252, #131f36); } .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 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; } +} diff --git a/plugins/career/screen.js b/plugins/career/screen.js index 9ad0bc1..1188d25 100644 --- a/plugins/career/screen.js +++ b/plugins/career/screen.js @@ -458,6 +458,8 @@ _pp = view; detectNewBadges(view); renderPassports(); + renderProfileWall(); + renderDashCard(); if (!_ppBootstrapped) { _ppBootstrapped = true; // Sync the local drill snapshot once per session — drill progress @@ -643,7 +645,11 @@ BRONZE -
Gold rung coming — improvise it, verified.
`; +
Gold rung coming — improvise it, verified.
+
+ + +
`; } else { const fill = (ppFillFraction(p) * 100).toFixed(0); badgeArea = `
@@ -823,6 +829,217 @@ 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) => + ``).join(''); + const line = earned.length + ? covers + : `${opened} passport${opened === 1 ? '' : 's'} open — first stamp pending`; + return `
${esc(ppLabel(inst))}${line}
`; + }).join(''); + const hours = fmtHours(totals.seconds); + mount.innerHTML = `
+
+ Passport wall + ${totals.badges} badge${totals.badges === 1 ? '' : 's'}${hours ? ` · ${hours} played` : ''}${totals.gigs ? ` · ${totals.gigs} gig${totals.gigs === 1 ? '' : 's'}` : ''} +
+ ${shelves} + +
`; + 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. '
' + audioRoutingCard() + statCard(String(songCount), 'songs', 'text-fb-gold') + + '
' + statCard(String(pluginCount), 'active', 'text-fb-good') + '
' + + '
' + recentSection + ''; diff --git a/static/v3/profile.js b/static/v3/profile.js index ce79818..5554391 100644 --- a/static/v3/profile.js +++ b/static/v3/profile.js @@ -191,6 +191,10 @@ '
' + 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). '
' +