ext(affordances): wirePasswordStrength via scheduleRate
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user