ext(affordances): wirePasswordStrength via scheduleRate

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-05-01 19:50:18 -04:00
parent 026b94092e
commit 7bd1a9dd7d
4 changed files with 138 additions and 2 deletions

View File

@@ -1385,3 +1385,29 @@ textarea {
.hostname-text { .hostname-text {
font-family: ui-monospace, monospace; font-family: ui-monospace, monospace;
} }
.strength-bar-row {
margin-top: 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.strength-bar {
display: flex;
gap: 3px;
height: 4px;
}
.strength-bar > span {
flex: 1;
background: var(--border-subtle);
border-radius: 2px;
}
.strength-bar.s-very-weak > span.lit { background: #c75a4f; }
.strength-bar.s-weak > span.lit { background: #c75a4f; }
.strength-bar.s-fair > span.lit { background: #d49b3a; }
.strength-bar.s-good > span.lit { background: #d49b3a; }
.strength-bar.s-strong > span.lit { background: #6cb37a; }
.strength-label {
font-size: 11px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { wirePasswordReveal } from '../password-tools'; import { wirePasswordReveal, wirePasswordStrength } from '../password-tools';
describe('wirePasswordReveal', () => { describe('wirePasswordReveal', () => {
let form: HTMLElement; let form: HTMLElement;
@@ -47,3 +47,48 @@ describe('wirePasswordReveal', () => {
expect(btn.title).toBe('reveal'); expect(btn.title).toBe('reveal');
}); });
}); });
describe('wirePasswordStrength', () => {
let form: HTMLElement;
let scheduleRate: ReturnType<typeof vi.fn>;
beforeEach(() => {
form = document.createElement('div');
form.innerHTML = `
<input id="f-password" type="password" value="" />
<div id="strength-bar-row" class="strength-bar-row" hidden>
<div class="strength-bar"><span></span><span></span><span></span><span></span><span></span></div>
<div class="strength-label"></div>
</div>
`;
document.body.appendChild(form);
scheduleRate = vi.fn();
});
afterEach(() => {
document.body.removeChild(form);
});
it('shows bar with score class on input', () => {
scheduleRate.mockImplementation((_pw, cb) => cb({ score: 3, guessesLog10: 11.4 }));
wirePasswordStrength(form, { scheduleRate });
const input = form.querySelector('#f-password') as HTMLInputElement;
input.value = 'CorrectHorseBatteryStaple';
input.dispatchEvent(new Event('input'));
const row = form.querySelector('#strength-bar-row') as HTMLElement;
expect(row.hidden).toBe(false);
expect(row.querySelector('.strength-bar')?.className).toContain('s-good');
expect(row.querySelector('.strength-label')?.textContent).toContain('good');
expect(row.querySelector('.strength-label')?.textContent).toContain('10^11');
});
it('hides bar when input is empty', () => {
scheduleRate.mockImplementation((_pw, cb) => cb({ score: -1, guessesLog10: -1 }));
wirePasswordStrength(form, { scheduleRate });
const input = form.querySelector('#f-password') as HTMLInputElement;
input.value = '';
input.dispatchEvent(new Event('input'));
const row = form.querySelector('#strength-bar-row') as HTMLElement;
expect(row.hidden).toBe(true);
});
});

View File

@@ -1,4 +1,5 @@
import { GLYPH_REVEAL, GLYPH_HIDE } from '../glyphs'; import { GLYPH_REVEAL, GLYPH_HIDE } from '../glyphs';
import { STRENGTH_LABELS, entropyText, type Strength } from '../../setup/setup-helpers';
/// Returns a teardown fn the caller must invoke on unmount. /// Returns a teardown fn the caller must invoke on unmount.
export function wirePasswordReveal(form: HTMLElement): () => void { export function wirePasswordReveal(form: HTMLElement): () => void {
@@ -26,3 +27,41 @@ export function wirePasswordReveal(form: HTMLElement): () => void {
btn.title = 'reveal'; btn.title = 'reveal';
}; };
} }
export interface PasswordStrengthOpts {
scheduleRate: (passphrase: string, cb: (s: Strength) => void) => void;
}
export function wirePasswordStrength(form: HTMLElement, opts: PasswordStrengthOpts): void {
const input = form.querySelector<HTMLInputElement>('#f-password');
const row = form.querySelector<HTMLElement>('#strength-bar-row');
if (!input || !row) return;
const bar = row.querySelector<HTMLElement>('.strength-bar');
const label = row.querySelector<HTMLElement>('.strength-label');
if (!bar || !label) return;
const update = () => {
const v = input.value;
if (!v) {
row.hidden = true;
return;
}
opts.scheduleRate(v, (s) => {
if (s.score < 0) { row.hidden = true; return; }
row.hidden = false;
// Reset score classes, then add the current one to the bar element.
bar.className = 'strength-bar';
const cls = STRENGTH_LABELS[s.score]?.cls ?? 's-very-weak';
bar.classList.add(cls);
// Light up segments 0..score (5-segment bar).
Array.from(bar.children).forEach((seg, i) => {
(seg as HTMLElement).classList.toggle('lit', i <= s.score);
});
const text = STRENGTH_LABELS[s.score]?.text ?? '?';
label.textContent = `${text} · ${entropyText(s.guessesLog10)}`;
});
};
input.addEventListener('input', update);
update();
}

View File

@@ -1415,3 +1415,29 @@ textarea {
.hostname-text { .hostname-text {
font-family: ui-monospace, monospace; font-family: ui-monospace, monospace;
} }
.strength-bar-row {
margin-top: 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.strength-bar {
display: flex;
gap: 3px;
height: 4px;
}
.strength-bar > span {
flex: 1;
background: var(--border-subtle);
border-radius: 2px;
}
.strength-bar.s-very-weak > span.lit { background: #c75a4f; }
.strength-bar.s-weak > span.lit { background: #c75a4f; }
.strength-bar.s-fair > span.lit { background: #d49b3a; }
.strength-bar.s-good > span.lit { background: #d49b3a; }
.strength-bar.s-strong > span.lit { background: #6cb37a; }
.strength-label {
font-size: 11px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}