theseus/unlock.html
Local Dev b8529f4a7f 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.
2026-10-04 21:18:26 +02:00

185 lines
9.9 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Unlock</title>
<style>
:root { color-scheme: light dark;
--surface:#1c222c; --surface2:#0f1621; --line:rgba(255,255,255,.12);
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d; --danger:#f6768a; }
@media (prefers-color-scheme: light) {
:root { --surface:#ffffff; --surface2:#f1f4fa; --line:rgba(0,0,0,.12);
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; --acid: #0AC18E; }
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: transparent; }
body { font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; color: var(--ink); }
.promptmask { position: fixed; inset: 0; background: rgba(0,0,0,.45);
display: grid; place-items: center; padding: 16px; }
.promptbox { background: var(--surface); border: 1px solid var(--line); border-radius: 12px;
padding: 16px 18px 14px; width: min(380px, calc(100vw - 32px));
box-shadow: 0 20px 60px #000d; animation: pop .12s ease-out; }
@keyframes pop { from { transform: scale(.97); opacity: 0; } to { transform: none; opacity: 1; } }
.who { display: flex; align-items: center; gap: 8px; color: var(--dim); font-size: 11.5px; margin-bottom: 8px; }
.who .addon { color: var(--mut); }
.title { font-size: 15px; font-weight: 650; margin: 0 0 4px; }
.reason { color: var(--mut); margin: 0 0 14px; }
/* The same PIN pad as Aegis (panel.html .pinpad): one PIN, one screen. */
.pinpad { display: flex; flex-direction: column; align-items: center; gap: 14px; margin: 6px 0 12px; }
.pinpad .pindots { display: flex; gap: 12px; }
.pinpad .pindot { width: 12px; height: 12px; border-radius: 50%; border: 1.5px solid var(--dim);
background: transparent; transition: background .12s, border-color .12s; }
.pinpad .pindot.on { background: var(--acid); border-color: var(--acid);
box-shadow: 0 0 6px rgb(from var(--acid) r g b / .5); }
.pinpad .pinkeys { display: grid; grid-template-columns: repeat(3, 62px); gap: 8px; }
.pinpad .pinkeys button { height: 46px; border-radius: 10px; border: 1px solid var(--line);
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); }
/* 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; 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);
color: var(--ink); cursor: pointer; font: inherit; font-size: 12.5px; }
.pbtn.primary { background: var(--acid); color: #0b0e14; border-color: transparent; font-weight: 650; }
.pbtn:disabled { opacity: .5; cursor: default; }
</style>
</head>
<body>
<script>
// Built with createElement/textContent only: the add-on supplies the
// reason text, and it must never be able to inject markup into a prompt
// that asks for the master password.
const el = (tag, attrs = {}, ...kids) => {
const n = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === "class") n.className = v;
else if (k.startsWith("on")) n.addEventListener(k.slice(2), v);
else n.setAttribute(k, v);
}
for (const c of kids.flat()) if (c != null && c !== false) n.append(c instanceof Node ? c : String(c));
return n;
};
let req = null;
let mode = "pin";
let pin = "";
let pinLength = 6; // 6-8; the PIN record says which, so the pad submits on the last digit
let busy = false;
let error = "";
function done(cancelled) {
if (!req) return;
const id = req.reqId;
req = null;
ui = null;
document.body.replaceChildren();
if (cancelled) window.unlock.cancel(id);
}
async function submit(value) {
if (!req || busy) return;
busy = true;
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; 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 = "";
paint(); // re-renders only if the answer switched mode or PIN length
}
function press(d) {
if (busy) return;
if (d === "back") pin = pin.slice(0, -1);
else if (d === "clear") pin = "";
else if (pin.length < pinLength) pin += d;
error = "";
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")),
el("h1", { class: "title" }, mode === "pin" ? "Enter your PIN" : "Enter your master password"),
req.reason ? el("p", { class: "reason" }, req.reason) : null,
];
let bodyEls;
if (mode === "pin") {
const dots = el("div", { class: "pindots" }, ...Array.from({ length: pinLength }, (_, i) => el("span", { class: i < pin.length ? "pindot on" : "pindot" })));
const keys = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "clear", "0", "back"];
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)));
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 = ""; 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,
ui.err,
el("div", { class: "pact" },
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: replaying ? "promptbox still" : "promptbox", role: "dialog", "aria-modal": "true" }, ...head, ...bodyEls)));
}
window.unlock.onShow((r) => {
req = r;
mode = r.pinSet && !(r.lockedMs > 0) ? "pin" : "password";
pinLength = r.pinLength || 6;
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) => {
if (!req) return;
if (e.key === "Escape") return done(true);
if (mode === "pin" && /^[0-9]$/.test(e.key)) press(e.key);
else if (mode === "pin" && e.key === "Backspace") press("back");
});
</script>
</body>
</html>