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:
parent
3243add70e
commit
b8529f4a7f
2 changed files with 41 additions and 14 deletions
|
|
@ -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";
|
||||||
|
|
|
||||||
48
unlock.html
48
unlock.html
|
|
@ -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) => {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue