From b8529f4a7fdf845e96896977596c11b7c5ba594f Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 21:18:26 +0200 Subject: [PATCH] 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. --- settings.html | 7 ++++--- unlock.html | 48 +++++++++++++++++++++++++++++++++++++----------- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/settings.html b/settings.html index 56e1dbad..6be7fcab 100644 --- a/settings.html +++ b/settings.html @@ -258,12 +258,12 @@ .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 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 span{flex:1;height:3px;border-radius:2px;background:var(--line)} .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-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-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} @@ -2288,7 +2288,8 @@ } else if (this.step === 3) { title.textContent = "Repeat your PIN"; 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 = `` + this.pad(); nextBtn.hidden = true; } else if (this.step === 4) { title.textContent = "PIN set"; diff --git a/unlock.html b/unlock.html index 1e3bbc1c..8fd23f30 100644 --- a/unlock.html +++ b/unlock.html @@ -36,11 +36,15 @@ 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.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); 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; } - .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; } .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); @@ -75,6 +79,7 @@ if (!req) return; const id = req.reqId; req = null; + ui = null; document.body.replaceChildren(); if (cancelled) window.unlock.cancel(id); } @@ -82,15 +87,15 @@ async function submit(value) { if (!req || busy) return; busy = true; - render(); + paint(); const r = await window.unlock.submit(req.reqId, mode, value).catch((e) => ({ ok: false, error: e.message })); 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"; if (r && r.mode) mode = r.mode; if (r && r.pinLength) pinLength = r.pinLength; pin = ""; - render(); + paint(); // re-renders only if the answer switched mode or PIN length } function press(d) { @@ -99,12 +104,27 @@ else if (d === "clear") pin = ""; else if (pin.length < pinLength) pin += d; error = ""; - render(); + paint(); 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() { if (!req) return; + const replaying = !!ui; // a mode switch inside the same prompt: no second pop-in + ui = { mode, pinLength }; const head = [ 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")), @@ -118,25 +138,30 @@ 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 }, 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("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"))]; } else { const input = el("input", { type: "password", placeholder: "Master password", autocomplete: "current-password" }); const go = el("button", { class: "pbtn primary", type: "submit" }, busy ? "Checking…" : "Unlock"); 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, - el("div", { class: "err" }, error), + ui.err, 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("button", { class: "pbtn", type: "button", onclick: () => done(true) }, "Cancel"), go))); bodyEls = [form]; setTimeout(() => input.focus(), 0); } 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) => { @@ -146,6 +171,7 @@ 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.` : ""; busy = false; + ui = null; // a new prompt: build it fresh, with its pop-in render(); }); document.addEventListener("keydown", (e) => {