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) => {