PIN pads: an Unlock button, Next to set 6-8 digits, bigger keys

A PIN can be 6, 7 or 8 digits, but the pads submitted on the last expected
digit, and setting one meant first picking its length. Now:
- The unlock prompt fills the dots for the PIN's length and sends it with
  Unlock (or Enter) beside Cancel, so a slip can still be fixed with the
  delete key first.
- Setting a PIN takes 6 to 8 digits with Next (the last two dots are marked
  optional), and the repeat step saves with Save PIN once it has as many.
- Keys are larger (76x56, 24px digits) and the dots a little bigger.
This commit is contained in:
Local Dev 2026-10-05 20:59:25 +02:00
parent ed441b4e9d
commit c4b3e77238
2 changed files with 44 additions and 24 deletions

View file

@ -272,15 +272,17 @@
.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;line-height:18px;min-height:36px;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-act .btn:disabled{opacity:.45;cursor:default}
.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}
.pinwiz-len button.on{border-color:var(--acid);color:var(--acid-text);background:rgb(from var(--acid) r g b / .12)} .pinwiz-len button.on{border-color:var(--acid);color:var(--acid-text);background:rgb(from var(--acid) r g b / .12)}
.pinpad{display:flex;flex-direction:column;align-items:center;gap:14px} .pinpad{display:flex;flex-direction:column;align-items:center;gap:14px}
.pinpad .pindots{display:flex;gap:12px} .pinpad .pindots{display:flex;gap:12px}
.pinpad .pindot{width:12px;height:12px;border-radius:50%;border:1.5px solid var(--dim);transition:background .12s,border-color .12s} .pinpad .pindot{width:14px;height:14px;border-radius:50%;border:1.5px solid var(--dim);transition:background .12s,border-color .12s}
.pinpad .pindot.optional{border-style:dashed;opacity:.6} /* digits 7 and 8: allowed, not needed */
.pinpad .pindot.on{background:var(--acid);border-color:var(--acid)} .pinpad .pindot.on{background:var(--acid);border-color:var(--acid)}
.pinpad .pinkeys{display:grid;grid-template-columns:repeat(3,62px);gap:8px} .pinpad .pinkeys{display:grid;grid-template-columns:repeat(3,76px);gap:10px}
.pinpad .pinkeys button{height:46px;border-radius:10px;border:1px solid var(--line);background:#1b2330;color:var(--ink);font:500 18px system-ui,sans-serif;cursor:pointer} .pinpad .pinkeys button{height:56px;border-radius:12px;border:1px solid var(--line);background:#1b2330;color:var(--ink);font:500 24px system-ui,sans-serif;cursor:pointer}
/* Keys only lighten on hover; an acid border lighting up on each key the pointer crossed read as flashing. */ /* Keys only lighten on hover; an acid border lighting up on each key the pointer crossed read as flashing. */
.pinpad .pinkeys button{transition:background-color .08s,transform .06s} .pinpad .pinkeys button{transition:background-color .08s,transform .06s}
.pinpad .pinkeys button:hover{background:#232c3b} .pinpad .pinkeys button:hover{background:#232c3b}
@ -2354,9 +2356,9 @@
// the dialog closes. // the dialog closes.
const pinWiz = { const pinWiz = {
el: document.getElementById("pinWiz"), el: document.getElementById("pinWiz"),
step: 0, master: "", first: "", entry: "", len: 6, busy: false, step: 0, master: "", first: "", entry: "", busy: false,
open() { open() {
Object.assign(this, { step: 1, master: "", first: "", entry: "", len: 6, busy: false }); Object.assign(this, { step: 1, master: "", first: "", entry: "", busy: false });
this.el.hidden = false; this.el.hidden = false;
this.render(); this.render();
}, },
@ -2365,6 +2367,11 @@
this.el.hidden = true; this.el.hidden = true;
}, },
error(msg) { document.getElementById("pinWizErr").textContent = msg || ""; }, error(msg) { document.getElementById("pinWizErr").textContent = msg || ""; },
// Step 2 takes 6 to 8 digits (eight dots, the first six marked as
// needed); step 3 must repeat exactly as many. Next, or Enter, moves on;
// nothing advances by itself, so a slip can still be fixed with ⌫.
get len() { return this.step === 3 ? this.first.length : 8; },
ready() { return this.step === 3 ? this.entry.length === this.first.length : this.entry.length >= 6; },
press(k) { press(k) {
if (this.busy || (this.step !== 2 && this.step !== 3)) return; if (this.busy || (this.step !== 2 && this.step !== 3)) return;
if (k === "back") this.entry = this.entry.slice(0, -1); if (k === "back") this.entry = this.entry.slice(0, -1);
@ -2372,14 +2379,15 @@
else if (this.entry.length < this.len) this.entry += k; else if (this.entry.length < this.len) this.entry += k;
this.error(""); this.error("");
this.paintDots(); this.paintDots();
if (this.entry.length === this.len) setTimeout(() => this.next(), 120);
}, },
paintDots() { paintDots() {
document.querySelectorAll("#pinWizBody .pindot").forEach((d, i) => d.classList.toggle("on", i < this.entry.length)); document.querySelectorAll("#pinWizBody .pindot").forEach((d, i) => d.classList.toggle("on", i < this.entry.length));
const nextBtn = document.getElementById("pinWizNext");
if (this.step === 2 || this.step === 3) nextBtn.disabled = !this.ready();
}, },
pad() { pad() {
const keys = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "clear", "0", "back"]; const keys = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "clear", "0", "back"];
return `<div class="pinpad"><div class="pindots">${Array.from({ length: this.len }, () => `<span class="pindot"></span>`).join("")}</div>` + return `<div class="pinpad"><div class="pindots">${Array.from({ length: this.len }, (_, i) => `<span class="pindot${this.step === 2 && i >= 6 ? " optional" : ""}"></span>`).join("")}</div>` +
`<div class="pinkeys">${keys.map((k) => `<button type="button" data-k="${k}" class="${k === "back" || k === "clear" ? "util" : ""}" aria-label="${k === "back" ? "Delete" : k === "clear" ? "Clear" : k}">${k === "back" ? "⌫" : k === "clear" ? "Clear" : k}</button>`).join("")}</div></div>`; `<div class="pinkeys">${keys.map((k) => `<button type="button" data-k="${k}" class="${k === "back" || k === "clear" ? "util" : ""}" aria-label="${k === "back" ? "Delete" : k === "clear" ? "Clear" : k}">${k === "back" ? "⌫" : k === "clear" ? "Clear" : k}</button>`).join("")}</div></div>`;
}, },
render() { render() {
@ -2397,16 +2405,14 @@
nextBtn.hidden = false; nextBtn.textContent = "Next"; nextBtn.hidden = false; nextBtn.textContent = "Next";
} else if (this.step === 2) { } else if (this.step === 2) {
title.textContent = "Choose a PIN"; title.textContent = "Choose a PIN";
text.textContent = "Pick how many digits, then type them. Longer is stronger."; text.textContent = "Type 6 to 8 digits, then press Next. Longer is stronger.";
body.innerHTML = `<div class="pinwiz-len">${[6, 7, 8].map((n) => `<button type="button" data-len="${n}" class="${n === this.len ? "on" : ""}">${n} digits</button>`).join("")}</div>` + this.pad(); body.innerHTML = this.pad();
body.querySelectorAll("[data-len]").forEach((b) => b.onclick = () => { this.len = +b.dataset.len; this.entry = ""; this.render(); }); nextBtn.hidden = false; nextBtn.textContent = "Next";
nextBtn.hidden = true;
} 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, then press Save.`;
// The same (hidden) length row as step 2, so the pad does not move up. body.innerHTML = this.pad();
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 = false; nextBtn.textContent = "Save PIN";
nextBtn.hidden = true;
} else if (this.step === 4) { } else if (this.step === 4) {
title.textContent = "PIN set"; title.textContent = "PIN set";
text.textContent = this.hardware === "tpm" text.textContent = this.hardware === "tpm"
@ -2416,6 +2422,8 @@
nextBtn.hidden = false; nextBtn.textContent = "Done"; nextBtn.hidden = false; nextBtn.textContent = "Done";
} }
body.querySelectorAll("[data-k]").forEach((b) => b.onclick = () => this.press(b.dataset.k)); body.querySelectorAll("[data-k]").forEach((b) => b.onclick = () => this.press(b.dataset.k));
nextBtn.disabled = false;
if (this.step === 2 || this.step === 3) this.paintDots(); // Next waits for enough digits
document.getElementById("pinWizCancel").hidden = this.step === 4; document.getElementById("pinWizCancel").hidden = this.step === 4;
}, },
async next() { async next() {
@ -2429,10 +2437,10 @@
if (!r.ok) return this.error("Wrong master password."); if (!r.ok) return this.error("Wrong master password.");
this.master = pw; this.step = 2; this.render(); this.master = pw; this.step = 2; this.render();
} else if (this.step === 2) { } else if (this.step === 2) {
if (this.entry.length !== this.len) return; if (!this.ready()) return this.error("Type at least 6 digits.");
this.first = this.entry; this.entry = ""; this.step = 3; this.render(); this.first = this.entry; this.entry = ""; this.step = 3; this.render();
} else if (this.step === 3) { } else if (this.step === 3) {
if (this.entry.length !== this.len) return; if (!this.ready()) return;
if (this.entry !== this.first) { if (this.entry !== this.first) {
this.first = ""; this.entry = ""; this.step = 2; this.render(); this.first = ""; this.entry = ""; this.step = 2; this.render();
return this.error("The PINs didn't match. Choose your PIN again."); return this.error("The PINs didn't match. Choose your PIN again.");
@ -2459,6 +2467,7 @@
if (e.key === "Escape") return pinWiz.close(); if (e.key === "Escape") return pinWiz.close();
if (/^[0-9]$/.test(e.key)) pinWiz.press(e.key); if (/^[0-9]$/.test(e.key)) pinWiz.press(e.key);
else if (e.key === "Backspace" && (pinWiz.step === 2 || pinWiz.step === 3)) pinWiz.press("back"); else if (e.key === "Backspace" && (pinWiz.step === 2 || pinWiz.step === 3)) pinWiz.press("back");
else if (e.key === "Enter" && (pinWiz.step === 2 || pinWiz.step === 3)) { e.preventDefault(); pinWiz.next(); }
}); });
document.getElementById("pinClearBtn").onclick = async () => { document.getElementById("pinClearBtn").onclick = async () => {
if (!confirm("Remove the PIN? The vault will need the master password again.")) return; if (!confirm("Remove the PIN? The vault will need the master password again.")) return;

View file

@ -27,14 +27,14 @@
/* The same PIN pad as Aegis (panel.html .pinpad): one PIN, one screen. */ /* 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 { display: flex; flex-direction: column; align-items: center; gap: 14px; margin: 6px 0 12px; }
.pinpad .pindots { display: flex; gap: 12px; } .pinpad .pindots { display: flex; gap: 12px; }
.pinpad .pindot { width: 12px; height: 12px; border-radius: 50%; border: 1.5px solid var(--dim); .pinpad .pindot { width: 14px; height: 14px; border-radius: 50%; border: 1.5px solid var(--dim);
background: transparent; transition: background .12s, border-color .12s; } background: transparent; transition: background .12s, border-color .12s; }
/* No glow, and keys only lighten on hover: a bright acid border lighting /* No glow, and keys only lighten on hover: a bright acid border lighting
up on whichever key the pointer crosses read as the pad flashing. */ up on whichever key the pointer crosses read as the pad flashing. */
.pinpad .pindot.on { background: var(--acid); border-color: var(--acid); } .pinpad .pindot.on { background: var(--acid); border-color: var(--acid); }
.pinpad .pinkeys { display: grid; grid-template-columns: repeat(3, 62px); gap: 8px; } .pinpad .pinkeys { display: grid; grid-template-columns: repeat(3, 76px); gap: 10px; }
.pinpad .pinkeys button { height: 46px; border-radius: 10px; border: 1px solid var(--line); .pinpad .pinkeys button { height: 56px; border-radius: 12px; border: 1px solid var(--line);
background: var(--surface2); color: var(--ink); font: 500 18px system-ui, sans-serif; cursor: pointer; } background: var(--surface2); color: var(--ink); font: 500 24px system-ui, sans-serif; cursor: pointer; }
.pinpad .pinkeys button { transition: background-color .08s, transform .06s; } .pinpad .pinkeys button { transition: background-color .08s, transform .06s; }
.pinpad .pinkeys button:hover { background: rgb(from var(--surface2) calc(r + 10) calc(g + 10) calc(b + 12)); } .pinpad .pinkeys button:hover { background: rgb(from var(--surface2) calc(r + 10) calc(g + 10) calc(b + 12)); }
.pinpad .pinkeys button:active { transform: scale(.96); } .pinpad .pinkeys button:active { transform: scale(.96); }
@ -49,6 +49,8 @@
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; line-height: 18px; min-height: 36px; 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; }
.pbtns { display: flex; gap: 6px; }
.pbtn.primary:disabled { background: var(--surface2); color: var(--dim); border-color: var(--line); opacity: 1; }
.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);
color: var(--ink); cursor: pointer; font: inherit; font-size: 12.5px; } color: var(--ink); cursor: pointer; font: inherit; font-size: 12.5px; }
@ -108,7 +110,6 @@
else if (pin.length < pinLength) pin += d; else if (pin.length < pinLength) pin += d;
error = ""; error = "";
paint(); paint();
if (pin.length === pinLength) submit(pin);
} }
// Keypresses and answers update the dialog in place. Rebuilding it on // Keypresses and answers update the dialog in place. Rebuilding it on
@ -121,7 +122,10 @@
if (ui.dots) ui.dots.forEach((d, i) => d.classList.toggle("on", i < pin.length)); if (ui.dots) ui.dots.forEach((d, i) => d.classList.toggle("on", i < pin.length));
ui.err.textContent = error; ui.err.textContent = error;
if (ui.pad) ui.pad.classList.toggle("busy", busy); if (ui.pad) ui.pad.classList.toggle("busy", busy);
if (ui.go) { ui.go.textContent = busy ? "Checking…" : "Unlock"; ui.go.disabled = busy; } if (ui.go) {
ui.go.textContent = busy ? "Checking…" : "Unlock";
ui.go.disabled = busy || (mode === "pin" && pin.length !== pinLength);
}
} }
function render() { function render() {
@ -144,10 +148,16 @@
ui.dots = [...dots.children]; ui.dots = [...dots.children];
ui.pad = pad; ui.pad = pad;
ui.err = el("div", { class: "pinerr", role: "alert" }, error); ui.err = el("div", { class: "pinerr", role: "alert" }, error);
// The PIN is sent with Unlock (or Enter) once every digit is in, not on
// the last keypress, so a slip can still be corrected with ⌫.
ui.go = el("button", { class: "pbtn primary", type: "button", onclick: () => { if (pin.length === pinLength) submit(pin); } }, "Unlock");
ui.go.disabled = pin.length !== pinLength;
bodyEls = [el("div", { class: "pinpad" }, dots, pad, ui.err), 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 = ""; pin = ""; 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("div", { class: "pbtns" },
el("button", { class: "pbtn", type: "button", onclick: () => done(true) }, "Cancel"),
ui.go))];
} 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");
@ -182,6 +192,7 @@
if (e.key === "Escape") return done(true); if (e.key === "Escape") return done(true);
if (mode === "pin" && /^[0-9]$/.test(e.key)) press(e.key); if (mode === "pin" && /^[0-9]$/.test(e.key)) press(e.key);
else if (mode === "pin" && e.key === "Backspace") press("back"); else if (mode === "pin" && e.key === "Backspace") press("back");
else if (mode === "pin" && e.key === "Enter") { e.preventDefault(); if (pin.length === pinLength && !busy) submit(pin); }
}); });
</script> </script>
</body> </body>