PIN pads: stop the keypad jumping on every press

The vault prompt rebuilt its whole dialog on each digit, which replayed the
pop-in animation, and because the dialog is centred, every change in the
message line's height moved the whole pad under the user's finger. Presses
and answers now update the dots and message in place, the message lines
keep a fixed two-line height, and switching between PIN and password clears
a half-typed PIN. The Settings PIN dialog keeps the 6/7/8 row's space on the
repeat step so the pad stays put between steps. Measured on a scratch
profile: key positions identical across presses, errors and steps.
This commit is contained in:
Local Dev 2026-10-04 21:18:26 +02:00
parent 3243add70e
commit b8529f4a7f
2 changed files with 41 additions and 14 deletions

View file

@ -258,12 +258,12 @@
.pinwiz[hidden], .row[hidden]{display:none} .pinwiz[hidden], .row[hidden]{display:none}
.pinwiz-box{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:20px 22px 16px;width:min(400px,100%);box-shadow:0 20px 60px #000c} .pinwiz-box{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:20px 22px 16px;width:min(400px,100%);box-shadow:0 20px 60px #000c}
.pinwiz-box h3{margin:6px 0 4px;font-size:17px} .pinwiz-box h3{margin:6px 0 4px;font-size:17px}
.pinwiz-text{color:var(--mut);font-size:13px;margin:0 0 14px} .pinwiz-text{color:var(--mut);font-size:13px;line-height:20px;min-height:40px;margin:0 0 14px}
.pinwiz-steps{display:flex;gap:6px} .pinwiz-steps{display:flex;gap:6px}
.pinwiz-steps span{flex:1;height:3px;border-radius:2px;background:var(--line)} .pinwiz-steps span{flex:1;height:3px;border-radius:2px;background:var(--line)}
.pinwiz-steps span.on{background:var(--acid)} .pinwiz-steps span.on{background:var(--acid)}
.pinwiz-box input[type=password]{width:100%;box-sizing:border-box;padding:10px 12px;border-radius:8px;border:1px solid var(--line);background:#1b2330;color:var(--ink);font:inherit;font-size:14px} .pinwiz-box input[type=password]{width:100%;box-sizing:border-box;padding:10px 12px;border-radius:8px;border:1px solid var(--line);background:#1b2330;color:var(--ink);font:inherit;font-size:14px}
.pinwiz-err{color:#f6768a;font-size:12.5px;min-height:18px;margin:8px 0 2px;text-align:center} .pinwiz-err{color:#f6768a;font-size:12.5px;line-height:18px;min-height:36px;margin:8px 0 2px;text-align:center}
.pinwiz-act{display:flex;justify-content:space-between;gap:8px;margin-top:6px} .pinwiz-act{display:flex;justify-content:space-between;gap:8px;margin-top:6px}
.pinwiz-len{display:flex;justify-content:center;gap:6px;margin:0 0 12px} .pinwiz-len{display:flex;justify-content:center;gap:6px;margin:0 0 12px}
.pinwiz-len button{border:1px solid var(--line);background:transparent;color:var(--mut);border-radius:999px;padding:4px 12px;font-size:12.5px;cursor:pointer} .pinwiz-len button{border:1px solid var(--line);background:transparent;color:var(--mut);border-radius:999px;padding:4px 12px;font-size:12.5px;cursor:pointer}
@ -2288,7 +2288,8 @@
} else if (this.step === 3) { } else if (this.step === 3) {
title.textContent = "Repeat your PIN"; title.textContent = "Repeat your PIN";
text.textContent = `Type the same ${this.len} digits again.`; text.textContent = `Type the same ${this.len} digits again.`;
body.innerHTML = this.pad(); // The same (hidden) length row as step 2, so the pad does not move up.
body.innerHTML = `<div class="pinwiz-len" style="visibility:hidden" aria-hidden="true"><button type="button" tabindex="-1">8 digits</button></div>` + this.pad();
nextBtn.hidden = true; nextBtn.hidden = true;
} else if (this.step === 4) { } else if (this.step === 4) {
title.textContent = "PIN set"; title.textContent = "PIN set";

View file

@ -36,11 +36,15 @@
background: var(--surface2); color: var(--ink); font: 500 18px system-ui, sans-serif; cursor: pointer; } background: var(--surface2); color: var(--ink); font: 500 18px system-ui, sans-serif; cursor: pointer; }
.pinpad .pinkeys button:hover { border-color: var(--acid); color: var(--acid); } .pinpad .pinkeys button:hover { border-color: var(--acid); color: var(--acid); }
.pinpad .pinkeys button.util { background: transparent; font-size: 13px; color: var(--dim); } .pinpad .pinkeys button.util { background: transparent; font-size: 13px; color: var(--dim); }
.pinpad .pinerr { color: var(--danger); font-size: 12px; text-align: center; min-height: 16px; } /* Two lines reserved, so a message appearing never changes the dialog's
height (it is centred, so any height change moves the whole pad). */
.pinpad .pinerr { color: var(--danger); font-size: 12px; line-height: 16px; text-align: center; min-height: 32px; max-width: 220px; }
.pinpad .pinkeys.busy { opacity: .55; pointer-events: none; }
.promptbox.still { animation: none; }
input[type=password] { width: 100%; padding: 9px 11px; border-radius: 8px; border: 1px solid var(--line); input[type=password] { width: 100%; padding: 9px 11px; border-radius: 8px; border: 1px solid var(--line);
background: var(--surface2); color: var(--ink); font: inherit; font-size: 14px; margin-bottom: 10px; } background: var(--surface2); color: var(--ink); font: inherit; font-size: 14px; margin-bottom: 10px; }
input:focus, button:focus-visible { outline: 2px solid rgb(from var(--acid) r g b / .6); outline-offset: 1px; } input:focus, button:focus-visible { outline: 2px solid rgb(from var(--acid) r g b / .6); outline-offset: 1px; }
.err { color: var(--danger); font-size: 12.5px; min-height: 18px; margin-bottom: 6px; text-align: center; } .err { color: var(--danger); font-size: 12.5px; line-height: 18px; min-height: 36px; margin-bottom: 6px; text-align: center; }
.pact { display: flex; gap: 6px; justify-content: space-between; align-items: center; } .pact { display: flex; gap: 6px; justify-content: space-between; align-items: center; }
.link { background: none; border: none; color: var(--mut); cursor: pointer; font: inherit; font-size: 12.5px; padding: 0; text-decoration: underline; } .link { background: none; border: none; color: var(--mut); cursor: pointer; font: inherit; font-size: 12.5px; padding: 0; text-decoration: underline; }
.pbtn { padding: 7px 14px; border-radius: 7px; border: 1px solid var(--line); background: var(--surface2); .pbtn { padding: 7px 14px; border-radius: 7px; border: 1px solid var(--line); background: var(--surface2);
@ -75,6 +79,7 @@
if (!req) return; if (!req) return;
const id = req.reqId; const id = req.reqId;
req = null; req = null;
ui = null;
document.body.replaceChildren(); document.body.replaceChildren();
if (cancelled) window.unlock.cancel(id); if (cancelled) window.unlock.cancel(id);
} }
@ -82,15 +87,15 @@
async function submit(value) { async function submit(value) {
if (!req || busy) return; if (!req || busy) return;
busy = true; busy = true;
render(); paint();
const r = await window.unlock.submit(req.reqId, mode, value).catch((e) => ({ ok: false, error: e.message })); const r = await window.unlock.submit(req.reqId, mode, value).catch((e) => ({ ok: false, error: e.message }));
busy = false; busy = false;
if (r && r.ok) { req = null; document.body.replaceChildren(); return; } if (r && r.ok) { req = null; ui = null; document.body.replaceChildren(); return; }
error = (r && r.error) || "Could not unlock"; error = (r && r.error) || "Could not unlock";
if (r && r.mode) mode = r.mode; if (r && r.mode) mode = r.mode;
if (r && r.pinLength) pinLength = r.pinLength; if (r && r.pinLength) pinLength = r.pinLength;
pin = ""; pin = "";
render(); paint(); // re-renders only if the answer switched mode or PIN length
} }
function press(d) { function press(d) {
@ -99,12 +104,27 @@
else if (d === "clear") pin = ""; else if (d === "clear") pin = "";
else if (pin.length < pinLength) pin += d; else if (pin.length < pinLength) pin += d;
error = ""; error = "";
render(); paint();
if (pin.length === pinLength) submit(pin); if (pin.length === pinLength) submit(pin);
} }
// Keypresses and answers update the dialog in place. Rebuilding it on
// every press replayed its pop-in animation and re-centred it whenever the
// message changed height, so the pad jumped under the user's finger.
let ui = null; // { mode, pinLength, dots[], err, go }
function paint() {
if (!req) return;
if (!ui || ui.mode !== mode || ui.pinLength !== pinLength) return render();
if (ui.dots) ui.dots.forEach((d, i) => d.classList.toggle("on", i < pin.length));
ui.err.textContent = error;
if (ui.pad) ui.pad.classList.toggle("busy", busy);
if (ui.go) { ui.go.textContent = busy ? "Checking…" : "Unlock"; ui.go.disabled = busy; }
}
function render() { function render() {
if (!req) return; if (!req) return;
const replaying = !!ui; // a mode switch inside the same prompt: no second pop-in
ui = { mode, pinLength };
const head = [ const head = [
el("div", { class: "who" }, el("span", {}, "🔒"), el("span", { class: "addon" }, req.addonName || "Theseus"), el("span", {}, "·"), el("div", { class: "who" }, el("span", {}, "🔒"), el("span", { class: "addon" }, req.addonName || "Theseus"), el("span", {}, "·"),
el("span", {}, req.confirm ? "asks you to confirm it's you" : "asks to unlock your vault")), el("span", {}, req.confirm ? "asks you to confirm it's you" : "asks to unlock your vault")),
@ -118,25 +138,30 @@
const pad = el("div", { class: "pinkeys" }, ...keys.map((k) => const pad = el("div", { class: "pinkeys" }, ...keys.map((k) =>
el("button", { type: "button", class: k === "back" || k === "clear" ? "util" : "", onclick: () => press(k), "aria-label": k === "back" ? "Delete" : k === "clear" ? "Clear" : k }, el("button", { type: "button", class: k === "back" || k === "clear" ? "util" : "", onclick: () => press(k), "aria-label": k === "back" ? "Delete" : k === "clear" ? "Clear" : k },
k === "back" ? "⌫" : k === "clear" ? "Clear" : k))); k === "back" ? "⌫" : k === "clear" ? "Clear" : k)));
bodyEls = [el("div", { class: "pinpad" }, dots, pad, el("div", { class: "pinerr" }, error)), ui.dots = [...dots.children];
ui.pad = pad;
ui.err = el("div", { class: "pinerr", role: "alert" }, error);
bodyEls = [el("div", { class: "pinpad" }, dots, pad, ui.err),
el("div", { class: "pact" }, el("div", { class: "pact" },
el("button", { class: "link", type: "button", onclick: () => { mode = "password"; error = ""; render(); } }, "Use master password"), el("button", { class: "link", type: "button", onclick: () => { mode = "password"; error = ""; pin = ""; render(); } }, "Use master password"),
el("button", { class: "pbtn", type: "button", onclick: () => done(true) }, "Cancel"))]; el("button", { class: "pbtn", type: "button", onclick: () => done(true) }, "Cancel"))];
} else { } else {
const input = el("input", { type: "password", placeholder: "Master password", autocomplete: "current-password" }); const input = el("input", { type: "password", placeholder: "Master password", autocomplete: "current-password" });
const go = el("button", { class: "pbtn primary", type: "submit" }, busy ? "Checking…" : "Unlock"); const go = el("button", { class: "pbtn primary", type: "submit" }, busy ? "Checking…" : "Unlock");
if (busy) go.setAttribute("disabled", ""); if (busy) go.setAttribute("disabled", "");
ui.go = go;
ui.err = el("div", { class: "err", role: "alert" }, error);
const form = el("form", { onsubmit: (e) => { e.preventDefault(); if (input.value) submit(input.value); } }, input, const form = el("form", { onsubmit: (e) => { e.preventDefault(); if (input.value) submit(input.value); } }, input,
el("div", { class: "err" }, error), ui.err,
el("div", { class: "pact" }, el("div", { class: "pact" },
req.pinSet && !(req.lockedMs > 0) && mode === "password" ? el("button", { class: "link", type: "button", onclick: () => { mode = "pin"; error = ""; render(); } }, "Use PIN") : el("span"), req.pinSet && !(req.lockedMs > 0) && mode === "password" ? el("button", { class: "link", type: "button", onclick: () => { mode = "pin"; error = ""; pin = ""; render(); } }, "Use PIN") : el("span"),
el("div", { style: "display:flex;gap:6px" }, el("div", { style: "display:flex;gap:6px" },
el("button", { class: "pbtn", type: "button", onclick: () => done(true) }, "Cancel"), go))); el("button", { class: "pbtn", type: "button", onclick: () => done(true) }, "Cancel"), go)));
bodyEls = [form]; bodyEls = [form];
setTimeout(() => input.focus(), 0); setTimeout(() => input.focus(), 0);
} }
document.body.replaceChildren(el("div", { class: "promptmask", onmousedown: (e) => { if (e.target.classList.contains("promptmask")) done(true); } }, document.body.replaceChildren(el("div", { class: "promptmask", onmousedown: (e) => { if (e.target.classList.contains("promptmask")) done(true); } },
el("div", { class: "promptbox", role: "dialog", "aria-modal": "true" }, ...head, ...bodyEls))); el("div", { class: replaying ? "promptbox still" : "promptbox", role: "dialog", "aria-modal": "true" }, ...head, ...bodyEls)));
} }
window.unlock.onShow((r) => { window.unlock.onShow((r) => {
@ -146,6 +171,7 @@
pin = ""; pin = "";
error = r.pinSet && r.lockedMs > 0 ? `Too many failed attempts. Try again in ${Math.max(1, Math.ceil(r.lockedMs / 60000))} min or use the master password.` : ""; error = r.pinSet && r.lockedMs > 0 ? `Too many failed attempts. Try again in ${Math.max(1, Math.ceil(r.lockedMs / 60000))} min or use the master password.` : "";
busy = false; busy = false;
ui = null; // a new prompt: build it fresh, with its pop-in
render(); render();
}); });
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {