feat(health): show the per-category breakdown under the score

Each category is capped, so past the cap fixing one more password leaves the total unchanged and the work looks wasted. The breakdown lists count and points per category, marks a capped deduction with (max), and explains in a tooltip why the score is standing still.
This commit is contained in:
r-zakarya
2026-08-01 19:32:37 +01:00
parent 2436825ed6
commit 6bee69eb1a
2 changed files with 65 additions and 1 deletions
+32
View File
@@ -1351,6 +1351,38 @@ input[type="range"]::-webkit-slider-thumb {
text-transform: uppercase; letter-spacing: 0.5px;
margin-top: 2px;
}
/* Score dial + its per-category breakdown, stacked. */
.health-score-col {
display: flex; flex-direction: column;
align-items: center; gap: 10px;
flex-shrink: 0;
}
.health-score-breakdown {
display: flex; flex-direction: column;
gap: 2px;
font-size: 11px;
color: var(--text-dim);
white-space: nowrap;
}
/* Each row keeps its own box: a display:contents row would have none, and the
title tooltip explaining the cap would never appear. */
.health-bd-row {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 0 10px;
cursor: help;
}
.health-bd-n { text-align: right; font-variant-numeric: tabular-nums; }
.health-bd-pts {
text-align: right;
font-family: 'JetBrains Mono', ui-monospace, monospace;
color: var(--text-faint);
}
/* Nothing wrong in this category — dim it so the eye goes to what needs work. */
.health-bd-row.is-clear .health-bd-lbl,
.health-bd-row.is-clear .health-bd-n,
.health-bd-row.is-clear .health-bd-pts { opacity: 0.45; }
.health-intro { flex: 1; }
.health-intro h3 { margin: 0 0 6px; font-size: 16px; }
.health-intro p {
+33 -1
View File
@@ -129,7 +129,39 @@ async function renderHealthDashboard(grid) {
const scoreEl = el('div', { class: 'health-score ' + band.cls });
scoreEl.appendChild(el('div', { class: 'health-score-num' }, String(h.score)));
scoreEl.appendChild(el('div', { class: 'health-score-lbl' }, band.label));
header.appendChild(scoreEl);
// Per-category breakdown. Each category is capped, so once a cap is reached
// fixing one more password leaves the total untouched — which reads as "my
// work did nothing". Showing the raw deduction next to the capped one makes
// the progress visible even when the score sits still.
const parts = [
{ lbl: 'Weak', n: h.weak.length, per: 5, cap: 40 },
{ lbl: 'Reused', n: h.reused.length, per: 10, cap: 30 },
{ lbl: 'Old', n: h.old.length, per: 2, cap: 20 },
{ lbl: 'Pwned', n: h.pwned.length, per: 15, cap: 50 },
];
const breakdown = el('div', { class: 'health-score-breakdown' });
parts.forEach(p => {
const raw = p.n * p.per;
const applied = Math.min(p.cap, raw);
const row = el('div', {
class: 'health-bd-row' + (p.n === 0 ? ' is-clear' : ''),
title: raw > applied
? p.lbl + ': ' + p.n + ' × ' + p.per + ' = −' + raw +
', capped at −' + p.cap + '. Fixing one more won\'t move the ' +
'score until you drop below the cap.'
: p.lbl + ': ' + p.n + ' × ' + p.per + ' points',
});
row.appendChild(el('span', { class: 'health-bd-lbl' }, p.lbl));
row.appendChild(el('span', { class: 'health-bd-n' }, String(p.n)));
row.appendChild(el('span', { class: 'health-bd-pts' },
applied === 0 ? '0' : '−' + applied + (raw > applied ? ' (max)' : '')));
breakdown.appendChild(row);
});
// Under the dial, not inside it — the circle is a fixed 110px.
const scoreCol = el('div', { class: 'health-score-col' });
scoreCol.appendChild(scoreEl);
scoreCol.appendChild(breakdown);
header.appendChild(scoreCol);
const intro = el('div', { class: 'health-intro' });
intro.appendChild(el('h3', null, 'How healthy is your vault?'));
intro.appendChild(el('p', null,