Skip to content
25 changes: 18 additions & 7 deletions src/renderer/plugin-manager/screen.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
<div id="plugin-manager-panel" class="p-4 max-w-3xl mx-auto">
<style>
/* Visible focus for keyboard travel. The host page ships no
:focus-visible rules of its own, so a keyboard user had no way to
see where they were between the native outlines a dark theme eats.
Scoped to this panel so it cannot repaint focus rings elsewhere. */
#plugin-manager-panel :focus-visible {
outline: 2px solid #34d399;
outline-offset: 2px;
}
</style>
<h2 class="text-xl font-bold mb-4 text-slate-200">Plugin Manager</h2>

<!-- Plugin catalog (read-only browsing; install actions live further down) -->
Expand All @@ -19,13 +29,13 @@ <h3 class="text-sm font-semibold text-slate-300">Browse catalog</h3>
<div id="pm-catalog-filters" class="grid grid-cols-1 sm:grid-cols-2 gap-3"></div>

<div class="flex items-center justify-between mt-3">
<span id="pm-catalog-active" class="text-xs text-slate-500"></span>
<span id="pm-catalog-active" class="text-xs text-slate-500" role="status" aria-live="polite"></span>
<button id="pm-catalog-clear" class="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600">
Clear filters
</button>
</div>

<div id="pm-catalog-status" class="mt-3 text-sm"></div>
<div id="pm-catalog-status" class="mt-3 text-sm" role="status" aria-live="polite"></div>
<div id="pm-catalog-list" class="mt-3 space-y-2"></div>
</div>

Expand All @@ -34,6 +44,7 @@ <h3 class="text-sm font-semibold text-slate-300">Browse catalog</h3>
<h3 class="text-sm font-semibold mb-2 text-slate-300">Install Plugin</h3>
<div class="flex gap-2">
<input type="text" id="pm-git-url" placeholder="https://github.com/user/feedback-plugin-foo.git"
aria-label="Plugin repository URL"
class="flex-1 bg-slate-700 border border-slate-600 rounded px-3 py-2 text-sm">
<button id="pm-install-btn" class="px-4 py-2 rounded bg-emerald-600 hover:bg-emerald-500 text-sm font-medium whitespace-nowrap">
Install
Expand Down Expand Up @@ -61,19 +72,19 @@ <h3 class="text-sm font-semibold text-slate-300">Plugin catalog</h3>
earlier one, disabled without losing their files, or uninstalled. Uninstalling keeps a
plugin's settings unless you choose otherwise in the confirmation dialog.
</p>
<div id="pm-catalog" class="space-y-2">
<div id="pm-catalog" class="space-y-2" role="group" aria-label="Curated plugins to install">
<div class="text-sm text-slate-500 italic">Loading...</div>
</div>
<div id="pm-catalog-deps" class="mt-2 text-xs text-slate-400 hidden"></div>
<div id="pm-catalog-deps" class="mt-2 text-xs text-slate-400" role="region" aria-label="Installation plan" aria-live="polite"></div>
<div class="flex items-center gap-2 mt-3">
<button id="pm-catalog-install" class="px-3 py-1.5 rounded bg-emerald-600 hover:bg-emerald-500 text-sm font-medium">Install selected</button>
<button id="pm-catalog-cancel" class="px-3 py-1.5 rounded bg-slate-700 hover:bg-slate-600 text-sm hidden">Cancel</button>
</div>
<div id="pm-catalog-progress-wrap" class="hidden mt-3">
<progress id="pm-catalog-progress" class="w-full" value="0" max="100"></progress>
<div id="pm-catalog-progress-text" class="text-xs text-slate-400 mt-1"></div>
<progress id="pm-catalog-progress" class="w-full" value="0" max="100" aria-label="Download progress"></progress>
</div>
<div id="pm-catalog-msg" class="mt-2 text-sm hidden"></div>
<div id="pm-catalog-progress-text" class="text-xs text-slate-400 mt-1" role="status" aria-live="polite"></div>
Comment thread
pullfrog[bot] marked this conversation as resolved.
Resolved
<div id="pm-catalog-msg" class="mt-2 text-sm" role="status" aria-live="polite"></div>
</div>

<!-- Network sharing (LAN access) -->
Expand Down
89 changes: 73 additions & 16 deletions src/renderer/plugin-manager/screen.js
Original file line number Diff line number Diff line change
Expand Up @@ -196,9 +196,11 @@
if (!catalogProgressWrap) return;
catalogProgressWrap.classList.remove('hidden');
catalogProgress.value = percent;
catalogProgressText.textContent = percent >= 100
? `${name} — installed.`
: `${name} — downloading… ${percent}%`;
if (catalogProgressText) {
catalogProgressText.textContent = percent >= 100
? `${name} — installed.`
: `${name} — downloading… ${percent}%`;
}
}

function setCatalogBusy(busy) {
Expand Down Expand Up @@ -258,6 +260,23 @@
: '';
const staticDisabled = !installable || entry.bundled || selection.tier === 'essential' || held || incompatible;
box.disabled = staticDisabled;
// Every disabled box says why, in words assistive tech can
// reach: a tooltip on the control itself, plus — for the two
// reasons that can change while the row stays put (an
// incompatibility, a lock the resolver just placed) — a
// visible line the box points at with aria-describedby.
let staticReason = '';
if (incompatible) staticReason = incompatibleReason;
else if (entry.bundled) staticReason = 'Ships with the app; nothing to install.';
else if (selection.tier === 'essential') staticReason = 'Part of the core set; installed with the app.';
else if (entry.pinned) staticReason = 'Pinned — unpin it to install ' + entry.version + '.';
else if (entry.disabled) staticReason = 'Disabled. Enable it to install ' + entry.version + '.';
else if (entry.updateStatus === 'ahead') staticReason = 'The catalog is older than the copy installed. Use Downgrade to go back.';
else if (!installable) staticReason = 'Installed at this version already.';
if (staticReason) box.title = staticReason;
if (incompatible) {
box.setAttribute('aria-describedby', 'pm-row-reason-' + entry.id);
}
if (box.disabled) catalogSelection.delete(entry.id);
box.checked = catalogSelection.has(entry.id);
box.addEventListener('change', async () => {
Expand All @@ -266,11 +285,6 @@
updateInstallLabel();
await updateDependencyInfo();
});
// Static facts about the row are kept beside its checkbox so the
// resolver-driven state below can restore them after each
// resolve: the same handle is what the install plan summary
// reads, and `staticDisabled` never changes per selection.
catalogBoxById.set(entry.id, { box, entry, staticDisabled, incompatible, incompatibleReason });

const meta = document.createElement('div');
meta.className = 'flex-1 min-w-0';
Expand All @@ -289,9 +303,24 @@
${esc(entry.category || '')}${Array.isArray(entry.instruments) && entry.instruments.length ? ' · ' + esc(entry.instruments.join(', ')) : ''}
${entry.size ? ' · ' + Math.max(1, Math.round(entry.size.downloadBytes / 1024)) + ' KB' : ''}
</div>
${incompatible ? `<div class="text-xs text-red-300 mt-0.5">${esc(incompatibleReason)}</div>` : ''}
${incompatible ? `<div class="text-xs text-red-300 mt-0.5" id="pm-row-reason-${esc(entry.id)}">${esc(incompatibleReason)}</div>` : ''}
`;

// The lock explanation is its own element rather than template
// markup: the resolver rewrites it on every selection change,
// and the checkbox above points at its id with aria-describedby
// so a screen reader hears why the box is off, not just that
// it is. Empty + hidden until something requires this plugin.
const lockEl = document.createElement('div');
lockEl.id = 'pm-row-lock-' + entry.id;
lockEl.className = 'text-xs text-sky-300 mt-0.5 hidden';
meta.appendChild(lockEl);
// Static facts about the row are kept beside its checkbox so the
// resolver-driven state below can restore them after each
// resolve: the same handle is what the install plan summary
// reads, and `staticDisabled` never changes per selection.
catalogBoxById.set(entry.id, { box, entry, staticDisabled, staticReason, lockEl });

row.appendChild(box);
row.appendChild(meta);
for (const control of lifecycleControls(entry)) row.appendChild(control);
Expand Down Expand Up @@ -488,6 +517,10 @@
function mirrorPlanOntoRows(plan) {
const pruned = ((plan && plan.conflicts) || []).filter(c => c && c.dropped);
const prunedIds = new Set(pruned.map((c) => c.dropped));
const nameOf = (id) => {
const handle = catalogBoxById.get(id);
return handle && handle.entry.name ? handle.entry.name : id;
};
// Dropped entries leave the set before any lock is computed, so a
// conflict-pruned plugin never adopts or locks anything behind it.
for (const id of prunedIds) catalogSelection.delete(id);
Expand Down Expand Up @@ -517,17 +550,44 @@
for (const handleEntry of catalogBoxById) {
const id = handleEntry[0];
const handle = handleEntry[1];
const isIncompatible = !!handle.entry.compat && handle.entry.compat.ok === false;
if (prunedIds.has(id)) {
// Pruned beats locked: a box the plan drops must never read as
// to-be-installed, whatever declared it as a dependency.
handle.box.disabled = handle.staticDisabled;
handle.box.checked = false;
handle.lockEl.textContent = '';
handle.lockEl.classList.add('hidden');
handle.box.title = handle.staticReason;
if (!isIncompatible) handle.box.removeAttribute('aria-describedby');
} else if (lockedBy.has(id)) {
handle.box.disabled = true;
if (!handle.staticDisabled) handle.box.checked = true;
if (!handle.staticDisabled) {
handle.box.checked = true;
const byNames = lockedBy.get(id).map(nameOf).join(', ');
// The lock is spoken, not just drawn: the line is what the
// checkbox's aria-describedby points at, so a screen reader
// hears who required this plugin and how to release it.
const lockText = 'Locked into the plan: required by ' + byNames + '. Untick ' + byNames + ' to remove it.';
handle.box.title = lockText;
handle.lockEl.textContent = lockText;
handle.lockEl.classList.remove('hidden');
handle.box.setAttribute('aria-describedby', 'pm-row-lock-' + id);
} else {
// Already on disk (bundled, or held): the row says what is
// on it, and a "locked into the plan" line over a checkbox
// that was never selectable would only confuse.
handle.lockEl.textContent = '';
handle.lockEl.classList.add('hidden');
if (!isIncompatible) handle.box.removeAttribute('aria-describedby');
}
} else {
handle.box.disabled = handle.staticDisabled;
handle.box.checked = catalogSelection.has(id);
handle.lockEl.textContent = '';
handle.lockEl.classList.add('hidden');
handle.box.title = handle.staticReason;
if (!isIncompatible) handle.box.removeAttribute('aria-describedby');
}
}
const locked = [...lockedBy]
Expand All @@ -540,8 +600,8 @@
if (!catalogDependencyInfo) return;
const ids = [...catalogSelection];
if (!ids.length) {
catalogDependencyInfo.classList.add('hidden');
catalogDependencyInfo.textContent = '';
mirrorPlanOntoRows(null);
updateInstallLabel();
return;
}
Expand Down Expand Up @@ -587,17 +647,14 @@
}
if (lines.length) {
catalogDependencyInfo.textContent = lines.join('\n');
Comment thread
pullfrog[bot] marked this conversation as resolved.
Resolved
catalogDependencyInfo.classList.remove('hidden');
return;
}
catalogDependencyInfo.classList.add('hidden');
catalogDependencyInfo.textContent = '';
} catch (e) {
// A resolver that cannot answer must not pretend the plan is fine:
// hide the stale note and say the selection could not be resolved,
// clear the stale note and say the selection could not be resolved,
// rather than leaving the install button looking ready over an
// unknown set.
catalogDependencyInfo.classList.add('hidden');
catalogDependencyInfo.textContent = '';
showCatalogMessage('Could not resolve the selection. Try again before installing.', false);
}
Expand Down Expand Up @@ -654,7 +711,6 @@
}

if (result.success && catalogDependencyInfo) {
catalogDependencyInfo.classList.add('hidden');
catalogDependencyInfo.textContent = '';
}
await refreshCatalog();
Expand All @@ -665,6 +721,7 @@
unsubscribe();
catalogState.clear();
catalogProgressWrap.classList.add('hidden');
if (catalogProgressText) catalogProgressText.textContent = '';
setCatalogBusy(false);
// setCatalogBusy(false) resets the label to the plain form; a
// kept selection (a failed install) must put its count back.
Expand Down
Loading
Loading