theseus/unlock.html
Local Dev b4362f2e54 PIN pads: calm hover and press states
Moving the pointer across the keypad lit each key it crossed with a bright
acid border and digit, and every filled dot glowed, which read as the pad
flashing while a PIN was typed. Keys now only lighten slightly on hover and
press in a little when clicked; dots fill without the glow.
2026-10-05 20:28:20 +02:00

188 lines
10 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; }
/* 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. */
.pinpad .pindot.on { background: var(--acid); border-color: var(--acid); }
.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 { 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:active { transform: scale(.96); }
.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>