feat(ext/setup): polished passphrase entry UX
Setup wizard step 3 now has self-explanatory passphrase feedback: - Strength meter: 5 segments with smooth color transitions (very-weak/weak/fair/good/strong). Tier 4 gets a subtle glow. - Nuanced label (lowercase, tracked): "very weak" / "weak" / "fair" / "good" / "strong" — color-matched to each tier. - Entropy readout line: "~10^N guesses — <time to crack>" with tiered shorthand (trivial / minutes-on-GPU / hours-to-days / years-on-consumer / beyond consumer / uncrackable). - Live char counter in the strength row. - Eye toggle buttons on both passphrase fields. Flip type="password" <-> type="text" without re-render, preserving focus + cursor. - Live match indicator (✓ / ✗) between the confirm field and its eye toggle. Updates per keystroke. - Create button gate widened: now requires score >= 3 AND confirm field filled AND confirm matches. Disabled button carries a tooltip saying which condition failed. - Contextual help box above the passphrase field explaining the "long phrase > complex password" idea + the score >= 3 threshold. All live-update paths (counter, label, entropy, match indicator, button gate) go through updateStrengthUi() which targets the DOM directly — no full re-render, so focus/cursor survive every keystroke. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -51,21 +51,21 @@
|
||||
.strength-bar {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
margin-top: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.strength-bar .seg {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
height: 5px;
|
||||
background: #21262d;
|
||||
border-radius: 2px;
|
||||
transition: background 0.2s;
|
||||
border-radius: 3px;
|
||||
transition: background 0.25s ease, box-shadow 0.25s ease;
|
||||
}
|
||||
|
||||
/* zxcvbn score-driven colors. Higher-scored bars light up earlier bars too. */
|
||||
.strength-bar.s0 .seg.i0 { background: #f85149; }
|
||||
.strength-bar.s1 .seg.i0,
|
||||
.strength-bar.s1 .seg.i1 { background: #db6d28; }
|
||||
.strength-bar.s1 .seg.i1 { background: #f08d49; }
|
||||
.strength-bar.s2 .seg.i0,
|
||||
.strength-bar.s2 .seg.i1,
|
||||
.strength-bar.s2 .seg.i2 { background: #d29922; }
|
||||
@@ -73,14 +73,101 @@
|
||||
.strength-bar.s3 .seg.i1,
|
||||
.strength-bar.s3 .seg.i2,
|
||||
.strength-bar.s3 .seg.i3 { background: #3fb950; }
|
||||
.strength-bar.s4 .seg { background: #3fb950; }
|
||||
.strength-bar.s4 .seg {
|
||||
background: #56d364;
|
||||
box-shadow: 0 0 4px rgba(86, 211, 100, 0.4);
|
||||
}
|
||||
|
||||
.strength-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.strength-label {
|
||||
font-size: 11px;
|
||||
margin-top: 3px;
|
||||
margin: 0;
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.03em;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
.strength-label.s-very-weak { color: #f85149; }
|
||||
.strength-label.s-weak { color: #f08d49; }
|
||||
.strength-label.s-fair { color: #d29922; }
|
||||
.strength-label.s-good { color: #3fb950; }
|
||||
.strength-label.s-strong { color: #56d364; font-weight: 600; }
|
||||
|
||||
.char-counter {
|
||||
font-size: 10px;
|
||||
color: #6e7681;
|
||||
margin: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.entropy-line {
|
||||
font-size: 10px;
|
||||
color: #8b949e;
|
||||
margin-top: 2px;
|
||||
font-family: "SF Mono", "JetBrains Mono", monospace;
|
||||
min-height: 1em;
|
||||
}
|
||||
|
||||
.pass-help {
|
||||
background: #0d1117;
|
||||
border: 1px solid #21262d;
|
||||
border-left: 2px solid #1f6feb;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
color: #8b949e;
|
||||
line-height: 1.55;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.pass-help strong { color: #c9d1d9; }
|
||||
|
||||
.passphrase-field {
|
||||
position: relative;
|
||||
}
|
||||
.passphrase-field input {
|
||||
padding-right: 76px; /* room for match indicator + eye button */
|
||||
}
|
||||
.eye-btn {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
background: transparent;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 3px;
|
||||
color: #8b949e;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
font-family: inherit;
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.eye-btn:hover { color: #c9d1d9; border-color: #484f58; }
|
||||
|
||||
.match-indicator {
|
||||
position: absolute;
|
||||
right: 50px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
transition: color 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
.match-indicator.ok { color: #3fb950; }
|
||||
.match-indicator.bad { color: #f85149; }
|
||||
|
||||
/* Primary button explicitly dims when disabled so the gate is obvious. */
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.strength-label.weak { color: #f85149; }
|
||||
.strength-label.strong { color: #3fb950; }
|
||||
|
||||
.success-box {
|
||||
background: #0d1b0e;
|
||||
|
||||
@@ -38,6 +38,9 @@ interface WizardState {
|
||||
passphraseConfirm: string;
|
||||
// zxcvbn meter state — -1 means "not yet scored" (empty passphrase).
|
||||
passphraseScore: number;
|
||||
passphraseGuessesLog10: number; // -1 before first rating
|
||||
passphraseVisible: boolean;
|
||||
confirmVisible: boolean;
|
||||
referenceImageBytes: Uint8Array | null;
|
||||
creating: boolean;
|
||||
error: string | null;
|
||||
@@ -56,6 +59,9 @@ const state: WizardState = {
|
||||
passphrase: '',
|
||||
passphraseConfirm: '',
|
||||
passphraseScore: -1,
|
||||
passphraseGuessesLog10: -1,
|
||||
passphraseVisible: false,
|
||||
confirmVisible: false,
|
||||
referenceImageBytes: null,
|
||||
creating: false,
|
||||
error: null,
|
||||
@@ -73,9 +79,11 @@ function escapeHtml(s: string): string {
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/// Call the SW to score a passphrase with zxcvbn. Returns a score in [0, 4]
|
||||
/// per the zxcvbn convention, or -1 if the message round-trip failed.
|
||||
function ratePassphrase(passphrase: string): Promise<number> {
|
||||
interface Strength { score: number; guessesLog10: number }
|
||||
|
||||
/// Call the SW to score a passphrase with zxcvbn. Returns score in [0, 4]
|
||||
/// and guesses_log10, or -1 on both if the round-trip failed.
|
||||
function ratePassphrase(passphrase: string): Promise<Strength> {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
chrome.runtime.sendMessage(
|
||||
@@ -84,20 +92,23 @@ function ratePassphrase(passphrase: string): Promise<number> {
|
||||
if (chrome.runtime.lastError) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[relicario setup] rate_passphrase lastError:', chrome.runtime.lastError);
|
||||
resolve(-1); return;
|
||||
resolve({ score: -1, guessesLog10: -1 }); return;
|
||||
}
|
||||
if (!response?.ok) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[relicario setup] rate_passphrase rejected by SW:', response);
|
||||
resolve(-1); return;
|
||||
resolve({ score: -1, guessesLog10: -1 }); return;
|
||||
}
|
||||
resolve(response.data?.score ?? -1);
|
||||
resolve({
|
||||
score: response.data?.score ?? -1,
|
||||
guessesLog10: response.data?.guesses_log10 ?? -1,
|
||||
});
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[relicario setup] rate_passphrase threw:', err);
|
||||
resolve(-1);
|
||||
resolve({ score: -1, guessesLog10: -1 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -105,36 +116,101 @@ function ratePassphrase(passphrase: string): Promise<number> {
|
||||
/// 150ms debounce around the rate_passphrase call so we don't hammer the SW
|
||||
/// on every keystroke. The last invocation wins.
|
||||
let rateDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
function scheduleRate(passphrase: string, onScore: (score: number) => void): void {
|
||||
function scheduleRate(passphrase: string, onResult: (s: Strength) => void): void {
|
||||
if (rateDebounceTimer !== null) clearTimeout(rateDebounceTimer);
|
||||
rateDebounceTimer = setTimeout(async () => {
|
||||
rateDebounceTimer = null;
|
||||
if (!passphrase) { onScore(-1); return; }
|
||||
onScore(await ratePassphrase(passphrase));
|
||||
if (!passphrase) { onResult({ score: -1, guessesLog10: -1 }); return; }
|
||||
onResult(await ratePassphrase(passphrase));
|
||||
}, 150);
|
||||
}
|
||||
|
||||
const STRENGTH_LABELS: Record<number, { text: string; cls: string }> = {
|
||||
0: { text: 'very weak', cls: 's-very-weak' },
|
||||
1: { text: 'weak', cls: 's-weak' },
|
||||
2: { text: 'fair', cls: 's-fair' },
|
||||
3: { text: 'good', cls: 's-good' },
|
||||
4: { text: 'strong', cls: 's-strong' },
|
||||
};
|
||||
|
||||
/// Render the entropy readout as "~10^N guesses to crack" or a friendlier
|
||||
/// shorthand for large values. Returns empty string when no data.
|
||||
function entropyText(guessesLog10: number): string {
|
||||
if (guessesLog10 < 0) return '';
|
||||
const rounded = Math.round(guessesLog10);
|
||||
if (rounded < 6) return `~10^${rounded} guesses — trivially crackable`;
|
||||
if (rounded < 9) return `~10^${rounded} guesses — minutes on a single GPU`;
|
||||
if (rounded < 12) return `~10^${rounded} guesses — hours to days on a GPU`;
|
||||
if (rounded < 15) return `~10^${rounded} guesses — years on consumer hardware`;
|
||||
if (rounded < 20) return `~10^${rounded} guesses — beyond consumer-hardware reach`;
|
||||
return `~10^${rounded} guesses — effectively uncrackable`;
|
||||
}
|
||||
|
||||
/// Update just the meter DOM without a full re-render (so the input keeps
|
||||
/// focus and the user's cursor position is preserved).
|
||||
/// focus and the user's cursor position is preserved). Also updates the
|
||||
/// char counter and confirm-match indicator live.
|
||||
function updateStrengthUi(): void {
|
||||
const bar = document.getElementById('strength-bar');
|
||||
const label = document.getElementById('strength-label');
|
||||
const entropy = document.getElementById('entropy-line');
|
||||
const counter = document.getElementById('passphrase-counter');
|
||||
const matchInd = document.getElementById('match-indicator');
|
||||
const create = document.getElementById('create-btn') as HTMLButtonElement | null;
|
||||
|
||||
const score = state.passphraseScore;
|
||||
const guessesLog10 = state.passphraseGuessesLog10;
|
||||
|
||||
if (bar) bar.className = `strength-bar${score >= 0 ? ` s${score}` : ''}`;
|
||||
|
||||
if (label) {
|
||||
if (score < 0) {
|
||||
label.className = 'strength-label';
|
||||
label.innerHTML = ' ';
|
||||
} else if (score >= 3) {
|
||||
label.className = 'strength-label strong';
|
||||
label.textContent = 'Strong enough';
|
||||
} else {
|
||||
label.className = 'strength-label weak';
|
||||
label.textContent = 'Too weak';
|
||||
const meta = STRENGTH_LABELS[score] ?? STRENGTH_LABELS[0];
|
||||
label.className = `strength-label ${meta.cls}`;
|
||||
label.textContent = meta.text;
|
||||
}
|
||||
}
|
||||
if (create) create.disabled = state.creating || score < 3;
|
||||
|
||||
if (entropy) {
|
||||
const txt = entropyText(guessesLog10);
|
||||
entropy.textContent = txt;
|
||||
entropy.style.visibility = txt ? 'visible' : 'hidden';
|
||||
}
|
||||
|
||||
if (counter) {
|
||||
const n = state.passphrase.length;
|
||||
counter.textContent = n === 0 ? '' : `${n} character${n === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
if (matchInd) {
|
||||
const p = state.passphrase;
|
||||
const c = state.passphraseConfirm;
|
||||
if (!p || !c) {
|
||||
matchInd.className = 'match-indicator';
|
||||
matchInd.textContent = '';
|
||||
} else if (p === c) {
|
||||
matchInd.className = 'match-indicator ok';
|
||||
matchInd.textContent = '✓';
|
||||
} else {
|
||||
matchInd.className = 'match-indicator bad';
|
||||
matchInd.textContent = '✗';
|
||||
}
|
||||
}
|
||||
|
||||
const matchOk = !state.passphraseConfirm || state.passphrase === state.passphraseConfirm;
|
||||
if (create) {
|
||||
const disabled = state.creating || score < 3 || !state.passphraseConfirm || !matchOk;
|
||||
create.disabled = disabled;
|
||||
create.title = disabled
|
||||
? (score < 3
|
||||
? 'passphrase must score "good" or better'
|
||||
: !state.passphraseConfirm ? 'confirm your passphrase'
|
||||
: !matchOk ? 'passphrases do not match'
|
||||
: '')
|
||||
: '';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Render ---
|
||||
@@ -320,17 +396,34 @@ function attachStep2(): void {
|
||||
|
||||
function renderStep3(): string {
|
||||
const score = state.passphraseScore;
|
||||
const guessesLog10 = state.passphraseGuessesLog10;
|
||||
const hasScore = score >= 0;
|
||||
const meterClass = hasScore ? `s${score}` : '';
|
||||
const labelClass = hasScore ? (score >= 3 ? 'strong' : 'weak') : '';
|
||||
const labelText = !hasScore
|
||||
? ' '
|
||||
: (score >= 3 ? 'Strong enough' : 'Too weak');
|
||||
const gateDisabled = state.creating || score < 3;
|
||||
const labelMeta = hasScore ? STRENGTH_LABELS[score] : null;
|
||||
const labelClass = labelMeta?.cls ?? '';
|
||||
const labelText = labelMeta?.text ?? ' ';
|
||||
const entropy = entropyText(guessesLog10);
|
||||
|
||||
const p = state.passphrase;
|
||||
const c = state.passphraseConfirm;
|
||||
const matchState = !p || !c ? '' : p === c ? 'ok' : 'bad';
|
||||
const matchGlyph = matchState === 'ok' ? '✓' : matchState === 'bad' ? '✗' : '';
|
||||
|
||||
const pType = state.passphraseVisible ? 'text' : 'password';
|
||||
const cType = state.confirmVisible ? 'text' : 'password';
|
||||
const pToggle = state.passphraseVisible ? 'hide' : 'show';
|
||||
const cToggle = state.confirmVisible ? 'hide' : 'show';
|
||||
|
||||
const matchOk = !c || p === c;
|
||||
const gateDisabled = state.creating || score < 3 || !c || !matchOk;
|
||||
|
||||
const nChars = p.length;
|
||||
const counterText = nChars === 0 ? '' : `${nChars} character${nChars === 1 ? '' : 's'}`;
|
||||
|
||||
return `
|
||||
<div class="wizard-step">
|
||||
<h3>create vault</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label">carrier image (JPEG)</label>
|
||||
<div class="file-drop ${state.carrierImageBytes ? 'has-file' : ''}" id="file-drop">
|
||||
@@ -341,9 +434,18 @@ function renderStep3(): string {
|
||||
</div>
|
||||
<p class="muted" style="margin-top:4px;">A 256-bit secret will be steganographically embedded in this image.</p>
|
||||
</div>
|
||||
|
||||
<div class="pass-help">
|
||||
A long phrase of unrelated words is stronger than a short complex password.
|
||||
Your vault needs <strong>good</strong> (score ≥ 3) to continue.
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="passphrase">passphrase</label>
|
||||
<input id="passphrase" type="password" value="${escapeHtml(state.passphrase)}" placeholder="enter a strong passphrase" autocomplete="new-password">
|
||||
<div class="passphrase-field">
|
||||
<input id="passphrase" type="${pType}" value="${escapeHtml(p)}" placeholder="enter a strong passphrase" autocomplete="new-password">
|
||||
<button type="button" class="eye-btn" id="eye-btn" aria-label="toggle passphrase visibility">${pToggle}</button>
|
||||
</div>
|
||||
<div class="strength-bar ${meterClass}" id="strength-bar" aria-hidden="true">
|
||||
<div class="seg i0"></div>
|
||||
<div class="seg i1"></div>
|
||||
@@ -351,12 +453,22 @@ function renderStep3(): string {
|
||||
<div class="seg i3"></div>
|
||||
<div class="seg i4"></div>
|
||||
</div>
|
||||
<p class="strength-label ${labelClass}" id="strength-label">${labelText}</p>
|
||||
<div class="strength-row">
|
||||
<p class="strength-label ${labelClass}" id="strength-label">${labelText}</p>
|
||||
<p class="char-counter" id="passphrase-counter">${escapeHtml(counterText)}</p>
|
||||
</div>
|
||||
<p class="entropy-line" id="entropy-line" style="visibility:${entropy ? 'visible' : 'hidden'};">${escapeHtml(entropy || ' ')}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="passphrase-confirm">confirm passphrase</label>
|
||||
<input id="passphrase-confirm" type="password" value="${escapeHtml(state.passphraseConfirm)}" placeholder="re-enter passphrase" autocomplete="new-password">
|
||||
<div class="passphrase-field">
|
||||
<input id="passphrase-confirm" type="${cType}" value="${escapeHtml(c)}" placeholder="re-enter passphrase" autocomplete="new-password">
|
||||
<span class="match-indicator ${matchState}" id="match-indicator">${matchGlyph}</span>
|
||||
<button type="button" class="eye-btn" id="confirm-eye-btn" aria-label="toggle confirm visibility">${cToggle}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn" id="back-btn">back</button>
|
||||
<button class="btn btn-primary" id="create-btn" ${gateDisabled ? 'disabled' : ''}>
|
||||
@@ -387,16 +499,41 @@ function attachStep3(): void {
|
||||
|
||||
// Track passphrase changes inline (no full re-render) so the input keeps focus.
|
||||
// zxcvbn score is computed via the SW on a 150ms debounce — see scheduleRate.
|
||||
document.getElementById('passphrase')?.addEventListener('input', (e) => {
|
||||
const passInput = document.getElementById('passphrase') as HTMLInputElement | null;
|
||||
passInput?.addEventListener('input', (e) => {
|
||||
state.passphrase = (e.target as HTMLInputElement).value;
|
||||
scheduleRate(state.passphrase, (score) => {
|
||||
state.passphraseScore = score;
|
||||
// Update char counter + match indicator + button gate immediately on every keystroke.
|
||||
updateStrengthUi();
|
||||
// Score updates on the 150ms debounce to avoid SW hammering.
|
||||
scheduleRate(state.passphrase, (s) => {
|
||||
state.passphraseScore = s.score;
|
||||
state.passphraseGuessesLog10 = s.guessesLog10;
|
||||
updateStrengthUi();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('passphrase-confirm')?.addEventListener('input', (e) => {
|
||||
const confirmInput = document.getElementById('passphrase-confirm') as HTMLInputElement | null;
|
||||
confirmInput?.addEventListener('input', (e) => {
|
||||
state.passphraseConfirm = (e.target as HTMLInputElement).value;
|
||||
updateStrengthUi();
|
||||
});
|
||||
|
||||
// Eye toggles — flip the input type and label without a full re-render so
|
||||
// focus + cursor position survive the click.
|
||||
document.getElementById('eye-btn')?.addEventListener('click', () => {
|
||||
state.passphraseVisible = !state.passphraseVisible;
|
||||
if (passInput) passInput.type = state.passphraseVisible ? 'text' : 'password';
|
||||
const btn = document.getElementById('eye-btn');
|
||||
if (btn) btn.textContent = state.passphraseVisible ? 'hide' : 'show';
|
||||
passInput?.focus();
|
||||
});
|
||||
|
||||
document.getElementById('confirm-eye-btn')?.addEventListener('click', () => {
|
||||
state.confirmVisible = !state.confirmVisible;
|
||||
if (confirmInput) confirmInput.type = state.confirmVisible ? 'text' : 'password';
|
||||
const btn = document.getElementById('confirm-eye-btn');
|
||||
if (btn) btn.textContent = state.confirmVisible ? 'hide' : 'show';
|
||||
confirmInput?.focus();
|
||||
});
|
||||
|
||||
document.getElementById('back-btn')?.addEventListener('click', () => {
|
||||
@@ -423,7 +560,9 @@ function attachStep3(): void {
|
||||
// Re-rate synchronously in case the button was clicked before the
|
||||
// debounced rater fired. Defence in depth — the button is already
|
||||
// disabled in the UI when score < 3 (audit H3).
|
||||
state.passphraseScore = await ratePassphrase(state.passphrase);
|
||||
const strength = await ratePassphrase(state.passphrase);
|
||||
state.passphraseScore = strength.score;
|
||||
state.passphraseGuessesLog10 = strength.guessesLog10;
|
||||
if (state.passphraseScore < 3) {
|
||||
state.error = 'Passphrase is too weak (zxcvbn score must be ≥ 3).';
|
||||
render();
|
||||
|
||||
Reference in New Issue
Block a user