From 6bee69eb1a37c11991f329a63b6d0ce53b12f3de Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:32:37 +0100 Subject: [PATCH] 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. --- css/style.css | 32 ++++++++++++++++++++++++++++++++ js/app.health.js | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/css/style.css b/css/style.css index 976e9eb..756e78b 100644 --- a/css/style.css +++ b/css/style.css @@ -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 { diff --git a/js/app.health.js b/js/app.health.js index 6116de2..08a7924 100644 --- a/js/app.health.js +++ b/js/app.health.js @@ -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,