From 07d528d0c81bfcb70b0bfec9a6f925c1451c251b Mon Sep 17 00:00:00 2001 From: Carolina <79524656+carochacs@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:29:29 -0600 Subject: [PATCH] core(ui): allow dynamic library card action text --- CHANGELOG.md | 6 ++-- static/capabilities/library-card-actions.js | 11 ++++++-- tests/js/library_card_actions.test.js | 31 +++++++++++++++++++++ 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c262781..6849804c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Added -- **Core reader for source rigs (feedpak 1.18.0).** A pack can declare what a +### Added +- Library card actions can now provide per-song label and icon callbacks, so + plugins can render dynamic card badges without DOM patching. +- **Core reader for source rigs (feedpak 1.18.0).** A pack can declare what a MIDI part should sound like by binding a rig; core now reads that binding and hands it to the client instead of dropping it. Three parts: the `tone_changes` WS message carries the pack's rig bindings (`base_rig`, and diff --git a/static/capabilities/library-card-actions.js b/static/capabilities/library-card-actions.js index 1bf2c7d4..c5c58c4b 100644 --- a/static/capabilities/library-card-actions.js +++ b/static/capabilities/library-card-actions.js @@ -13,6 +13,7 @@ * register(spec) -> unregister() spec: { id, pluginId, label, icon?, * placement?('menu'|'inline'|'overlay'), order?, destructive?, * applies?(song)->bool, enabled?(song)->bool, run(song, ctx) } + * label/icon may be strings or (song)->string functions. * list(song) -> [actionSummary] applicable actions, sorted * run(id, song, ctx) -> Promise<{ ok, outcome, result?|error? }> * snapshot() -> redaction-safe registry snapshot @@ -39,8 +40,8 @@ return { id: String(spec.id), pluginId: String(spec.pluginId || 'unknown'), - label: String(spec.label || spec.id), - icon: spec.icon || '', + label: typeof spec.label === 'function' ? spec.label : () => String(spec.label || spec.id), + icon: typeof spec.icon === 'function' ? spec.icon : () => String(spec.icon || ''), placement: PLACEMENTS.includes(spec.placement) ? spec.placement : 'menu', order: Number.isFinite(spec.order) ? spec.order : 100, destructive: !!spec.destructive, @@ -87,7 +88,11 @@ if (!applicable) continue; let enabled = true; try { enabled = a.enabled(song) !== false; } catch (e) { enabled = true; } - out.push({ id: a.id, pluginId: a.pluginId, label: a.label, icon: a.icon, placement: a.placement, order: a.order, destructive: a.destructive, enabled }); + let label = a.id; + try { label = String(a.label(song) || a.id); } catch (e) { label = a.id; } + let icon = ''; + try { icon = String(a.icon(song) || ''); } catch (e) { icon = ''; } + out.push({ id: a.id, pluginId: a.pluginId, label, icon, placement: a.placement, order: a.order, destructive: a.destructive, enabled }); } out.sort((x, y) => (x.order - y.order) || x.label.localeCompare(y.label)); return out; diff --git a/tests/js/library_card_actions.test.js b/tests/js/library_card_actions.test.js index 43c7989a..86f7ba22 100644 --- a/tests/js/library_card_actions.test.js +++ b/tests/js/library_card_actions.test.js @@ -29,6 +29,37 @@ test('enabled() reflected in the summary but action still listed', () => { assert.strictEqual(on.enabled, true); }); +test('label and icon callbacks resolve per listed song', () => { + freshIds(); + reg.register({ + id: 'difficulty', + label: (s) => `${s.progress}%`, + icon: (s) => (s.progress >= 90 ? '*' : ''), + run() {}, + }); + assert.deepStrictEqual( + reg.list({ filename: 'easy.sloppak', progress: 42 }).map((a) => ({ label: a.label, icon: a.icon })), + [{ label: '42%', icon: '' }], + ); + assert.deepStrictEqual( + reg.list({ filename: 'done.sloppak', progress: 96 }).map((a) => ({ label: a.label, icon: a.icon })), + [{ label: '96%', icon: '*' }], + ); +}); + +test('label and icon callback errors fall back safely', () => { + freshIds(); + reg.register({ + id: 'bad-dynamic', + label() { throw new Error('label unavailable'); }, + icon() { throw new Error('icon unavailable'); }, + run() {}, + }); + const [action] = reg.list({ filename: 'song.sloppak' }); + assert.strictEqual(action.label, 'bad-dynamic'); + assert.strictEqual(action.icon, ''); +}); + test('run() invokes the handler and reports handled', async () => { freshIds(); let got = null;