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,