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); }