diff --git a/web/public/app.js b/web/public/app.js
index ab9e50c..fa9c589 100644
--- a/web/public/app.js
+++ b/web/public/app.js
@@ -2458,11 +2458,11 @@ async function renderDashboard() {
Top contributors (most likely annual loss)
${contributors.map(({ f, ml }) => `
-
+
`).join('') || '
No findings in range.
'}
+ `).join('') || '
No findings in range.
'}
@@ -2506,6 +2506,36 @@ async function renderDashboard() {
$('#runFilter').value = tr.dataset.target;
renderSidebar();
}));
+ // A top contributor is a specific finding in a specific run — clicking it
+ // opens that run and pops the finding's full detail (evidence/impact/PoC),
+ // so "what is this loss" is one click, not a hunt through run history.
+ $$('#dashBody .contrib-click').forEach((row) => row.addEventListener('click', () =>
+ openContributorFinding(row.dataset.run, row.dataset.title, row.dataset.cwe)));
+}
+
+/// Open the run a dashboard contributor belongs to and surface that exact
+/// finding. The dashboard row carries only (runId, title, cwe) — the full
+/// finding lives in the run detail — so we load the run, then match by title
+/// (falling back to CWE) and open its modal.
+async function openContributorFinding(runId, title, cwe) {
+ if (!runId) return;
+ const run = (state.runs || []).find((r) => r.id === runId) || { id: runId, state: 'complete' };
+ // Switch to the run's detail view (this also refreshes the tables), and in
+ // parallel fetch the detail directly so we match the finding without racing
+ // loadDetail's own async fill of state.detailFindings.
+ openRun(run);
+ let detail;
+ try { detail = await api(`/api/runs/${encodeURIComponent(runId)}`); }
+ catch { return; }
+ const fs = detail.findings || [];
+ const norm = (s) => String(s || '').trim().toLowerCase();
+ let f = fs.find((x) => norm(x.title) === norm(title));
+ if (!f && cwe) f = fs.find((x) => norm(x.cwe) === norm(cwe));
+ if (f) {
+ openFindingModal(f, detail.pocs || [], runId);
+ } else {
+ toast('Opened the run — this finding was recalibrated or merged, so its row may differ.', 'warn', 6000);
+ }
}
/// The assumptions panel. FAIR without visible inputs is a magic number; with
diff --git a/web/public/style.css b/web/public/style.css
index 0c1f994..4f5f684 100644
--- a/web/public/style.css
+++ b/web/public/style.css
@@ -284,6 +284,12 @@ a { color: var(--accent); text-decoration: none; }
.fair-note { font-size: var(--fs-xs); color: var(--text-dim); margin-top: var(--sp-3); }
.contrib-row { display: flex; align-items: center; gap: var(--sp-3); padding: 5px 0; border-bottom: 1px solid var(--border); font-size: var(--fs-sm); }
.contrib-row:last-child { border-bottom: none; }
+/* Clickable contributor: a full-width button that reads like a row but opens
+ the finding. */
+button.contrib-row { width: 100%; text-align: left; background: transparent; border-left: none; border-right: none; border-top: none; color: var(--text); cursor: pointer; padding: 5px var(--sp-2); margin: 0 calc(-1 * var(--sp-2)); border-radius: var(--radius-xs); transition: background var(--t-fast); }
+button.contrib-row:hover { background: var(--surface-3); }
+button.contrib-row:hover .contrib-title { color: var(--accent); }
+button.contrib-row .contrib-v { margin-left: auto; }
.contrib-title { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.contrib-v { font-family: var(--mono); font-size: var(--fs-sm); }
.fair-params { display: flex; flex-direction: column; gap: var(--sp-2); }