website: redesign part 33

This commit is contained in:
nym21
2026-06-21 17:12:25 +02:00
parent 2e401379a0
commit b3031b3375
31 changed files with 818 additions and 417 deletions
+29
View File
@@ -0,0 +1,29 @@
dialog {
--dialog-space: 1.5rem;
width: min(100% - 2rem, 30rem);
border: 0;
border-radius: var(--dialog-space);
padding: var(--dialog-space);
color: var(--black);
background: var(--white);
&::backdrop {
background: color-mix(in oklch, var(--black) 72%, transparent);
}
button {
color: var(--white);
background: var(--gray);
&:hover {
color: var(--white);
background: var(--black);
}
&:active {
color: var(--white);
background: var(--orange);
}
}
}
-19
View File
@@ -17,24 +17,5 @@ main.home {
font-size: var(--font-size-xs);
line-height: 1;
text-transform: uppercase;
a {
display: block;
padding: 0.75rem 1rem;
border-radius: 0.3125rem;
color: var(--white);
background: var(--gray);
text-decoration: none;
&:hover {
color: var(--black);
background: var(--white);
}
&:active {
color: var(--black);
background: var(--orange);
}
}
}
}
+3 -3
View File
@@ -116,14 +116,14 @@
<link rel="stylesheet" href="/learn/contents/style.css" />
<link rel="stylesheet" href="/build/style.css" />
<link rel="stylesheet" href="/wallets/style.css" />
<link rel="stylesheet" href="/dialog/style.css" />
<link rel="stylesheet" href="/wallets/amount/style.css" />
<link rel="stylesheet" href="/wallets/layout/style.css" />
<link rel="stylesheet" href="/wallets/form/style.css" />
<link rel="stylesheet" href="/wallets/dialog/style.css" />
<link rel="stylesheet" href="/wallets/add/style.css" />
<link rel="stylesheet" href="/wallets/empty/style.css" />
<link rel="stylesheet" href="/wallets/lock/style.css" />
<link rel="stylesheet" href="/wallets/setup/style.css" />
<link rel="stylesheet" href="/wallets/start/style.css" />
<link rel="stylesheet" href="/wallets/start/reset/style.css" />
<link rel="stylesheet" href="/wallets/wallet/actions/style.css" />
<link rel="stylesheet" href="/wallets/selector/style.css" />
<link rel="stylesheet" href="/wallets/wallet/summary/style.css" />
+52 -4
View File
@@ -7,10 +7,58 @@ body {
background: var(--black);
}
body {
> main {
min-height: 100dvh;
color: var(--white);
body > main {
min-height: 100dvh;
color: var(--white);
}
:where(button, main.home nav a) {
display: inline-flex;
appearance: none;
align-items: center;
justify-content: center;
min-width: 0;
border: 0;
border-radius: 0.3125rem;
padding: 0.75rem 1rem;
color: var(--black);
background: var(--gray);
font: inherit;
line-height: 1;
text-decoration: none;
cursor: pointer;
&:hover {
color: var(--black);
background: var(--white);
}
&:active {
color: var(--black);
background: var(--orange);
}
&:focus-visible {
outline: 2px solid var(--orange);
outline-offset: 2px;
}
}
button {
font-size: var(--font-size-sm);
&:disabled {
opacity: 0.5;
cursor: progress;
}
}
:is(input, textarea)[aria-invalid="true"] {
border-color: var(--red);
color: var(--red);
&::placeholder {
color: var(--red);
}
}
+2
View File
@@ -29,6 +29,8 @@
--line-height-sm: calc(1.25 / 0.875);
--font-size-base: 1rem;
--line-height-base: calc(1.5 / 1);
--font-size-lg: 1.25rem;
--line-height-lg: calc(1.5 / 1.25);
--page-x: 2rem;
--layer-header: 10;
+16 -12
View File
@@ -1,12 +1,12 @@
import { createField } from "../form/index.js";
import { createElement } from "../dom.js";
import { redaction } from "../redaction/index.js";
/**
* @typedef {Object} AddWalletFormSubmit
* @property {HTMLInputElement} name
* @property {HTMLInputElement} source
* @property {HTMLTextAreaElement} source
* @property {HTMLButtonElement} submit
* @property {HTMLElement} status
* @property {HTMLFormElement} form
*/
@@ -17,15 +17,15 @@ import { redaction } from "../redaction/index.js";
*/
function createSourceInput() {
const input = document.createElement("input");
const input = document.createElement("textarea");
input.name = "source";
input.type = "text";
redaction.setInput(input);
input.autocomplete = "off";
input.placeholder = "xpub or descriptor...";
input.placeholder = "xpub... or wsh(sortedmulti(...))";
input.required = true;
input.spellcheck = false;
input.rows = 4;
return input;
}
@@ -34,44 +34,48 @@ function createSourceInput() {
* @param {AddWalletFormOptions} options
*/
export function createAddForm(options) {
const form = document.createElement("form");
const form = createElement("form", "add");
const title = document.createElement("h2");
const description = document.createElement("p");
const name = document.createElement("input");
const source = createSourceInput();
const actions = document.createElement("footer");
const cancel = document.createElement("button");
const submit = document.createElement("button");
const status = document.createElement("output");
const fields = [
createField("name", name),
createField("xpub or descriptor", source),
];
title.append("Watch wallet");
title.append("Add wallet");
description.append(
"Import an xpub or watch-only descriptor. Spending keys are never needed.",
);
name.name = "name";
name.autocomplete = "off";
name.placeholder = "Wallet name";
name.placeholder = "Wallet 1";
name.required = true;
cancel.type = "button";
cancel.append("Cancel");
submit.type = "submit";
submit.classList.add("primary");
submit.append("Add");
actions.append(cancel, submit);
form.append(
title,
description,
...fields,
actions,
status,
);
cancel.addEventListener("click", options.onCancel);
source.addEventListener("input", () => {
source.removeAttribute("aria-invalid");
});
form.addEventListener("submit", (event) => {
event.preventDefault();
void options.onSubmit({
name,
source,
submit,
status,
form,
});
});
+29 -8
View File
@@ -1,14 +1,35 @@
main.wallets {
.wallets__dialog {
> form {
display: grid;
gap: 0.75rem;
.add {
display: grid;
gap: 1.25rem;
> footer {
display: flex;
gap: 0.5rem;
justify-content: end;
> h2 {
color: var(--black);
font-size: 2.5rem;
line-height: 1;
}
> p {
margin: 0;
color: var(--gray);
font-size: var(--font-size-sm);
line-height: var(--line-height-sm);
}
:is(input, textarea) {
border: 1px solid var(--gray);
color: var(--black);
background: transparent;
&::placeholder {
color: var(--gray);
}
}
> footer {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
}
}
}
-18
View File
@@ -1,18 +0,0 @@
main.wallets {
.wallets__dialog {
width: min(100% - 2rem, 30rem);
border: 1px solid color-mix(in oklch, var(--gray) 36%, transparent);
border-radius: 0.5rem;
padding: 1rem;
color: var(--white);
background: var(--black);
h2 {
margin: 0;
}
&::backdrop {
background: color-mix(in oklch, var(--black) 72%, transparent);
}
}
}
+18 -4
View File
@@ -3,22 +3,36 @@ import { createElement } from "../dom.js";
/**
* @typedef {Object} EmptyOptions
* @property {() => void} onAdd
* @property {() => void} [onClear]
*/
/**
* @param {EmptyOptions} options
*/
export function createEmpty(options) {
const empty = createElement("section", "wallets__empty");
const empty = createElement("section", "empty");
const title = document.createElement("h1");
const text = document.createElement("p");
const actions = document.createElement("menu");
const button = document.createElement("button");
text.append("No wallet imported yet");
title.append("No wallet yet");
text.append("Import an xpub or watch-only descriptor to start watching activity.");
button.type = "button";
button.classList.add("primary");
button.append("Add wallet");
button.addEventListener("click", options.onAdd);
empty.append(text, button);
actions.append(button);
if (options.onClear) {
const clear = document.createElement("button");
clear.type = "button";
clear.append("Clear");
clear.addEventListener("click", options.onClear);
actions.append(clear);
}
empty.append(title, text, actions);
return empty;
}
+23 -2
View File
@@ -1,13 +1,34 @@
main.wallets {
.wallets__empty {
.empty {
display: grid;
gap: 1rem;
place-content: center;
min-height: calc(100dvh - 2 * var(--offset));
text-align: center;
p {
h1 {
margin: 0;
font-size: 4rem;
font-weight: 400;
line-height: 1;
}
p {
max-width: 31rem;
margin: 0;
color: var(--gray);
font-size: var(--font-size-base);
line-height: var(--line-height-base);
}
> menu {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
margin: 0;
padding: 0;
list-style: none;
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* @param {string} label
* @param {HTMLInputElement | HTMLSelectElement} control
* @param {HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement} control
*/
export function createField(label, control) {
const element = document.createElement("label");
+50 -31
View File
@@ -7,12 +7,11 @@ import { createEmpty } from "./empty/index.js";
import { getErrorMessage } from "./errors.js";
import { createAddForm } from "./add/index.js";
import { createLayout } from "./layout/index.js";
import { createLock } from "./lock/index.js";
import { redaction } from "./redaction/index.js";
import { readWalletSourceText } from "./add/source.js";
import { scanStatus } from "./wallet/status.js";
import { createSelector } from "./selector/index.js";
import { createSetup } from "./setup/index.js";
import { createStart } from "./start/index.js";
import {
createWalletPanel,
renderWalletPanel,
@@ -33,7 +32,7 @@ export function createWalletsPage() {
header,
addButton,
privacyButton,
lockButton,
sessionButton,
selector: selectorElement,
walletList,
content,
@@ -67,6 +66,16 @@ export function createWalletsPage() {
render();
}
function startEphemeral() {
vault.startEphemeral();
render();
}
function clearEphemeral() {
vault.clearEphemeral();
render();
}
function openAdd() {
addDialog.replaceChildren(createAddForm({
onCancel() {
@@ -84,7 +93,12 @@ export function createWalletsPage() {
syncBtcAmounts();
});
lockButton.addEventListener("click", () => {
sessionButton.addEventListener("click", () => {
if (vault.isEphemeral()) {
clearEphemeral();
return;
}
lock();
});
@@ -92,22 +106,21 @@ export function createWalletsPage() {
openAdd();
});
function renderLocked() {
content.replaceChildren(createLock({
onUnlock(password, button, status) {
return unlock(password, button, status);
/**
* @param {"create" | "unlock"} mode
*/
function renderStart(mode) {
content.replaceChildren(createStart({
mode,
onPassword(password, button, status) {
return mode === "unlock"
? unlock(password, button, status)
: setup(password, button, status);
},
onReset() {
reset();
},
}));
}
function renderSetup() {
content.replaceChildren(createSetup({
onCreate(password, button, status) {
return setup(password, button, status);
onEphemeral() {
startEphemeral();
},
onReset: mode === "unlock" ? reset : undefined,
}));
}
@@ -170,18 +183,24 @@ export function createWalletsPage() {
* @param {string} password
* @param {HTMLButtonElement} button
* @param {HTMLElement} status
* @returns {Promise<boolean>}
*/
async function unlock(password, button, status) {
let unlocked = false;
await withBusy(button, "Unlock", "Unlocking", async () => {
setStatus(status, "");
try {
await vault.unlock(password);
unlocked = true;
render();
} catch {
setStatus(status, "Invalid password");
unlocked = false;
}
});
return unlocked;
}
/**
@@ -190,7 +209,7 @@ export function createWalletsPage() {
* @param {HTMLElement} status
*/
async function setup(password, button, status) {
await withBusy(button, "Continue", "Creating", async () => {
await withBusy(button, "Create", "Creating", async () => {
setStatus(status, "");
try {
@@ -205,20 +224,22 @@ export function createWalletsPage() {
function renderContent() {
const needsSetup = vault.needsSetup();
const locked = vault.isLocked();
const ephemeral = vault.isEphemeral();
const current = vault.current();
const empty = !needsSetup && !locked && !current;
header.hidden = locked || needsSetup || empty;
selectorElement.hidden = locked || needsSetup || empty;
lockButton.hidden = locked || needsSetup || !vault.hasPassword;
sessionButton.hidden = locked || needsSetup || (!vault.hasPassword && !ephemeral);
sessionButton.textContent = ephemeral ? "Clear" : "Lock";
if (needsSetup) {
renderSetup();
renderStart("create");
return;
}
if (locked) {
renderLocked();
renderStart("unlock");
return;
}
@@ -227,6 +248,7 @@ export function createWalletsPage() {
onAdd() {
openAdd();
},
onClear: ephemeral ? clearEphemeral : undefined,
}));
return;
}
@@ -246,28 +268,24 @@ export function createWalletsPage() {
/**
* @param {Object} options
* @param {HTMLInputElement} options.name
* @param {HTMLInputElement} options.source
* @param {HTMLTextAreaElement} options.source
* @param {HTMLButtonElement} options.submit
* @param {HTMLElement} options.status
* @param {HTMLFormElement} options.form
*/
async function submitAdd({
name,
source,
submit,
status,
form,
}) {
await withBusy(submit, "Add", "Adding", async () => {
setStatus(status, "Checking wallet");
source.removeAttribute("aria-invalid");
try {
const value = readWalletSourceText(source.value);
await generateAddressesFromWalletSource(value, { count: 1 });
setStatus(status, "Saving");
await vault.addWallet({
name: name.value,
source: value,
@@ -276,8 +294,9 @@ export function createWalletsPage() {
form.reset();
addDialog.close();
render();
} catch (error) {
setStatus(status, getErrorMessage(error));
} catch {
source.setAttribute("aria-invalid", "true");
source.focus();
}
});
}
+7 -10
View File
@@ -6,7 +6,7 @@ import { createElement } from "../dom.js";
* @property {HTMLElement} header
* @property {HTMLButtonElement} addButton
* @property {HTMLButtonElement} privacyButton
* @property {HTMLButtonElement} lockButton
* @property {HTMLButtonElement} sessionButton
* @property {HTMLElement} selector
* @property {HTMLElement} walletList
* @property {HTMLElement} content
@@ -22,24 +22,21 @@ export function createLayout() {
const actions = document.createElement("menu");
const addButton = document.createElement("button");
const privacyButton = document.createElement("button");
const lockButton = document.createElement("button");
const sessionButton = document.createElement("button");
const selector = createElement("section", "wallets__selector");
const walletList = document.createElement("nav");
const content = document.createElement("article");
const addDialog = createElement("dialog", "wallets__dialog");
const addDialog = document.createElement("dialog");
addButton.type = "button";
addButton.classList.add("primary");
addButton.append("Add watch-only wallet");
privacyButton.type = "button";
privacyButton.classList.add("primary");
lockButton.type = "button";
lockButton.classList.add("primary");
lockButton.append("Lock");
sessionButton.type = "button";
sessionButton.append("Lock");
content.setAttribute("aria-live", "polite");
walletList.setAttribute("tabindex", "0");
walletList.setAttribute("aria-label", "Wallets");
actions.append(addButton, privacyButton, lockButton);
actions.append(addButton, privacyButton, sessionButton);
header.append(actions);
selector.append(walletList);
main.append(header, selector, content, addDialog);
@@ -49,7 +46,7 @@ export function createLayout() {
header,
addButton,
privacyButton,
lockButton,
sessionButton,
selector,
walletList,
content,
-97
View File
@@ -1,97 +0,0 @@
import { createElement } from "../dom.js";
/**
* @typedef {Object} LockOptions
* @property {(password: string, button: HTMLButtonElement, status: HTMLElement) => void | Promise<void>} onUnlock
* @property {() => void} onReset
*/
const RESET_HOLD_MS = 2_000;
/**
* @param {HTMLButtonElement} button
* @param {() => void} onReset
*/
function bindResetHold(button, onReset) {
/** @type {number | undefined} */
let timer;
function cancel() {
if (timer === undefined) return;
clearTimeout(timer);
timer = undefined;
button.classList.remove("holding");
}
function start() {
if (timer !== undefined) return;
button.classList.add("holding");
timer = window.setTimeout(() => {
timer = undefined;
button.classList.remove("holding");
onReset();
}, RESET_HOLD_MS);
}
button.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
button.setPointerCapture(event.pointerId);
start();
});
button.addEventListener("pointerup", cancel);
button.addEventListener("pointercancel", cancel);
button.addEventListener("lostpointercapture", cancel);
button.addEventListener("keydown", (event) => {
if (event.repeat || (event.key !== " " && event.key !== "Enter")) return;
event.preventDefault();
start();
});
button.addEventListener("keyup", (event) => {
if (event.key === " " || event.key === "Enter") {
cancel();
}
});
button.addEventListener("blur", cancel);
}
/**
* @param {LockOptions} options
*/
export function createLock(options) {
const section = createElement("section", "wallets__unlock");
const title = document.createElement("h1");
const form = document.createElement("form");
const password = document.createElement("input");
const button = document.createElement("button");
const reset = document.createElement("button");
const status = document.createElement("output");
title.append("Unlock vault");
password.name = "password";
password.type = "password";
password.autocomplete = "current-password";
password.autofocus = true;
password.placeholder = "Password";
password.required = true;
button.type = "submit";
button.classList.add("primary");
button.append("Unlock");
reset.type = "button";
reset.append("Reset vault");
form.append(password, button);
form.addEventListener("submit", (event) => {
event.preventDefault();
void options.onUnlock(password.value, button, status);
});
bindResetHold(reset, options.onReset);
section.append(title, form, reset, status);
queueMicrotask(() => {
password.focus({ preventScroll: true });
});
return section;
}
-53
View File
@@ -1,53 +0,0 @@
main.wallets {
.wallets__unlock {
display: grid;
gap: 1rem;
place-content: center;
width: min(100%, 28rem);
min-height: calc(100dvh - 2 * var(--offset));
margin-inline: auto;
text-align: center;
> h1 {
margin: 0;
font-size: 3rem;
font-weight: 400;
line-height: 1;
}
> form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: end;
@media (max-width: 34rem) {
grid-template-columns: 1fr;
}
}
> button {
position: relative;
isolation: isolate;
justify-self: center;
overflow: hidden;
color: var(--gray);
background: transparent;
&::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
background: color-mix(in oklch, var(--red) 34%, transparent);
transform: scaleX(0);
transform-origin: left;
}
&.holding::before {
transform: scaleX(1);
transition: transform 2s linear;
}
}
}
}
+6 -2
View File
@@ -74,11 +74,15 @@ function setAddress(element, value, render) {
}
/**
* @param {HTMLInputElement} input
* @param {HTMLInputElement | HTMLTextAreaElement} input
*/
function setInput(input) {
addEffect(input, () => {
input.type = hidden ? "password" : "text";
if (input instanceof HTMLTextAreaElement) {
input.style.setProperty("-webkit-text-security", hidden ? "disc" : "");
} else {
input.type = hidden ? "password" : "text";
}
});
}
-62
View File
@@ -1,62 +0,0 @@
import { createElement } from "../dom.js";
/**
* @typedef {Object} SetupOptions
* @property {(password: string, button: HTMLButtonElement, status: HTMLElement) => void | Promise<void>} onCreate
*/
/**
* @param {string} text
*/
function createDescriptionText(text) {
const paragraph = document.createElement("p");
paragraph.append(text);
return paragraph;
}
/**
* @param {SetupOptions} options
*/
export function createSetup(options) {
const section = createElement("section", "wallets__setup");
const title = document.createElement("h1");
const description = document.createElement("article");
const form = document.createElement("form");
const password = document.createElement("input");
const button = document.createElement("button");
const status = document.createElement("output");
title.append("Wallets");
description.append(
createDescriptionText(
"A privacy-preserving xpub viewer that runs in your browser and never uploads your xpub.",
),
createDescriptionText(
"Import an xpub or watch-only descriptor to view a Bitcoin wallet without spending access.",
),
createDescriptionText(
"Addresses are derived locally, checked through prefix buckets, and saved encrypted in this browser.",
),
createDescriptionText(
"Privacy benefits can be drastically reduced if those addresses are already linked together on-chain.",
),
);
password.name = "password";
password.type = "password";
password.autocomplete = "new-password";
password.placeholder = "Set password";
password.required = true;
button.type = "submit";
button.classList.add("primary");
button.append("Continue");
form.append(password, button);
form.addEventListener("submit", (event) => {
event.preventDefault();
void options.onCreate(password.value, button, status);
});
section.append(title, description, form, status);
return section;
}
-51
View File
@@ -1,51 +0,0 @@
main.wallets {
.wallets__setup {
display: grid;
gap: 1rem;
place-content: center;
width: min(100%, 36rem);
min-height: calc(100dvh - 2 * var(--offset));
margin-inline: auto;
h1 {
margin: 0;
text-align: center;
font-size: 5rem;
font-weight: 400;
line-height: 0.9;
@media (max-width: 56rem) {
font-size: 4rem;
}
@media (max-width: 34rem) {
font-size: 3rem;
}
}
p {
margin: 0;
}
> article {
display: grid;
gap: 0.75rem;
color: var(--gray);
font-size: var(--font-size-sm);
line-height: var(--line-height-sm);
text-align: left;
}
> form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: end;
width: 100%;
@media (max-width: 34rem) {
grid-template-columns: 1fr;
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 KiB

+126
View File
@@ -0,0 +1,126 @@
import { createElement } from "../dom.js";
import { createResetButton } from "./reset/index.js";
/**
* @typedef {"create" | "unlock"} StartMode
*/
/**
* @typedef {Object} StartOptions
* @property {StartMode} mode
* @property {(password: string, button: HTMLButtonElement, status: HTMLElement) => boolean | void | Promise<boolean | void>} onPassword
* @property {() => void} onEphemeral
* @property {() => void} [onReset]
*/
/**
* @param {StartOptions} options
*/
export function createStart(options) {
const section = createElement("section", "start");
const story = document.createElement("article");
const title = document.createElement("h1");
const titleBreak = document.createElement("br");
const titleAccent = document.createElement("span");
const lead = document.createElement("p");
const details = document.createElement("ul");
const warningRule = document.createElement("hr");
const warning = document.createElement("p");
const modes = document.createElement("div");
const persistent = document.createElement("section");
const persistentTitle = document.createElement("h2");
const persistentText = document.createElement("p");
const form = document.createElement("form");
const password = document.createElement("input");
const submit = document.createElement("button");
const divider = document.createElement("p");
const temporary = document.createElement("section");
const temporaryTitle = document.createElement("h2");
const temporaryText = document.createElement("p");
const temporaryButton = document.createElement("button");
const status = document.createElement("output");
const unlock = options.mode === "unlock";
titleAccent.append("wallets");
title.append("Watch-only", titleBreak, titleAccent);
lead.append("View a Bitcoin wallet privately, without spending access.");
details.append(
createDetail("Open xpubs and watch-only descriptors."),
createDetail("Addresses are derived on your device."),
createDetail("Anonymity sets increase lookup privacy."),
createDetail("Save encrypted wallets, or use a temporary session."),
);
warning.append(
"Use a VPN for extra network privacy.",
document.createElement("br"),
"On-chain address links will reduce anonymity.",
);
story.append(title, lead, details, warningRule, warning);
persistentTitle.append("Persistent vault");
persistentText.append(
unlock
? "Unlock the encrypted vault saved in this browser."
: "Create an encrypted vault saved in this browser.",
);
password.name = "password";
password.type = "password";
password.autocomplete = unlock ? "current-password" : "new-password";
password.autofocus = true;
password.placeholder = unlock ? "Password" : "Set password";
password.required = true;
submit.type = "submit";
submit.append(unlock ? "Unlock" : "Create");
form.append(password, submit);
function clearInvalid() {
password.removeAttribute("aria-invalid");
}
password.addEventListener("input", clearInvalid);
form.addEventListener("submit", (event) => {
event.preventDefault();
clearInvalid();
void (async () => {
const valid = await options.onPassword(password.value, submit, status);
if (valid === false) {
password.setAttribute("aria-invalid", "true");
password.focus({ preventScroll: true });
}
})();
});
persistent.append(persistentTitle, persistentText, form);
if (options.onReset) {
persistent.append(createResetButton(options.onReset));
}
divider.append("OR");
temporaryTitle.append("Temporary vault");
temporaryText.append("Wallets are never saved to this browser.");
temporaryButton.type = "button";
temporaryButton.append("Start temporary");
temporaryButton.addEventListener("click", () => {
options.onEphemeral();
});
temporary.append(temporaryTitle, temporaryText, temporaryButton);
persistent.append(status);
modes.append(persistent, divider, temporary);
section.append(story, modes);
queueMicrotask(() => {
password.focus({ preventScroll: true });
});
return section;
}
/**
* @param {string} text
*/
function createDetail(text) {
const item = document.createElement("li");
item.append(text);
return item;
}
+130
View File
@@ -0,0 +1,130 @@
import { createElement } from "../../dom.js";
const FILL_MS = 2_000;
const DRAIN_MS = 600;
const LABEL = "Reset vault";
/**
* @param {number} value
*/
function clampProgress(value) {
return Math.max(0, Math.min(1, value));
}
/**
* @param {HTMLButtonElement} button
* @param {() => void} onReset
*/
function bindHold(button, onReset) {
/** @type {number | undefined} */
let frame;
let holding = false;
let progress = 0;
let previous = 0;
function render() {
button.style.setProperty("--reset-progress", String(progress));
button.style.setProperty("--reset-progress-width", `${progress * 100}%`);
button.classList.toggle("active", progress > 0);
}
function stop() {
if (frame === undefined) return;
cancelAnimationFrame(frame);
frame = undefined;
}
/**
* @param {number} now
*/
function tick(now) {
const elapsed = now - previous;
const rate = elapsed / (holding ? FILL_MS : DRAIN_MS);
previous = now;
progress = clampProgress(progress + (holding ? rate : -rate));
render();
if (holding && progress === 1) {
stop();
holding = false;
progress = 0;
button.classList.remove("holding");
render();
onReset();
return;
}
if (!holding && progress === 0) {
stop();
return;
}
frame = requestAnimationFrame(tick);
}
function run() {
if (frame !== undefined) return;
previous = performance.now();
frame = requestAnimationFrame(tick);
}
function release() {
if (!holding) return;
holding = false;
button.classList.remove("holding");
run();
}
function hold() {
stop();
holding = true;
button.classList.add("holding");
run();
}
render();
button.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
button.setPointerCapture(event.pointerId);
hold();
});
button.addEventListener("pointerup", release);
button.addEventListener("pointercancel", release);
button.addEventListener("lostpointercapture", release);
button.addEventListener("keydown", (event) => {
if (event.repeat || (event.key !== " " && event.key !== "Enter")) return;
event.preventDefault();
hold();
});
button.addEventListener("keyup", (event) => {
if (event.key === " " || event.key === "Enter") {
release();
}
});
button.addEventListener("blur", release);
}
/**
* @param {() => void} onReset
*/
export function createResetButton(onReset) {
const button = createElement("button", "reset");
const label = document.createElement("span");
button.type = "button";
button.dataset.label = LABEL;
button.title = "Hold to reset";
label.append(LABEL);
button.append(label);
bindHold(button, onReset);
return button;
}
@@ -0,0 +1,62 @@
main.wallets {
.start {
.reset {
position: relative;
isolation: isolate;
justify-self: start;
overflow: hidden;
width: 100%;
border-color: transparent;
color: color-mix(in oklch, var(--gray) 76%, transparent);
background: transparent;
font-size: var(--font-size-sm);
--reset-progress: 0;
--reset-progress-width: 0%;
&::before,
&::after {
content: "";
position: absolute;
inset: 0;
opacity: 0;
}
&::before {
z-index: -1;
background: var(--red);
transform: scaleX(var(--reset-progress));
transform-origin: left;
}
&::after {
content: attr(data-label);
display: flex;
align-items: center;
justify-content: center;
padding: inherit;
color: var(--black);
pointer-events: none;
white-space: nowrap;
clip-path: inset(0 calc(100% - var(--reset-progress-width)) 0 0);
}
span {
color: inherit;
}
&:is(:hover, :focus-visible, :active):not(.holding) {
color: var(--red);
background: transparent;
}
&.active {
color: var(--red);
}
&.active::before,
&.active::after {
opacity: 1;
}
}
}
}
+198
View File
@@ -0,0 +1,198 @@
main.wallets {
.start {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(19rem, 26rem);
gap: 4rem;
align-items: center;
width: min(100%, 68rem);
min-height: calc(100dvh - 2 * var(--offset));
margin-inline: auto;
@media (max-width: 56rem) {
grid-template-columns: 1fr;
gap: 2rem;
align-content: center;
width: min(100%, 39rem);
margin-inline: 0 auto;
}
> article {
display: grid;
gap: 0.875rem;
}
h1 {
margin: 0;
font-size: 4.5rem;
font-weight: 400;
line-height: 0.95;
span {
color: var(--orange);
}
@media (max-width: 34rem) {
font-size: 3.5rem;
}
}
p {
margin: 0;
}
> article > p:first-of-type {
max-width: 35rem;
color: var(--white);
font-size: var(--font-size-base);
line-height: var(--line-height-base);
}
> article > ul {
display: grid;
gap: 0.75rem;
margin: 0.5rem 0 0;
padding: 0;
list-style: none;
}
> article li {
display: grid;
grid-template-columns: 1rem minmax(0, 1fr);
gap: 0.75rem;
max-width: 34rem;
color: var(--white);
font-size: var(--font-size-base);
line-height: var(--line-height-base);
&::before {
content: "";
width: 0.5rem;
height: 0.5rem;
border: 1px solid var(--orange);
border-radius: 50%;
margin-top: 0.5rem;
}
}
> article > hr {
width: min(100%, 34rem);
height: 0.5px;
border: 0;
margin: 0.125rem 0 0;
background: var(--gray);
}
> article > p:last-of-type {
max-width: 34rem;
color: var(--gray);
font-size: var(--font-size-sm);
line-height: var(--line-height-sm);
}
> div {
display: grid;
gap: 0.875rem;
width: 100%;
> section {
display: grid;
gap: 0.5rem;
}
> p {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 0.625rem;
align-items: center;
color: var(--gray);
font-size: var(--font-size-xs);
line-height: 1;
&::before {
content: "";
height: 0.5px;
background: var(--gray);
}
&::after {
content: "";
height: 0.5px;
background: var(--gray);
}
}
h2 {
margin: 0;
color: var(--white);
font-family: var(--font-mono);
font-size: var(--font-size-sm);
font-weight: 400;
line-height: var(--line-height-sm);
}
p {
color: var(--gray);
font-size: var(--font-size-sm);
line-height: var(--line-height-sm);
}
form {
--height: 2.375rem;
display: flex;
gap: 0;
width: 100%;
font-size: var(--font-size-sm);
:is(input, button) {
height: var(--height);
padding: 0 1rem;
font: inherit;
line-height: 1;
}
input {
flex: 1 1 auto;
display: block;
min-block-size: 0;
border: 1px solid var(--gray);
border-top-right-radius: 0;
border-bottom-right-radius: 0;
color: var(--white);
background: transparent;
&::placeholder {
color: var(--gray);
}
}
button {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
@media (max-width: 34rem) {
flex-direction: column;
input {
border-top-right-radius: 0.375rem;
border-bottom-left-radius: 0;
}
button {
border-top-right-radius: 0;
border-bottom-left-radius: 0.375rem;
}
}
}
> section:last-child {
font-size: var(--font-size-sm);
}
> section:last-child > button {
width: 100%;
}
}
}
}
+10 -22
View File
@@ -1,7 +1,6 @@
main.wallets {
--offset: 4rem;
--content-width: 72rem;
--control-height: 2.75rem;
display: grid;
gap: 1.5rem;
@@ -20,41 +19,30 @@ main.wallets {
line-height: var(--line-height-sm);
}
:is(input, select, button) {
:is(input, select, textarea) {
appearance: none;
min-width: 0;
height: var(--control-height);
border: 1px solid color-mix(in oklch, var(--gray) 45%, transparent);
border: 0;
border-radius: 0.375rem;
padding: 0 0.875rem;
padding: 0.75rem 0.875rem;
color: var(--white);
background: color-mix(in oklch, var(--black) 72%, var(--white));
font: inherit;
line-height: 1;
}
button {
cursor: pointer;
textarea {
min-height: 7rem;
resize: vertical;
line-height: var(--line-height-sm);
}
:is(input, select, button):focus-visible {
:is(input, select, textarea):focus-visible {
outline: 2px solid var(--orange);
outline-offset: 2px;
}
button.primary {
border-color: var(--orange);
color: var(--black);
background: var(--orange);
}
input::placeholder {
:is(input, textarea)::placeholder {
color: color-mix(in oklch, var(--gray) 70%, transparent);
}
button:disabled {
border-color: var(--gray);
color: var(--black);
background: var(--gray);
cursor: progress;
}
}
+36 -2
View File
@@ -13,6 +13,7 @@ export function createVault() {
let selectedId = "";
let locked = hasVault();
let password = "";
let ephemeral = false;
/** @type {Map<string, WalletRuntime>} */
const runtimes = new Map();
@@ -68,6 +69,7 @@ export function createVault() {
function lock() {
clear();
password = "";
ephemeral = false;
locked = hasVault();
}
@@ -75,9 +77,24 @@ export function createVault() {
vaultStorage.reset();
clear();
password = "";
ephemeral = false;
locked = false;
}
function startEphemeral() {
clear();
password = "";
ephemeral = true;
locked = false;
}
function clearEphemeral() {
clear();
password = "";
ephemeral = false;
locked = hasVault();
}
/**
* @param {string} pagePassword
*/
@@ -85,6 +102,7 @@ export function createVault() {
await vaultStorage.setup(pagePassword);
clear();
password = pagePassword;
ephemeral = false;
locked = false;
}
@@ -96,6 +114,7 @@ export function createVault() {
syncSelected();
runtimes.clear();
password = pagePassword;
ephemeral = false;
locked = false;
for (const wallet of wallets) {
@@ -107,6 +126,16 @@ export function createVault() {
* @param {AddWalletInput} input
*/
async function addWallet(input) {
if (ephemeral) {
const wallet = vaultStorage.createWallet(input);
wallets = [...wallets, wallet];
selectedId = wallet.id;
locked = false;
runtimes.set(wallet.id, createRuntime(wallet.source));
return;
}
const added = await vaultStorage.addWallet(wallets, input, password);
wallets = added.wallets;
@@ -126,16 +155,21 @@ export function createVault() {
return password !== "";
},
needsSetup() {
return !hasVault() && !password;
return !hasVault() && !password && !ephemeral;
},
isLocked() {
return locked && hasVault();
return !ephemeral && locked && hasVault();
},
isEphemeral() {
return ephemeral;
},
current,
isCurrent,
select,
lock,
reset,
startEphemeral,
clearEphemeral,
setup,
unlock,
addWallet,
+17 -8
View File
@@ -66,6 +66,21 @@ function reset() {
localStorage.removeItem(STORAGE_KEY);
}
/**
* @param {AddWalletInput} input
*/
function createWallet(input) {
const time = now();
return {
id: createWalletId(),
name: input.name.trim(),
source: input.source.trim(),
createdAt: time,
updatedAt: time,
};
}
/**
* @param {string} pagePassword
*/
@@ -107,14 +122,7 @@ async function writeWallets(wallets, pagePassword) {
* @param {string} pagePassword
*/
async function addWallet(wallets, input, pagePassword) {
const time = now();
const wallet = {
id: createWalletId(),
name: input.name.trim(),
source: input.source.trim(),
createdAt: time,
updatedAt: time,
};
const wallet = createWallet(input);
const nextWallets = [...wallets, wallet];
await writeWallets(nextWallets, pagePassword);
@@ -128,6 +136,7 @@ async function addWallet(wallets, input, pagePassword) {
export const vaultStorage = /** @type {const} */ ({
has,
reset,
createWallet,
setup,
load,
addWallet,
+1 -6
View File
@@ -77,10 +77,7 @@ async function copyReceiveAddress(receiveAddress, copy) {
* @param {ReceiveAddress} receiveAddress
*/
function openReceiveDialog(host, receiveAddress) {
const dialog = createElement(
"dialog",
"wallets__dialog wallets__receive-dialog",
);
const dialog = createElement("dialog", "receive");
const content = document.createElement("article");
const actions = document.createElement("footer");
const copy = document.createElement("button");
@@ -88,7 +85,6 @@ function openReceiveDialog(host, receiveAddress) {
const close = document.createElement("button");
copy.type = "button";
copy.classList.add("primary");
copy.append("Copy");
closeForm.method = "dialog";
close.type = "submit";
@@ -129,7 +125,6 @@ export function renderReceiveButton(element, receiveAddress) {
button.type = "button";
button.disabled = !receiveAddress;
button.classList.add("primary");
button.append("Receive");
button.addEventListener("click", () => {
if (receiveAddress) {
@@ -1,5 +1,5 @@
main.wallets {
.wallets__receive-dialog {
dialog.receive {
width: min(100% - 2rem, 32rem);
> article {
@@ -24,7 +24,7 @@ main.wallets {
> p {
margin: 0;
color: var(--white);
color: var(--black);
font-size: var(--font-size-sm);
line-height: var(--line-height-sm);
}