feat(webui): make Overview board rows clickable end to end

Each action-board row on /apps/overview already carries a navigation
button (Review / View / Open Backups). The whole row now triggers that
same navigation, so the small button is no longer the only hit target.

Rows opt in via a `nav` descriptor, so only plain go-to-that-tab rows
become clickable — rows with no action, and the "Update all" button
inside the updates row, are unaffected (updater actions are matched
first in the delegated handler, so Update all never double-fires).

The row is not given role="button": nesting the real buttons inside a
button role would break them for assistive tech. The inner button stays
the focusable, keyboard-reachable control; the row is a mouse-only
widening, with a hover state that lifts row and button together. A
whole-row click is suppressed when it ends a text selection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-18 21:18:45 +01:00
parent c9a513abc0
commit cbbb978e50
2 changed files with 39 additions and 1 deletions

View File

@ -117,6 +117,22 @@
background: rgba(var(--text-rgb), .03); background: rgba(var(--text-rgb), .03);
border-color: rgba(var(--text-rgb), .08); border-color: rgba(var(--text-rgb), .08);
} }
/* Rows whose action is just "go to that tab" are clickable end to end the
button inside remains the affordance (and the focusable control), the row
only widens the target. Hover lifts the whole row AND the button together so
it reads as one control, not a row that happens to sit under the cursor. */
.ov-board-row.ov-clickable { cursor: pointer; transition: background .12s ease, border-color .12s ease; }
.ov-board-row.ov-clickable:hover {
background: rgba(var(--page-rgb, var(--accent-rgb)), .11);
border-color: rgba(var(--page-rgb, var(--accent-rgb)), .28);
}
.ov-board-row.ov-clickable.ok:hover,
.ov-board-row.ov-clickable.none:hover {
background: rgba(var(--text-rgb), .06);
border-color: rgba(var(--text-rgb), .14);
}
/* Keep the button's own hover styling from reading as "only this bit is live". */
.ov-board-row.ov-clickable:hover .ov-board-actions .updater-btn { filter: brightness(1.08); }
.ov-dot { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto; } .ov-dot { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto; }
.ov-dot.ok { background: #36d399; } .ov-dot.ok { background: #36d399; }
.ov-dot.warn { background: #fbbd23; } .ov-dot.warn { background: #fbbd23; }

View File

@ -316,6 +316,13 @@ class OverviewManager {
if (oa) { if (oa) {
switch (oa.dataset.overviewAction) { switch (oa.dataset.overviewAction) {
case 'goto': case 'goto':
// Whole-row navigation is a convenience over the row's own button —
// don't fire it when the click was really the end of a text drag
// inside the row. (Clicks on the button itself always count.)
if (oa.classList.contains('ov-board-row')) {
const sel = window.getSelection && window.getSelection();
if (sel && !sel.isCollapsed && String(sel).trim()) break;
}
// Board rows can land on Updates pre-filtered (e.g. Security → the // Board rows can land on Updates pre-filtered (e.g. Security → the
// affected apps only), so honor an optional data-filter on the way. // affected apps only), so honor an optional data-filter on the way.
if (oa.dataset.filter) this.filter = oa.dataset.filter; if (oa.dataset.filter) this.filter = oa.dataset.filter;
@ -425,6 +432,7 @@ class OverviewManager {
text: `${waiting.length} update${waiting.length === 1 ? '' : 's'} waiting for you`, text: `${waiting.length} update${waiting.length === 1 ? '' : 's'} waiting for you`,
sub: `${esc(listOf(waiting))} — a recovery snapshot is taken before each update` sub: `${esc(listOf(waiting))} — a recovery snapshot is taken before each update`
+ (selfing ? ` · ${selfing} more will install automatically` : ''), + (selfing ? ` · ${selfing} more will install automatically` : ''),
nav: { tab: 'updates', filter: 'updates' },
actions: goto('updates', 'Review', 'updates') actions: goto('updates', 'Review', 'updates')
+ `<button class="updater-btn updater-btn-primary" data-updater-action="update-all">Update all</button>`, + `<button class="updater-btn updater-btn-primary" data-updater-action="update-all">Update all</button>`,
}); });
@ -438,6 +446,7 @@ class OverviewManager {
hue: 'updates', icon: '⬆️', kind: 'ok', hue: 'updates', icon: '⬆️', kind: 'ok',
text: `${pending.length} update${pending.length === 1 ? '' : 's'} installing automatically${winBit}`, text: `${pending.length} update${pending.length === 1 ? '' : 's'} installing automatically${winBit}`,
sub: `${esc(listOf(pending))} — each is snapshotted first, and rolled back if it fails`, sub: `${esc(listOf(pending))} — each is snapshotted first, and rolled back if it fails`,
nav: { tab: 'updates', filter: 'updates' },
actions: goto('updates', 'Review', 'updates'), actions: goto('updates', 'Review', 'updates'),
}); });
} }
@ -469,6 +478,7 @@ class OverviewManager {
text: `${u.totalCves} known CVE${u.totalCves === 1 ? '' : 's'} across ${hit} app${hit === 1 ? '' : 's'}`, text: `${u.totalCves} known CVE${u.totalCves === 1 ? '' : 's'} across ${hit} app${hit === 1 ? '' : 's'}`,
badge: worst ? ` <span class="updater-badge sev-${worst}">${worst}</span>` : '', badge: worst ? ` <span class="updater-badge sev-${worst}">${worst}</span>` : '',
sub: order.filter((s) => sev[s]).map((s) => `${sev[s]} ${s}`).join(' · '), sub: order.filter((s) => sev[s]).map((s) => `${sev[s]} ${s}`).join(' · '),
nav: { tab: 'updates', filter: 'security' },
actions: goto('updates', 'Review', 'security'), actions: goto('updates', 'Review', 'security'),
}); });
} else { } else {
@ -482,6 +492,7 @@ class OverviewManager {
hue: 'updater', icon: '✨', kind: 'warn', hue: 'updater', icon: '✨', kind: 'warn',
text: `${u.improvements} signed improvement${u.improvements === 1 ? '' : 's'} ready to apply`, text: `${u.improvements} signed improvement${u.improvements === 1 ? '' : 's'} ready to apply`,
sub: 'individually reversible — a snapshot is taken first', sub: 'individually reversible — a snapshot is taken first',
nav: { tab: 'improvements' },
actions: goto('improvements', 'View'), actions: goto('improvements', 'View'),
}); });
} else { } else {
@ -497,6 +508,7 @@ class OverviewManager {
hue: 'backups', icon: '💾', kind: 'warn', hue: 'backups', icon: '💾', kind: 'warn',
text: 'No backup location configured', text: 'No backup location configured',
sub: 'apps cant be protected until theres somewhere to back them up to', sub: 'apps cant be protected until theres somewhere to back them up to',
nav: { tab: 'backups' },
actions: goto('backups', 'Open Backups'), actions: goto('backups', 'Open Backups'),
}); });
} else if (b.never + b.old) { } else if (b.never + b.old) {
@ -505,6 +517,7 @@ class OverviewManager {
hue: 'backups', icon: '💾', kind: 'warn', hue: 'backups', icon: '💾', kind: 'warn',
text: `${n} app${n === 1 ? '' : 's'} need${n === 1 ? 's' : ''} a fresh backup`, text: `${n} app${n === 1 ? '' : 's'} need${n === 1 ? 's' : ''} a fresh backup`,
sub: [b.never && `${b.never} never backed up`, b.old && `${b.old} older than a week`].filter(Boolean).join(' · '), sub: [b.never && `${b.never} never backed up`, b.old && `${b.old} older than a week`].filter(Boolean).join(' · '),
nav: { tab: 'backups' },
actions: goto('backups', 'Open Backups'), actions: goto('backups', 'Open Backups'),
}); });
} else { } else {
@ -516,8 +529,17 @@ class OverviewManager {
return rows; return rows;
} }
// A row whose action is a plain "go to that tab" (Review / View / Open
// Backups) makes the WHOLE row a hit target for it — the button stays put as
// the visible affordance and the only focusable control, so the row is a
// mouse convenience layered on top rather than a nested role="button" (which
// would swallow the buttons it contains). Rows that only carry a real
// operation (Update all) or no action at all stay inert.
boardRow(r) { boardRow(r) {
return `<div class="ov-board-row ${r.kind}" style="--page: var(--page-${r.hue}); --page-rgb: var(--page-${r.hue}-rgb);"> const nav = r.nav
? ` data-overview-action="goto" data-tab="${r.nav.tab}"${r.nav.filter ? ` data-filter="${r.nav.filter}"` : ''}`
: '';
return `<div class="ov-board-row ${r.kind}${r.nav ? ' ov-clickable' : ''}"${nav} style="--page: var(--page-${r.hue}); --page-rgb: var(--page-${r.hue}-rgb);">
<span class="ov-dot ${r.kind}" aria-hidden="true"></span> <span class="ov-dot ${r.kind}" aria-hidden="true"></span>
<span class="ov-board-icon" aria-hidden="true">${r.icon}</span> <span class="ov-board-icon" aria-hidden="true">${r.icon}</span>
<div class="ov-board-main"> <div class="ov-board-main">