diff --git a/website_next/dialog/style.css b/website_next/dialog/style.css
new file mode 100644
index 000000000..20be5fdd4
--- /dev/null
+++ b/website_next/dialog/style.css
@@ -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);
+ }
+ }
+}
diff --git a/website_next/home/style.css b/website_next/home/style.css
index 1f6d49d30..bbee8a776 100644
--- a/website_next/home/style.css
+++ b/website_next/home/style.css
@@ -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);
- }
- }
}
}
diff --git a/website_next/index.html b/website_next/index.html
index 48fd4a920..9bddf744e 100644
--- a/website_next/index.html
+++ b/website_next/index.html
@@ -116,14 +116,14 @@
+
-
-
-
+
+
diff --git a/website_next/styles/main.css b/website_next/styles/main.css
index 32b1098a4..1006e4f96 100644
--- a/website_next/styles/main.css
+++ b/website_next/styles/main.css
@@ -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);
}
}
diff --git a/website_next/styles/variables.css b/website_next/styles/variables.css
index 886948ede..537e41137 100644
--- a/website_next/styles/variables.css
+++ b/website_next/styles/variables.css
@@ -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;
diff --git a/website_next/wallets/add/index.js b/website_next/wallets/add/index.js
index f45bfd82d..06c02c807 100644
--- a/website_next/wallets/add/index.js
+++ b/website_next/wallets/add/index.js
@@ -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,
});
});
diff --git a/website_next/wallets/add/style.css b/website_next/wallets/add/style.css
index 41a8547d9..41ceee4e2 100644
--- a/website_next/wallets/add/style.css
+++ b/website_next/wallets/add/style.css
@@ -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;
+ }
}
}
diff --git a/website_next/wallets/dialog/style.css b/website_next/wallets/dialog/style.css
deleted file mode 100644
index 6b62202ad..000000000
--- a/website_next/wallets/dialog/style.css
+++ /dev/null
@@ -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);
- }
- }
-}
diff --git a/website_next/wallets/empty/index.js b/website_next/wallets/empty/index.js
index 8552bb0a9..b73ed0abe 100644
--- a/website_next/wallets/empty/index.js
+++ b/website_next/wallets/empty/index.js
@@ -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;
}
diff --git a/website_next/wallets/empty/style.css b/website_next/wallets/empty/style.css
index 5ff46b79a..5102bf2eb 100644
--- a/website_next/wallets/empty/style.css
+++ b/website_next/wallets/empty/style.css
@@ -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;
}
}
}
diff --git a/website_next/wallets/form/index.js b/website_next/wallets/form/index.js
index de352783c..8ab7aa3ff 100644
--- a/website_next/wallets/form/index.js
+++ b/website_next/wallets/form/index.js
@@ -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");
diff --git a/website_next/wallets/index.js b/website_next/wallets/index.js
index 7687530de..78cd9265b 100644
--- a/website_next/wallets/index.js
+++ b/website_next/wallets/index.js
@@ -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}
*/
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();
}
});
}
diff --git a/website_next/wallets/layout/index.js b/website_next/wallets/layout/index.js
index a7d467bfb..7dda1516e 100644
--- a/website_next/wallets/layout/index.js
+++ b/website_next/wallets/layout/index.js
@@ -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,
diff --git a/website_next/wallets/lock/index.js b/website_next/wallets/lock/index.js
deleted file mode 100644
index 04c28a193..000000000
--- a/website_next/wallets/lock/index.js
+++ /dev/null
@@ -1,97 +0,0 @@
-import { createElement } from "../dom.js";
-
-/**
- * @typedef {Object} LockOptions
- * @property {(password: string, button: HTMLButtonElement, status: HTMLElement) => void | Promise} 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;
-}
diff --git a/website_next/wallets/lock/style.css b/website_next/wallets/lock/style.css
deleted file mode 100644
index 51651976f..000000000
--- a/website_next/wallets/lock/style.css
+++ /dev/null
@@ -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;
- }
- }
- }
-}
diff --git a/website_next/wallets/redaction/index.js b/website_next/wallets/redaction/index.js
index a340bd994..5cf1ac8c9 100644
--- a/website_next/wallets/redaction/index.js
+++ b/website_next/wallets/redaction/index.js
@@ -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";
+ }
});
}
diff --git a/website_next/wallets/setup/index.js b/website_next/wallets/setup/index.js
deleted file mode 100644
index 17b6f4502..000000000
--- a/website_next/wallets/setup/index.js
+++ /dev/null
@@ -1,62 +0,0 @@
-import { createElement } from "../dom.js";
-
-/**
- * @typedef {Object} SetupOptions
- * @property {(password: string, button: HTMLButtonElement, status: HTMLElement) => void | Promise} 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;
-}
diff --git a/website_next/wallets/setup/style.css b/website_next/wallets/setup/style.css
deleted file mode 100644
index 2dcd0bd0c..000000000
--- a/website_next/wallets/setup/style.css
+++ /dev/null
@@ -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;
- }
- }
- }
-}
diff --git a/website_next/wallets/start/background-v2.jpg b/website_next/wallets/start/background-v2.jpg
new file mode 100644
index 000000000..3b5420a17
Binary files /dev/null and b/website_next/wallets/start/background-v2.jpg differ
diff --git a/website_next/wallets/start/background-v3.jpg b/website_next/wallets/start/background-v3.jpg
new file mode 100644
index 000000000..52d46b7a4
Binary files /dev/null and b/website_next/wallets/start/background-v3.jpg differ
diff --git a/website_next/wallets/start/background-v4.jpg b/website_next/wallets/start/background-v4.jpg
new file mode 100644
index 000000000..1d7f496ce
Binary files /dev/null and b/website_next/wallets/start/background-v4.jpg differ
diff --git a/website_next/wallets/start/background.jpg b/website_next/wallets/start/background.jpg
new file mode 100644
index 000000000..1459560e9
Binary files /dev/null and b/website_next/wallets/start/background.jpg differ
diff --git a/website_next/wallets/start/index.js b/website_next/wallets/start/index.js
new file mode 100644
index 000000000..b6be82c91
--- /dev/null
+++ b/website_next/wallets/start/index.js
@@ -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} 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;
+}
diff --git a/website_next/wallets/start/reset/index.js b/website_next/wallets/start/reset/index.js
new file mode 100644
index 000000000..5c545a762
--- /dev/null
+++ b/website_next/wallets/start/reset/index.js
@@ -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;
+}
diff --git a/website_next/wallets/start/reset/style.css b/website_next/wallets/start/reset/style.css
new file mode 100644
index 000000000..2f9cd784f
--- /dev/null
+++ b/website_next/wallets/start/reset/style.css
@@ -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;
+ }
+ }
+ }
+}
diff --git a/website_next/wallets/start/style.css b/website_next/wallets/start/style.css
new file mode 100644
index 000000000..62134e853
--- /dev/null
+++ b/website_next/wallets/start/style.css
@@ -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%;
+ }
+ }
+ }
+}
diff --git a/website_next/wallets/style.css b/website_next/wallets/style.css
index 1d7d07128..c3d18bf5e 100644
--- a/website_next/wallets/style.css
+++ b/website_next/wallets/style.css
@@ -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;
- }
}
diff --git a/website_next/wallets/vault/index.js b/website_next/wallets/vault/index.js
index 80984ec97..c519a296d 100644
--- a/website_next/wallets/vault/index.js
+++ b/website_next/wallets/vault/index.js
@@ -13,6 +13,7 @@ export function createVault() {
let selectedId = "";
let locked = hasVault();
let password = "";
+ let ephemeral = false;
/** @type {Map} */
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,
diff --git a/website_next/wallets/vault/storage.js b/website_next/wallets/vault/storage.js
index 5f7e5b7dd..a09635429 100644
--- a/website_next/wallets/vault/storage.js
+++ b/website_next/wallets/vault/storage.js
@@ -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,
diff --git a/website_next/wallets/wallet/receive/index.js b/website_next/wallets/wallet/receive/index.js
index fa220f43e..45b88b87d 100644
--- a/website_next/wallets/wallet/receive/index.js
+++ b/website_next/wallets/wallet/receive/index.js
@@ -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) {
diff --git a/website_next/wallets/wallet/receive/style.css b/website_next/wallets/wallet/receive/style.css
index eb2ad337d..a12b40e74 100644
--- a/website_next/wallets/wallet/receive/style.css
+++ b/website_next/wallets/wallet/receive/style.css
@@ -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);
}