diff --git a/website_next/.well-known/ai-plugin.json b/website_next/.well-known/ai-plugin.json index f6d2ced1f..63da237ed 100644 --- a/website_next/.well-known/ai-plugin.json +++ b/website_next/.well-known/ai-plugin.json @@ -9,7 +9,7 @@ "type": "openapi", "url": "https://bitview.space/openapi.json" }, - "logo_url": "https://bitview.space/assets/favicon/web-app-manifest-512x512.png", + "logo_url": "https://bitview.space/assets/web-app-manifest-512x512.png", "contact_email": "hello@bitcoinresearchkit.org", "legal_info_url": "https://github.com/bitcoinresearchkit/brk/blob/main/docs/LICENSE.md" } diff --git a/website_next/assets/logo/demo-svg.html b/website_next/assets/logo/demo-svg.html deleted file mode 100644 index bc9f7fcb0..000000000 --- a/website_next/assets/logo/demo-svg.html +++ /dev/null @@ -1,379 +0,0 @@ - - - - - bitview logo (svg) - - - - - -
-
-
-
-
-
-
-
-
-
-
-
-
-
- - - - diff --git a/website_next/assets/logo/demo.html b/website_next/assets/logo/demo.html deleted file mode 100644 index dc87c5f20..000000000 --- a/website_next/assets/logo/demo.html +++ /dev/null @@ -1,650 +0,0 @@ - - - - - bitview logo - - - -
- - -
-
-
- -
-
-
- -
-
-
- -
-
-
-
- - - - diff --git a/website_next/brand/index.js b/website_next/brand/index.js new file mode 100644 index 000000000..6806c04de --- /dev/null +++ b/website_next/brand/index.js @@ -0,0 +1,49 @@ +import { createCube } from "../cube/index.js"; + +/** + * @param {string | undefined} href + * @param {string} label + */ +function createBrandElement(href, label) { + if (href) { + const link = document.createElement("a"); + + link.href = href; + link.setAttribute("aria-label", `${label} home`); + + return link; + } + + return document.createElement("p"); +} + +/** + * @param {{ + * href?: string, + * tone?: "default" | "receipt", + * animated?: boolean, + * fill?: number, + * }} [options] + */ +export function createBrand({ + href, + tone = "default", + animated = Boolean(href), + fill = tone === "receipt" ? 1 : 0.5, +} = {}) { + const label = "bitview"; + const brand = createBrandElement(href, label); + const mark = document.createElement("span"); + const text = document.createElement("span"); + + brand.dataset.brand = ""; + brand.dataset.tone = tone; + if (animated) brand.dataset.animated = ""; + mark.dataset.brandMark = ""; + text.dataset.brandText = ""; + text.textContent = label; + mark.append(createCube({ fill })); + brand.append(mark, text); + + return brand; +} diff --git a/website_next/brand/style.css b/website_next/brand/style.css new file mode 100644 index 000000000..c7eb63772 --- /dev/null +++ b/website_next/brand/style.css @@ -0,0 +1,70 @@ +[data-brand] { + --brand-color: var(--white); + --brand-mark-color: var(--black); + --brand-mark-height: auto; + --brand-mark-padding: 0.2rem 0.3rem; + --brand-mark-width: auto; + --brand-opacity: 0.8; + --brand-cube-size: 0.75rem; + + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; + color: var(--brand-color); + font-family: var(--font-mono); + font-size: var(--font-size-base); + line-height: 1; + text-decoration: none; + text-transform: lowercase; + opacity: var(--brand-opacity); + + @media (hover: hover) and (pointer: fine) { + &:where(a):hover { + opacity: 1; + } + } + + &:where(a):active { + --brand-color: var(--orange); + opacity: 1; + } + + &:where(a)[data-press] { + --brand-color: var(--white); + opacity: 1; + } + + &[data-tone="receipt"] { + --brand-color: var(--black); + --brand-mark-color: var(--white); + --brand-mark-height: 1.625rem; + --brand-mark-padding: 0; + --brand-mark-width: 1.625rem; + --brand-opacity: 1; + --brand-cube-size: 0.55rem; + } + + [data-brand-mark] { + display: inline-grid; + place-items: center; + width: var(--brand-mark-width); + height: var(--brand-mark-height); + padding: var(--brand-mark-padding); + color: var(--brand-mark-color); + background-color: var(--brand-color); + border-radius: 0.25rem; + } + + [data-brand-text] { + line-height: 1; + } + + [data-cube="mark"] { + --size: var(--brand-cube-size); + } + + &[data-animated] [data-cube="mark"] { + animation: cube-fill 5s linear infinite alternate; + } +} diff --git a/website_next/btc/index.js b/website_next/btc/index.js index 849d8e9e3..5ddb4d643 100644 --- a/website_next/btc/index.js +++ b/website_next/btc/index.js @@ -2,6 +2,14 @@ export const SATS_PER_BTC = 100_000_000; const FRACTION_DIGITS = 8; +/** + * @param {number} sats + * @param {number} price + */ +export function satsToUsd(sats, price) { + return (sats / SATS_PER_BTC) * price; +} + /** * @typedef {Object} BtcAmountOptions * @property {boolean} [signed] diff --git a/website_next/build/index.js b/website_next/build/index.js index 8eb2f18ca..e7d23fb95 100644 --- a/website_next/build/index.js +++ b/website_next/build/index.js @@ -1,6 +1,6 @@ export function createBuildPage() { const main = document.createElement("main"); - main.className = "build"; + main.dataset.page = "build"; const title = document.createElement("h1"); title.append("Build"); main.append(title); diff --git a/website_next/build/style.css b/website_next/build/style.css index 60c614713..458fe7dbb 100644 --- a/website_next/build/style.css +++ b/website_next/build/style.css @@ -1,4 +1,4 @@ -main.build { +main[data-page="build"] { display: grid; place-items: center; font-size: 4rem; diff --git a/website_next/chart/xy/index.js b/website_next/chart/xy/index.js index 4520161d1..0a717d117 100644 --- a/website_next/chart/xy/index.js +++ b/website_next/chart/xy/index.js @@ -145,9 +145,13 @@ export function createXyChart({ highlight.clearPreview(); } - function disconnect() { + function cancelPointerFrame() { if (pointerFrame) cancelAnimationFrame(pointerFrame); pointerFrame = 0; + } + + function disconnect() { + cancelPointerFrame(); resizeObserver.disconnect(); } @@ -157,8 +161,7 @@ export function createXyChart({ svg.addEventListener("pointerenter", measure); svg.addEventListener("pointermove", updateFromPointer); svg.addEventListener("pointerleave", () => { - if (pointerFrame) cancelAnimationFrame(pointerFrame); - pointerFrame = 0; + cancelPointerFrame(); hideMarker(); }); figure.addEventListener("chart:destroy", disconnect, { once: true }); diff --git a/website_next/cube/faces.js b/website_next/cube/faces.js new file mode 100644 index 000000000..1077ccedb --- /dev/null +++ b/website_next/cube/faces.js @@ -0,0 +1,30 @@ +export const CUBE_FACE_SPECS = /** @type {const} */ ([ + ["glass", "bottom"], + ["glass", "rear-right"], + ["glass", "rear-left"], + ["liquid", "bottom"], + ["liquid", "rear-right"], + ["liquid", "rear-left"], + ["liquid", "right"], + ["liquid", "left"], + ["liquid", "top"], + ["glass", "right"], + ["glass", "left"], + ["glass", "top"], +]); + +/** + * @template {keyof HTMLElementTagNameMap} T + * @param {T} tag + * @param {string} role + * @param {string} side + */ +export function createCubeFace(tag, role, side) { + const element = document.createElement(tag); + + element.dataset.face = ""; + element.dataset.role = role; + element.dataset.side = side; + + return element; +} diff --git a/website_next/cube/index.js b/website_next/cube/index.js index 22d2061c5..43eef117a 100644 --- a/website_next/cube/index.js +++ b/website_next/cube/index.js @@ -1,35 +1,18 @@ +import { CUBE_FACE_SPECS, createCubeFace } from "./faces.js"; + /** * @param {{ fill?: number }} [options] */ export function createCube({ fill = 0.5 } = {}) { const cube = document.createElement("span"); - cube.className = "cube"; + cube.dataset.cube = "mark"; cube.setAttribute("aria-hidden", "true"); cube.style.setProperty("--fill", String(fill)); - - /** @param {...string} names */ - const face = (...names) => { - const element = document.createElement("span"); - element.className = `face ${names.join(" ")}`; - return element; - }; - - const faces = /** @type {const} */ ([ - ["glass", "front", "bottom"], - ["glass", "rear", "right"], - ["glass", "rear", "left"], - ["liquid", "front", "bottom"], - ["liquid", "rear", "right"], - ["liquid", "rear", "left"], - ["liquid", "front", "right"], - ["liquid", "front", "left"], - ["liquid", "front", "top"], - ["glass", "front", "right"], - ["glass", "front", "left"], - ["glass", "front", "top"], - ]); - - cube.append(...faces.map((names) => face(...names))); + cube.append( + ...CUBE_FACE_SPECS.map(([role, side]) => + createCubeFace("span", role, side), + ), + ); return cube; } diff --git a/website_next/cube/style.css b/website_next/cube/style.css index 81327c002..0ed641108 100644 --- a/website_next/cube/style.css +++ b/website_next/cube/style.css @@ -1,9 +1,8 @@ -.cube { +[data-cube="mark"] { --size: 1rem; - --iso-scale: calc(sqrt(3) / 2); --empty-alpha: 0.4; --step: 0.033; - --width: calc(var(--size) * 2 * var(--iso-scale)); + --width: calc(var(--size) * sqrt(3)); --height: calc(var(--size) * 2); --face-base: currentColor; --face-top: var(--face-base); @@ -17,84 +16,52 @@ flex-shrink: 0; width: var(--width); height: var(--height); - overflow: visible; + overflow: hidden; + clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%); - .face { + [data-face] { position: absolute; - transform-origin: 0 0; - width: var(--size); - height: var(--size); - transform: translateY(50%) var(--orient) - translate(calc(var(--size) * var(--x)), calc(var(--size) * var(--y))) - scale(var(--scale-x, 1), var(--scale-y)); + inset: 0; } - .liquid { + [data-role="liquid"] { background: var(--face-color); + transform: translateY(calc((1 - var(--fill)) * 50%)); opacity: calc(1 - var(--is-empty)); - --scale-y: calc(var(--iso-scale) * var(--fill)); - --stack-shift: calc(var(--iso-scale) * (1 - var(--fill))); } - .glass { + [data-role="glass"] { background: oklch(from var(--face-color) l c h / var(--empty-alpha)); - --scale-y: calc(var(--iso-scale) * (1 - var(--fill))); - --stack-shift: 0; } - .front { - &.top, - &.bottom { - --orient: rotate(30deg) skewX(-30deg); - --scale-y: var(--iso-scale); - } - - &.right { - --orient: rotate(-30deg) skewX(-30deg); - --face-color: var(--face-right); - --x: 1; - --y: calc(var(--stack-shift) + var(--iso-scale)); - } - - &.left { - --orient: rotate(30deg) skewX(30deg); - --face-color: var(--face-left); - --x: 0; - --y: var(--stack-shift); - } - - &.top { - --face-color: var(--face-top); - --x: 0; - --y: calc(var(--stack-shift) - var(--iso-scale)); - } - - &.bottom { - --face-color: var(--face-bottom); - --x: 1; - --y: 0; - } + [data-side="bottom"] { + --face-color: var(--face-bottom); + clip-path: polygon(50% 50%, 100% 75%, 50% 100%, 0 75%); } - .rear { - &.right { - --orient: rotate(30deg) skewX(30deg); - --face-color: var(--face-left); - --x: 1; - --y: calc(var(--stack-shift) - var(--iso-scale)); - } - - &.left { - --orient: rotate(-30deg) skewX(-30deg); - --face-color: var(--face-top); - --x: 1; - --y: var(--stack-shift); - --scale-x: -1; - } + [data-side="top"] { + --face-color: var(--face-top); + clip-path: polygon(50% 0, 100% 25%, 50% 50%, 0 25%); } - .liquid.front.top { - --x: calc(1 - var(--fill)); + [data-side="right"] { + --face-color: var(--face-right); + clip-path: polygon(100% 25%, 100% 75%, 50% 100%, 50% 50%); + } + + [data-side="left"] { + --face-color: var(--face-left); + clip-path: polygon(0 25%, 50% 50%, 50% 100%, 0 75%); + } + + [data-side="rear-right"] { + --face-color: var(--face-left); + clip-path: polygon(50% 50%, 100% 75%, 100% 25%, 50% 0); + } + + [data-side="rear-left"] { + --face-color: var(--face-top); + clip-path: polygon(50% 50%, 0 75%, 0 25%, 50% 0); } } diff --git a/website_next/dialog/index.js b/website_next/dialog/index.js new file mode 100644 index 000000000..41fe5ad61 --- /dev/null +++ b/website_next/dialog/index.js @@ -0,0 +1,17 @@ +/** @param {MouseEvent} event */ +function closeOnBackdrop(event) { + const dialog = /** @type {HTMLDialogElement} */ (event.currentTarget); + + if (event.target === dialog) dialog.close(); +} + +/** + * @param {HTMLDialogElement} dialog + * @param {HTMLElement} host + */ +export function openDialog(dialog, host) { + host.append(dialog); + dialog.addEventListener("close", () => dialog.remove(), { once: true }); + dialog.addEventListener("click", closeOnBackdrop); + dialog.showModal(); +} diff --git a/website_next/explore/block/difficulty.js b/website_next/explore/block/difficulty.js index e115ba9eb..3c90d8319 100644 --- a/website_next/explore/block/difficulty.js +++ b/website_next/explore/block/difficulty.js @@ -1,7 +1,12 @@ -/** @typedef {import("../../modules/brk-client/index.js").BlockInfoV1} Block */ +import { + DIFFICULTY_EPOCH_BLOCKS, + HALVING_EPOCH_BLOCKS, + formatEpoch, + formatNumber, + getEpochProgress, +} from "./format.js"; -const DIFFICULTY_EPOCH_BLOCKS = 2_016; -const HALVING_EPOCH_BLOCKS = 210_000; +/** @typedef {import("../../modules/brk-client/index.js").BlockInfoV1} Block */ /** * @param {string} label @@ -27,7 +32,7 @@ function createMetricStat(label, value) { * @param {string} color */ function createEpochProgress(label, height, length, color) { - const progress = (height % length) + 1; + const progress = getEpochProgress(height, length); const row = document.createElement("div"); const head = document.createElement("div"); const name = document.createElement("span"); @@ -42,11 +47,11 @@ function createEpochProgress(label, height, length, color) { done.dataset.epochSegment = "done"; remaining.dataset.epochSegment = "remaining"; row.style.setProperty("--epoch-color", color); - done.style.setProperty("--share", `${(progress / length) * 100}%`); - remaining.style.setProperty("--share", `${((length - progress) / length) * 100}%`); + done.style.setProperty("--share", `${progress.ratio * 100}%`); + remaining.style.setProperty("--share", `${(progress.remaining / length) * 100}%`); name.textContent = label; - value.textContent = `${((progress / length) * 100).toFixed(1)}%`; + value.textContent = formatEpoch(height, length); head.append(name, value); bar.append(done, remaining); row.append(head, bar); @@ -60,7 +65,7 @@ export function createDifficultyPane(block) { pane.dataset.metricList = ""; pane.append( - createMetricStat("Difficulty", block.difficulty.toLocaleString()), + createMetricStat("Difficulty", formatNumber(block.difficulty)), createEpochProgress( "Difficulty epoch", block.height, diff --git a/website_next/explore/block/difficulty/style.css b/website_next/explore/block/difficulty/style.css new file mode 100644 index 000000000..d21f0d8c9 --- /dev/null +++ b/website_next/explore/block/difficulty/style.css @@ -0,0 +1,85 @@ +#block-details section[data-group="difficulty"] { + --section-color: var(--orange); + + [data-metric-list] { + display: grid; + gap: 0.5rem; + min-width: 0; + } + + [data-metric-stat] { + display: grid; + grid-template-columns: minmax(5.5rem, auto) minmax(0, 1fr); + gap: 0.75rem; + align-items: center; + min-width: 0; + + > span { + color: var(--section-color); + font-size: var(--font-size-xs); + line-height: var(--line-height-xs); + text-transform: uppercase; + } + + strong { + min-width: 0; + overflow-wrap: anywhere; + color: var(--white); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-strong); + line-height: var(--line-height-sm); + text-align: right; + } + } + + [data-epoch] { + display: grid; + gap: 0.25rem; + min-width: 0; + } + + [data-epoch-head] { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.75rem; + align-items: center; + min-width: 0; + + > span { + min-width: 0; + overflow-wrap: anywhere; + color: var(--epoch-color); + font-size: var(--font-size-xs); + line-height: var(--line-height-xs); + text-transform: uppercase; + } + + strong { + color: var(--white); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-strong); + line-height: var(--line-height-sm); + text-align: right; + } + } + + [data-epoch-bar] { + display: flex; + gap: 0.125rem; + height: 0.5rem; + min-width: 0; + } + + [data-epoch-segment] { + width: var(--share); + border-radius: 0.125rem; + + &[data-epoch-segment="done"] { + background: var(--epoch-color); + } + + &[data-epoch-segment="remaining"] { + background: color-mix(in oklch, var(--gray) 35%, transparent); + } + } +} diff --git a/website_next/explore/block/fee-chart/style.css b/website_next/explore/block/fee-chart/style.css new file mode 100644 index 000000000..d9ca2fc04 --- /dev/null +++ b/website_next/explore/block/fee-chart/style.css @@ -0,0 +1,5 @@ +#block-details section[data-group="fees"] { + figure[data-fee-chart] { + --chart-xy-height: 7.5rem; + } +} diff --git a/website_next/explore/block/format.js b/website_next/explore/block/format.js new file mode 100644 index 000000000..f3e6b752b --- /dev/null +++ b/website_next/explore/block/format.js @@ -0,0 +1,69 @@ +export const DIFFICULTY_EPOCH_BLOCKS = 2_016; +export const HALVING_EPOCH_BLOCKS = 210_000; + +const MAX_BLOCK_WEIGHT = 4_000_000; + +/** @param {number} value */ +export function formatNumber(value) { + return value.toLocaleString(); +} + +/** @param {number | undefined} value */ +export function formatPoolBlockNumber(value) { + // Temporary compatibility with servers that do not include pool.blockNumber yet. + return `#${formatNumber(value ?? 0)}`; +} + +/** @param {number} unixSeconds */ +export function formatDateTime(unixSeconds) { + return new Date(unixSeconds * 1_000).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "medium", + }); +} + +/** @param {number} bytes */ +export function formatBytes(bytes) { + return bytes >= 1_000_000 + ? `${(bytes / 1_000_000).toFixed(2)} MB` + : `${formatNumber(bytes)} B`; +} + +/** @param {number} weight */ +export function formatWeight(weight) { + return weight >= 1_000_000 + ? `${(weight / 1_000_000).toFixed(2)} MWU` + : `${formatNumber(weight)} WU`; +} + +/** @param {number} weight */ +export function formatBlockFill(weight) { + return `${((weight / MAX_BLOCK_WEIGHT) * 100).toFixed(1)}%`; +} + +/** @param {number} height @param {number} length */ +export function getEpochProgress(height, length) { + const blocks = (height % length) + 1; + + return /** @type {const} */ ({ + number: Math.floor(height / length) + 1, + blocks, + remaining: length - blocks, + ratio: blocks / length, + }); +} + +/** @param {number} height @param {number} length */ +export function formatEpoch(height, length) { + const progress = getEpochProgress(height, length); + + return `#${formatNumber(progress.number)} · ${(progress.ratio * 100).toFixed(1)}%`; +} + +/** @param {string} raw */ +export function getCoinbaseMessage(raw) { + return (raw.match(/[\x20-\x7e]{2,}/g) ?? []) + .map((value) => value.trim()) + .filter((value) => /[A-Za-z0-9]/.test(value)) + .join(" · "); +} diff --git a/website_next/explore/block/header.js b/website_next/explore/block/header.js index 94038e57e..1c9ef26fb 100644 --- a/website_next/explore/block/header.js +++ b/website_next/explore/block/header.js @@ -1,29 +1,9 @@ import { createUsdAmount, renderUsdAmount } from "../../usd/index.js"; +import { formatDateTime } from "./format.js"; +import { createBlockTitle } from "./title.js"; /** @typedef {import("../../modules/brk-client/index.js").BlockInfoV1} Block */ -/** @param {number} unixSeconds */ -function formatDateTime(unixSeconds) { - return new Date(unixSeconds * 1_000).toLocaleString(undefined, { - dateStyle: "medium", - timeStyle: "medium", - }); -} - -/** @param {number} height */ -function createHeightElement(height) { - const element = document.createElement("span"); - const prefix = document.createElement("span"); - const value = document.createElement("span"); - - prefix.dataset.dim = ""; - prefix.textContent = `#${"0".repeat(Math.max(0, 7 - String(height).length))}`; - value.textContent = String(height); - element.append(prefix, value); - - return element; -} - /** @param {string} hash */ function createHashElement(hash) { const element = document.createElement("span"); @@ -41,41 +21,35 @@ function createHashElement(hash) { return element; } -/** @param {number} height */ -function createTitle(height) { - const label = document.createElement("span"); - const value = document.createElement("span"); - - label.dataset.titleLabel = ""; - value.dataset.titleHeight = ""; - label.textContent = "Block"; - value.append(createHeightElement(height)); - - return [label, value]; -} - -export function createBlockHeader() { +/** @param {Node[]} [actions] */ +export function createBlockHeader(actions = []) { const element = document.createElement("header"); const titleRow = document.createElement("div"); + const main = document.createElement("div"); const title = document.createElement("h1"); + const side = document.createElement("div"); + const actionList = document.createElement("div"); const date = document.createElement("time"); - const meta = document.createElement("div"); const hash = document.createElement("p"); const price = createUsdAmount("output", 0, { tone: "positive", }); + main.dataset.blockMain = ""; titleRow.dataset.blockTitle = ""; + side.dataset.blockSide = ""; + actionList.dataset.blockActions = ""; date.dataset.blockDate = ""; - meta.dataset.blockMeta = ""; hash.dataset.blockHashLine = ""; - titleRow.append(title, date); - meta.append(hash, price); - element.append(titleRow, meta); + actionList.append(...actions); + side.append(date, price, actionList); + main.append(title, hash); + titleRow.append(main, side); + element.append(titleRow); /** @param {Block} block */ function update(block) { - title.replaceChildren(...createTitle(block.height)); + title.replaceChildren(...createBlockTitle(block.height)); date.dateTime = new Date(block.timestamp * 1_000).toISOString(); date.textContent = formatDateTime(block.timestamp); hash.replaceChildren(createHashElement(block.id)); diff --git a/website_next/explore/block/index.js b/website_next/explore/block/index.js index 76f2f1cc2..f2420c27b 100644 --- a/website_next/explore/block/index.js +++ b/website_next/explore/block/index.js @@ -5,10 +5,12 @@ import { createRewardsPane } from "./rewards.js"; import { createTransactionPane } from "./transactions.js"; import { createFeeChart } from "./fee-chart.js"; import { appendPane } from "./pane.js"; +import { createBlockReceipt } from "./receipt.js"; export function createBlockDetails() { const element = document.createElement("section"); - const header = createBlockHeader(); + const receipt = createBlockReceipt(); + const header = createBlockHeader([receipt.button]); const content = document.createElement("div"); element.id = "block-details"; @@ -21,17 +23,18 @@ export function createBlockDetails() { element.hidden = false; header.update(block); + receipt.update(block); for (const chart of content.querySelectorAll("[data-fee-chart]")) { chart.dispatchEvent(new Event("chart:destroy")); } content.textContent = ""; - appendPane(content, "Mining", [createMinerPane(block)]); - appendPane(content, "Difficulty", [createDifficultyPane(block)]); - appendPane(content, "Rewards", [createRewardsPane(extras)]); - appendPane(content, "Block", [createTransactionPane(block)]); - appendPane(content, "Fees", [ + appendPane(content, "mining", [createMinerPane(block)]); + appendPane(content, "difficulty", [createDifficultyPane(block)]); + appendPane(content, "rewards", [createRewardsPane(extras)]); + appendPane(content, "block", [createTransactionPane(block)]); + appendPane(content, "fees", [ createFeeChart(extras.feeRange, extras.avgFeeRate), ]); } diff --git a/website_next/explore/block/miner.js b/website_next/explore/block/miner.js index 15d78715d..a5528b4cc 100644 --- a/website_next/explore/block/miner.js +++ b/website_next/explore/block/miner.js @@ -1,15 +1,8 @@ import { createPoolLogo } from "../../pools/index.js"; +import { formatPoolBlockNumber, getCoinbaseMessage } from "./format.js"; /** @typedef {import("../../modules/brk-client/index.js").BlockInfoV1} Block */ -/** @param {string} raw */ -function getCoinbaseMessage(raw) { - return (raw.match(/[\x20-\x7e]{2,}/g) ?? []) - .map((value) => value.trim()) - .filter((value) => /[A-Za-z0-9]/.test(value)) - .join(" · "); -} - /** @param {string} raw */ function createCoinbaseMessage(raw) { const message = getCoinbaseMessage(raw); @@ -45,8 +38,7 @@ export function createMinerPane(block) { logo.dataset.minerLogo = ""; name.textContent = pool.name; - // TODO: remove fallback after the server includes pool.blockNumber everywhere. - blockNumber.textContent = `#${(pool.blockNumber || 0).toLocaleString()}`; + blockNumber.textContent = formatPoolBlockNumber(pool.blockNumber); slug.textContent = pool.slug; title.append(name, blockNumber); identity.append(title, slug); diff --git a/website_next/explore/block/miner/style.css b/website_next/explore/block/miner/style.css new file mode 100644 index 000000000..989b7c9d4 --- /dev/null +++ b/website_next/explore/block/miner/style.css @@ -0,0 +1,71 @@ +#block-details section[data-group="mining"] { + --section-color: var(--orange); + + [data-miner-pane] { + display: grid; + gap: 1rem; + min-width: 0; + } + + [data-miner-head] { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 1rem; + align-items: center; + min-width: 0; + } + + [data-miner-identity] { + display: grid; + gap: 0.125rem; + min-width: 0; + } + + [data-miner-title] { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: baseline; + min-width: 0; + + > strong { + min-width: 0; + overflow-wrap: anywhere; + color: var(--white); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-strong); + line-height: var(--line-height-sm); + } + + > span { + color: var(--gray); + font-size: var(--font-size-sm); + line-height: var(--line-height-sm); + } + } + + [data-miner-slug] { + min-width: 0; + overflow-wrap: anywhere; + color: var(--gray); + font-size: var(--font-size-sm); + line-height: var(--line-height-sm); + } + + [data-miner-logo] { + width: 1.25rem; + height: 1.25rem; + object-fit: contain; + } + + [data-coinbase-message] { + min-width: 0; + overflow: hidden; + color: var(--white); + font-size: var(--font-size-sm); + font-style: italic; + line-height: var(--line-height-sm); + text-overflow: ellipsis; + white-space: nowrap; + } +} diff --git a/website_next/explore/block/pane.js b/website_next/explore/block/pane.js index d7a9f9041..23fe2506e 100644 --- a/website_next/explore/block/pane.js +++ b/website_next/explore/block/pane.js @@ -1,19 +1,14 @@ -/** @param {string} title */ -function groupName(title) { - return title.toLowerCase().replace(/[^a-z0-9]+/g, "-"); -} - /** * @param {HTMLElement} parent - * @param {string} title + * @param {string} group * @param {Node[]} children */ -export function appendPane(parent, title, children) { +export function appendPane(parent, group, children) { if (!children.length) return; const section = document.createElement("section"); - section.dataset.group = groupName(title); + section.dataset.group = group; section.append(...children); parent.append(section); } diff --git a/website_next/explore/block/receipt.js b/website_next/explore/block/receipt.js new file mode 100644 index 000000000..a883938fa --- /dev/null +++ b/website_next/explore/block/receipt.js @@ -0,0 +1,139 @@ +import { createBrand } from "../../brand/index.js"; +import { openDialog } from "../../dialog/index.js"; +import { getCoinbaseMessage } from "./format.js"; +import { createBlockUrl, getReceiptSections } from "./receipt/data.js"; +import { createReceiptQr } from "./receipt/qr.js"; +import { createBlockTitle } from "./title.js"; + +/** @typedef {import("../../modules/brk-client/index.js").BlockInfoV1} Block */ +/** @typedef {import("./receipt/data.js").ReceiptRow} ReceiptRow */ +/** @typedef {import("./receipt/data.js").ReceiptSection} ReceiptSection */ + +/** @param {ReceiptRow} data */ +function createReceiptRow(data) { + const row = document.createElement("div"); + const name = document.createElement("span"); + const amount = document.createElement("strong"); + + row.dataset.receiptRow = data.variant ?? (data.detail ? "amount" : ""); + name.textContent = data.label; + amount.textContent = data.value; + if (data.detail) { + const detail = document.createElement("small"); + + detail.textContent = data.detail; + amount.append(detail); + } + row.append(name, amount); + + return row; +} + +/** @param {ReceiptSection} data */ +function createReceiptSection(data) { + const section = document.createElement("section"); + const heading = document.createElement("h3"); + + section.dataset.receiptSection = ""; + heading.textContent = data.title; + section.append(heading, ...data.rows.map(createReceiptRow)); + + return section; +} + +/** @param {string} message */ +function createCoinbaseNote(message) { + const note = document.createElement("p"); + + note.dataset.receiptNote = ""; + note.textContent = message; + + return note; +} + +/** @param {Block} block */ +function createReceiptHead(block) { + const head = document.createElement("header"); + const title = document.createElement("h2"); + + head.dataset.receiptHead = ""; + title.append(...createBlockTitle(block.height)); + head.append(title); + + return head; +} + +function createReceiptBrand() { + const section = document.createElement("section"); + + section.dataset.receiptBrand = ""; + section.append(createBrand({ tone: "receipt", fill: 1 })); + + return section; +} + +/** @param {Block} block */ +function openReceiptDialog(block) { + const dialog = document.createElement("dialog"); + const paper = document.createElement("article"); + const controls = document.createElement("footer"); + const print = document.createElement("button"); + const closeForm = document.createElement("form"); + const close = document.createElement("button"); + const url = createBlockUrl(block); + const coinbase = getCoinbaseMessage(block.extras.coinbaseSignatureAscii); + + dialog.dataset.blockReceipt = ""; + dialog.tabIndex = -1; + paper.dataset.receiptPaper = ""; + controls.dataset.receiptControls = ""; + print.type = "button"; + print.dataset.receiptAction = "print"; + print.textContent = "Print"; + closeForm.method = "dialog"; + close.type = "submit"; + close.dataset.receiptAction = "close"; + close.textContent = "Close"; + closeForm.append(close); + controls.append(closeForm, print); + paper.append( + createReceiptHead(block), + ...getReceiptSections(block).map(createReceiptSection), + ...(coinbase ? [createCoinbaseNote(coinbase)] : []), + createReceiptQr(block, url), + createReceiptBrand(), + ); + dialog.append(paper, controls); + + print.addEventListener("click", () => { + window.print(); + }); + openDialog(dialog, document.body); + dialog.focus({ preventScroll: true }); + dialog.scrollTop = 0; +} + +export function createBlockReceipt() { + const button = document.createElement("button"); + /** @type {Block | null} */ + let block = null; + + button.type = "button"; + button.disabled = true; + button.dataset.receiptButton = ""; + button.textContent = "Receipt"; + button.addEventListener("click", () => { + if (block) openReceiptDialog(block); + }); + + /** @param {Block} nextBlock */ + function update(nextBlock) { + block = nextBlock; + button.disabled = false; + } + + return /** @type {const} */ ({ + button, + update, + }); +} diff --git a/website_next/explore/block/receipt/data.js b/website_next/explore/block/receipt/data.js new file mode 100644 index 000000000..36e2441ad --- /dev/null +++ b/website_next/explore/block/receipt/data.js @@ -0,0 +1,162 @@ +import { getBtcParts, satsToUsd } from "../../../btc/index.js"; +import { getUsdParts } from "../../../usd/index.js"; +import { formatFeeRate } from "../../../utils/fee-rate.js"; +import { + DIFFICULTY_EPOCH_BLOCKS, + HALVING_EPOCH_BLOCKS, + formatBlockFill, + formatBytes, + formatDateTime, + formatEpoch, + formatNumber, + formatPoolBlockNumber, + formatWeight, +} from "../format.js"; + +/** @typedef {import("../../../modules/brk-client/index.js").BlockInfoV1} Block */ + +/** + * @typedef {Object} ReceiptRow + * @property {string} label + * @property {string} value + * @property {string} [detail] + * @property {"total" | "wide"} [variant] + */ + +/** + * @typedef {Object} ReceiptSection + * @property {string} title + * @property {ReceiptRow[]} rows + */ + +/** @param {number} value */ +function formatBtc(value) { + return getBtcParts(value).map(({ text }) => text).join(""); +} + +/** @param {number} value */ +function formatUsd(value) { + return getUsdParts(value).map(({ text }) => text).join(""); +} + +/** + * @param {number} sats + * @param {number} price + */ +function formatSatsUsd(sats, price) { + return formatUsd(satsToUsd(sats, price)); +} + +/** @param {Block} block */ +export function createBlockUrl(block) { + return new URL(`/block/${block.id}`, window.location.origin).href; +} + +/** @param {string} url */ +export function formatDisplayUrl(url) { + return url.replace(/^https?:\/\//, ""); +} + +/** + * @param {string} label + * @param {string} value + * @param {"total" | "wide"} [variant] + * @returns {ReceiptRow} + */ +function createRow(label, value, variant) { + return { label, value, ...(variant ? { variant } : {}) }; +} + +/** + * @param {string} label + * @param {string} value + * @param {string} detail + * @param {"total"} [variant] + * @returns {ReceiptRow} + */ +function createAmountRow(label, value, detail, variant) { + return { label, value, detail, ...(variant ? { variant } : {}) }; +} + +/** + * @param {string} title + * @param {ReceiptRow[]} rows + * @returns {ReceiptSection} + */ +function createSection(title, rows) { + return { title, rows }; +} + +/** @param {Block} block */ +export function getReceiptSections(block) { + const { extras } = block; + const subsidy = extras.reward - extras.totalFees; + + return [ + createSection("Summary", [ + createRow("Time", formatDateTime(block.timestamp), "wide"), + createRow("Price", formatUsd(extras.price)), + createRow("Miner", extras.pool.name), + createAmountRow( + "Reward", + formatBtc(extras.reward), + formatSatsUsd(extras.reward, extras.price), + "total", + ), + createAmountRow( + "Fees", + formatBtc(extras.totalFees), + formatSatsUsd(extras.totalFees, extras.price), + ), + createRow("Tx", formatNumber(block.txCount)), + createRow( + "Size", + `${formatBytes(block.size)} · ${formatBlockFill(block.weight)}`, + ), + ]), + createSection("Reward split", [ + createAmountRow( + "Subsidy", + formatBtc(subsidy), + formatSatsUsd(subsidy, extras.price), + ), + createAmountRow( + "Fees", + formatBtc(extras.totalFees), + formatSatsUsd(extras.totalFees, extras.price), + ), + ]), + createSection("Block", [ + createRow("Weight", formatWeight(block.weight)), + createRow("Virtual", `${formatNumber(extras.virtualSize)} vB`), + createRow("Avg tx", formatBytes(extras.avgTxSize)), + createRow("Input", formatNumber(extras.totalInputs)), + createRow("Output", formatNumber(extras.totalOutputs)), + ]), + createSection("Fees", [ + createRow("Median", `${formatFeeRate(extras.medianFee)} sat/vB`), + createRow("Average", `${formatFeeRate(extras.avgFeeRate)} sat/vB`), + createRow( + "Range", + `${formatFeeRate(extras.feeRange[0])} - ${formatFeeRate(extras.feeRange[6])} sat/vB`, + ), + createRow("Avg fee", `${formatNumber(extras.avgFee)} sat`), + createRow("Median fee", `${formatNumber(extras.medianFeeAmt)} sat`), + ]), + createSection("Mining", [ + createRow("Pool slug", extras.pool.slug), + createRow("Pool block", formatPoolBlockNumber(extras.pool.blockNumber)), + createRow("Difficulty", formatNumber(block.difficulty), "wide"), + createRow( + "Diff epoch", + formatEpoch(block.height, DIFFICULTY_EPOCH_BLOCKS), + ), + createRow("Halving", formatEpoch(block.height, HALVING_EPOCH_BLOCKS)), + ]), + createSection("Verify", [ + createRow("Height", `#${formatNumber(block.height)}`), + createRow("Hash", block.id, "wide"), + createRow("Previous", block.previousblockhash, "wide"), + ]), + ]; +} diff --git a/website_next/explore/block/receipt/qr.js b/website_next/explore/block/receipt/qr.js new file mode 100644 index 000000000..542cb77f7 --- /dev/null +++ b/website_next/explore/block/receipt/qr.js @@ -0,0 +1,25 @@ +import { createQrDataUrl } from "../../../qr/index.js"; +import { formatDisplayUrl } from "./data.js"; + +/** @typedef {import("../../../modules/brk-client/index.js").BlockInfoV1} Block */ + +/** + * @param {Block} block + * @param {string} url + */ +export function createReceiptQr(block, url) { + const section = document.createElement("section"); + const label = document.createElement("span"); + const image = document.createElement("img"); + const link = document.createElement("a"); + + section.dataset.receiptQr = ""; + label.textContent = "Scan to verify"; + image.alt = `QR code for block ${block.height}`; + image.src = createQrDataUrl(url, { padX: 0, padY: 0, scale: 8 }); + link.href = url; + link.textContent = formatDisplayUrl(url); + section.append(label, image, link); + + return section; +} diff --git a/website_next/explore/block/receipt/style.css b/website_next/explore/block/receipt/style.css new file mode 100644 index 000000000..c824ea991 --- /dev/null +++ b/website_next/explore/block/receipt/style.css @@ -0,0 +1,320 @@ +dialog[data-block-receipt] { + --receipt-page-width: 80mm; + --receipt-paper-width: 72mm; + --receipt-top-teeth: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 2 1' preserveAspectRatio='none'%3E%3Cpath fill='black' d='M0 1 1 0 2 1z'/%3E%3C/svg%3E"); + --receipt-bottom-teeth: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 2 1' preserveAspectRatio='none'%3E%3Cpath fill='black' d='M0 0 2 0 1 1z'/%3E%3C/svg%3E"); + --receipt-dialog-padding-block: var(--page-x); + --receipt-tooth-count: 36; + --receipt-tooth-size: 0.16rem; + + position: fixed; + inset: 0 0 auto; + width: min(100% - 2rem, 28rem); + max-height: 100dvh; + margin: 0 auto; + border-radius: 0; + overflow-y: auto; + padding-block: var(--receipt-dialog-padding-block); + color: var(--black); + background: transparent; + font-family: var(--font-mono); + overscroll-behavior: contain; + scrollbar-width: none; + + :is( + [data-receipt-head] > h2, + [data-receipt-section] > h3, + [data-receipt-row] > span, + [data-receipt-row] > strong, + [data-receipt-row] small, + [data-receipt-note], + [data-receipt-qr] > span, + [data-receipt-qr] > a + ) { + font-size: var(--font-size-xs); + line-height: var(--line-height-xs); + } + + [data-receipt-paper] { + position: relative; + display: grid; + gap: 0.375rem; + width: min(100%, var(--receipt-paper-width)); + margin-inline: auto; + padding: calc(var(--receipt-tooth-size) + 0.5rem) 1rem + calc(var(--receipt-tooth-size) + 0.75rem); + border-radius: 0; + background: var(--white); + mask: + var(--receipt-top-teeth) top left / calc(100% / var(--receipt-tooth-count)) + var(--receipt-tooth-size) repeat-x, + linear-gradient(#000 0 0) 0 var(--receipt-tooth-size) / 100% + calc( + 100% - var(--receipt-tooth-size) - var(--receipt-tooth-size) + ) + no-repeat, + var(--receipt-bottom-teeth) bottom left / + calc(100% / var(--receipt-tooth-count)) var(--receipt-tooth-size) + repeat-x; + } + + [data-receipt-head] { + --block-title-height-color: color-mix(in oklch, var(--black) 62%, transparent); + --block-title-height-line-height: var(--line-height-base); + --block-title-height-size: var(--font-size-base); + --block-title-label-size: 1.375rem; + + display: grid; + gap: 0.3125rem; + align-items: center; + justify-items: center; + margin-block-start: 0.5rem; + text-align: center; + + > h2 { + display: flex; + flex-wrap: wrap; + gap: 0.35em; + align-items: baseline; + justify-content: center; + min-width: 0; + color: var(--black); + font-family: var(--font-mono); + font-weight: var(--font-weight-regular); + line-height: 1; + } + } + + [data-receipt-section] { + display: grid; + gap: 0.25rem; + min-width: 0; + border-top: 1px dashed color-mix(in oklch, var(--black) 35%, transparent); + padding-top: 0.75rem; + + > h3 { + color: var(--black); + font-family: var(--font-mono); + font-weight: var(--font-weight-strong); + text-transform: uppercase; + } + } + + [data-receipt-row] { + display: grid; + grid-template-columns: minmax(5rem, auto) minmax(0, 1fr); + gap: 0.75rem; + align-items: baseline; + min-width: 0; + + > span { + color: color-mix(in oklch, var(--black) 58%, transparent); + text-transform: uppercase; + } + + > strong { + min-width: 0; + overflow-wrap: anywhere; + color: var(--black); + font-weight: var(--font-weight-strong); + text-align: right; + + small { + display: block; + color: color-mix(in oklch, var(--black) 52%, transparent); + font-weight: var(--font-weight-regular); + } + } + + &[data-receipt-row="wide"] { + grid-template-columns: minmax(0, 1fr); + gap: 0.125rem; + + > strong { + word-break: break-all; + text-align: left; + } + } + + &[data-receipt-row="amount"], + &[data-receipt-row="total"] { + align-items: start; + + > strong { + display: grid; + gap: 0.125rem; + } + } + + &[data-receipt-row="total"] { + border-block: 1px dashed + color-mix(in oklch, var(--black) 35%, transparent); + padding-block: 0.375rem; + + > span, + > strong { + color: var(--black); + } + } + } + + [data-receipt-note] { + overflow: hidden; + color: var(--black); + font-style: italic; + text-overflow: ellipsis; + white-space: nowrap; + } + + [data-receipt-brand] { + display: grid; + justify-items: center; + border-top: 1px dashed color-mix(in oklch, var(--black) 35%, transparent); + padding-top: 0.5rem; + } + + [data-receipt-qr] { + display: grid; + justify-items: center; + gap: 0.5rem; + border-top: 1px dashed color-mix(in oklch, var(--black) 35%, transparent); + padding-top: 0.75rem; + text-align: center; + + > span { + color: var(--black); + text-transform: uppercase; + } + + img { + width: min(100%, 10rem); + aspect-ratio: 1; + image-rendering: pixelated; + } + + a { + min-width: 0; + overflow-wrap: anywhere; + color: var(--black); + text-decoration: none; + } + } + + [data-receipt-controls] { + position: fixed; + bottom: 0.5rem; + left: 50%; + z-index: 1; + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + justify-content: center; + width: min(100% - 2rem, var(--receipt-paper-width)); + transform: translateX(-50%); + + form { + display: contents; + } + + button { + padding: 0.25rem 0.375rem; + font-size: var(--font-size-xs); + } + + [data-receipt-action="close"] { + --button-background: var(--red); + --button-hover-background: var(--black); + --button-color: var(--black); + } + + [data-receipt-action="print"] { + --button-background: var(--green); + --button-hover-background: var(--black); + --button-color: var(--black); + } + + @media (hover: hover) and (pointer: fine) { + [data-receipt-action="close"]:hover { + color: var(--red); + } + + [data-receipt-action="print"]:hover { + color: var(--green); + } + } + + [data-receipt-action="close"][data-press] { + color: var(--red); + } + + [data-receipt-action="print"][data-press] { + color: var(--green); + } + + [data-receipt-action]:active { + color: var(--black); + } + } +} + +@media print { + @page { + size: 80mm auto; + margin: 4mm; + } + + html, + body { + background: white !important; + } + + body > :not(dialog[data-block-receipt]) { + display: none !important; + } + + dialog[data-block-receipt] { + position: static; + display: block; + width: var(--receipt-page-width); + max-width: none; + max-height: none; + margin: 0; + overflow: visible; + padding: 0; + color: black; + background: white; + overscroll-behavior: auto; + } + + dialog[data-block-receipt]::backdrop { + display: none; + } + + dialog[data-block-receipt] [data-receipt-paper] { + width: var(--receipt-paper-width); + padding: calc(var(--receipt-tooth-size) + 0.5rem) 1rem + calc(var(--receipt-tooth-size) + 0.75rem); + border-radius: 0; + color: black; + background: white; + font-size: 8pt; + line-height: 1.25; + } + + dialog[data-block-receipt] :is( + [data-receipt-head] > h2, + [data-receipt-row] > span, + [data-receipt-row] > strong, + [data-receipt-row] small, + [data-receipt-section] > h3, + [data-receipt-qr] > span, + [data-receipt-qr] > a + ) { + font-size: 8pt; + line-height: 1.25; + } + + dialog[data-block-receipt] [data-receipt-controls] { + display: none; + } +} diff --git a/website_next/explore/block/rewards.js b/website_next/explore/block/rewards.js index 0d464b836..1bb53f2c4 100644 --- a/website_next/explore/block/rewards.js +++ b/website_next/explore/block/rewards.js @@ -1,4 +1,4 @@ -import { createBtcAmount, SATS_PER_BTC } from "../../btc/index.js"; +import { createBtcAmount, satsToUsd } from "../../btc/index.js"; import { appendLegendListItem, createLegendItem, @@ -23,20 +23,12 @@ function formatShare(sats, total) { return `${((sats / total) * 100).toFixed(2)}%`; } -/** - * @param {number} sats - * @param {number} price - */ -function getSatsUsd(sats, price) { - return (sats / SATS_PER_BTC) * price; -} - /** * @param {number} sats * @param {number} price */ function createSatsUsdAmount(sats, price) { - return createUsdAmount("span", getSatsUsd(sats, price)); + return createUsdAmount("span", satsToUsd(sats, price)); } /** diff --git a/website_next/explore/block/rewards/style.css b/website_next/explore/block/rewards/style.css new file mode 100644 index 000000000..ec10663a1 --- /dev/null +++ b/website_next/explore/block/rewards/style.css @@ -0,0 +1,59 @@ +#block-details section[data-group="rewards"] { + --section-color: var(--orange); + + [data-rewards-pane] { + display: grid; + gap: 0.5rem; + min-width: 0; + } + + [data-reward-total] { + display: grid; + gap: 0.125rem; + justify-items: start; + min-width: 0; + color: var(--gray); + font-size: var(--font-size-xs); + line-height: var(--line-height-xs); + text-align: left; + + strong { + min-width: 0; + overflow-wrap: anywhere; + color: var(--white); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-strong); + line-height: var(--line-height-sm); + } + + > span:first-child { + color: var(--section-color); + text-transform: uppercase; + } + } + + [data-reward-bar] { + display: flex; + gap: 0.125rem; + height: 0.5rem; + min-width: 0; + } + + [data-reward-segment] { + width: var(--share); + border-radius: 0.125rem; + transition: opacity var(--transition-duration) ease; + + &[data-reward-segment="subsidy"] { + background: var(--orange); + } + + &[data-reward-segment="fees"] { + background: var(--green); + } + + &[data-muted] { + opacity: 0.25; + } + } +} diff --git a/website_next/explore/block/style.css b/website_next/explore/block/style.css index 04874824a..03d5a3378 100644 --- a/website_next/explore/block/style.css +++ b/website_next/explore/block/style.css @@ -19,14 +19,25 @@ } > header { + --block-title-height-color: var(--gray); + --block-title-height-line-height: var(--line-height-lg); + --block-title-height-size: var(--font-size-lg); + --block-title-label-size: 2.5rem; + display: grid; padding-bottom: 1.25rem; - :is([data-block-title], [data-block-meta]) { + [data-block-title] { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 0.35rem 1rem; - align-items: baseline; + align-items: start; + min-width: 0; + } + + [data-block-main] { + display: grid; + gap: 0.15rem; min-width: 0; } @@ -38,24 +49,10 @@ min-width: 0; overflow-wrap: anywhere; font-family: var(--font-mono); - font-weight: 400; + font-weight: var(--font-weight-regular); line-height: 1; } - [data-title-label] { - font-family: var(--font-serif); - font-size: 2.5rem; - font-style: italic; - line-height: 0.9; - text-transform: lowercase; - } - - [data-title-height] { - color: var(--gray); - font-size: var(--font-size-lg); - line-height: var(--line-height-lg); - } - [data-block-date], [data-block-hash-line] { color: var(--gray); @@ -66,6 +63,32 @@ text-align: right; } + [data-block-side] { + display: grid; + gap: 0.15rem; + justify-items: end; + min-width: max-content; + } + + [data-block-actions] { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + justify-content: flex-end; + min-width: 0; + + button { + padding: 0.375rem 0.5rem; + font-size: var(--font-size-xs); + line-height: 1; + } + + [data-receipt-button] { + padding: 0.25rem 0.375rem; + } + } + [data-block-hash-line] { min-width: 0; overflow: hidden; @@ -85,325 +108,26 @@ align-content: start; gap: 0.75rem; min-width: 0; + } +} - &[data-group="mining"] { - --section-color: var(--orange); +:is(#block-details > header, dialog[data-block-receipt] [data-receipt-head]) { + [data-title-label] { + font-family: var(--font-serif); + font-size: var(--block-title-label-size); + font-style: italic; + line-height: 0.9; + text-transform: lowercase; + } - [data-miner-pane] { - display: grid; - gap: 1rem; - min-width: 0; - } + [data-title-height] { + color: var(--block-title-height-color); + font-size: var(--block-title-height-size); + line-height: var(--block-title-height-line-height); + } - [data-miner-head] { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 1rem; - align-items: center; - min-width: 0; - } - - [data-miner-identity] { - display: grid; - gap: 0.125rem; - min-width: 0; - } - - [data-miner-title] { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - align-items: baseline; - min-width: 0; - - > strong { - min-width: 0; - overflow-wrap: anywhere; - color: var(--white); - font-size: var(--font-size-sm); - font-weight: 450; - line-height: var(--line-height-sm); - } - - > span { - color: var(--gray); - font-size: var(--font-size-sm); - line-height: var(--line-height-sm); - } - } - - [data-miner-slug] { - min-width: 0; - overflow-wrap: anywhere; - color: var(--gray); - font-size: var(--font-size-sm); - line-height: var(--line-height-sm); - } - - [data-miner-logo] { - width: 1.25rem; - height: 1.25rem; - object-fit: contain; - } - - [data-coinbase-message] { - min-width: 0; - overflow: hidden; - color: var(--white); - font-size: var(--font-size-sm); - font-style: italic; - line-height: var(--line-height-sm); - text-overflow: ellipsis; - white-space: nowrap; - } - } - - &[data-group="difficulty"] { - --section-color: var(--orange); - - [data-metric-list] { - display: grid; - gap: 0.5rem; - min-width: 0; - } - - [data-metric-stat] { - display: grid; - grid-template-columns: minmax(5.5rem, auto) minmax(0, 1fr); - gap: 0.75rem; - align-items: center; - min-width: 0; - - > span { - color: var(--section-color); - font-size: var(--font-size-xs); - line-height: var(--line-height-xs); - text-transform: uppercase; - } - - strong { - min-width: 0; - overflow-wrap: anywhere; - color: var(--white); - font-size: var(--font-size-sm); - font-weight: 450; - line-height: var(--line-height-sm); - text-align: right; - } - } - - [data-epoch] { - display: grid; - gap: 0.25rem; - min-width: 0; - } - - [data-epoch-head] { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 0.75rem; - align-items: center; - min-width: 0; - - > span { - min-width: 0; - overflow-wrap: anywhere; - color: var(--epoch-color); - font-size: var(--font-size-xs); - line-height: var(--line-height-xs); - text-transform: uppercase; - } - - strong { - color: var(--white); - font-size: var(--font-size-sm); - font-weight: 450; - line-height: var(--line-height-sm); - text-align: right; - } - } - - [data-epoch-bar] { - display: flex; - gap: 0.125rem; - height: 0.5rem; - min-width: 0; - } - - [data-epoch-segment] { - width: var(--share); - border-radius: 0.125rem; - - &[data-epoch-segment="done"] { - background: var(--epoch-color); - } - - &[data-epoch-segment="remaining"] { - background: color-mix(in oklch, var(--gray) 35%, transparent); - } - } - } - - &[data-group="block"] { - --section-color: var(--cyan); - - [data-block-box] { - display: grid; - gap: 0.5rem; - min-width: 0; - border: 1px solid - color-mix(in oklch, var(--section-color) 35%, transparent); - border-radius: 0.25rem; - padding: 0.75rem; - - &[data-block-box="tx"] { - border-color: color-mix(in oklch, var(--orange) 35%, transparent); - } - - &[data-block-box="input"] { - border-color: color-mix(in oklch, var(--yellow) 55%, transparent); - } - - &[data-block-box="output"] { - border-color: color-mix(in oklch, var(--red) 55%, transparent); - } - } - - [data-inline-row] { - display: grid; - grid-template-columns: minmax(4.5rem, auto) minmax(0, 1fr); - gap: 0.75rem; - align-items: center; - min-width: 0; - - > span { - color: var(--section-color); - font-size: var(--font-size-xs); - line-height: var(--line-height-xs); - text-transform: uppercase; - } - - strong { - display: flex; - gap: 0.5rem; - align-items: center; - justify-content: flex-end; - min-width: 0; - color: var(--section-color); - font-size: var(--font-size-sm); - font-weight: 450; - line-height: var(--line-height-sm); - text-align: right; - } - } - - [data-block-io] { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - gap: 0.75rem; - min-width: 0; - - [data-block-box] { - align-content: center; - padding: 0.5rem; - } - - [data-inline-row] { - grid-template-columns: auto auto; - justify-content: space-between; - gap: 0.5rem; - width: 100%; - } - } - - [data-block-box="tx"] > [data-inline-row] { - > span, - strong { - color: var(--orange); - } - } - - :is([data-block-box="input"], [data-block-box="output"]) > [data-inline-row] { - > span, - strong { - color: var(--block-box-color); - } - } - - [data-block-box="input"] { - --block-box-color: var(--yellow); - } - - [data-block-box="output"] { - --block-box-color: var(--red); - } - } - - &[data-group="rewards"] { - --section-color: var(--orange); - - [data-rewards-pane] { - display: grid; - gap: 0.5rem; - min-width: 0; - } - - [data-reward-total] { - display: grid; - gap: 0.125rem; - justify-items: start; - min-width: 0; - color: var(--gray); - font-size: var(--font-size-xs); - line-height: var(--line-height-xs); - text-align: left; - - strong { - min-width: 0; - overflow-wrap: anywhere; - color: var(--white); - font-size: var(--font-size-sm); - font-weight: 450; - line-height: var(--line-height-sm); - } - - > span:first-child { - color: var(--section-color); - text-transform: uppercase; - } - } - - [data-reward-bar] { - display: flex; - gap: 0.125rem; - height: 0.5rem; - min-width: 0; - } - - [data-reward-segment] { - width: var(--share); - border-radius: 0.125rem; - transition: opacity var(--transition-duration) ease; - - &[data-reward-segment="subsidy"] { - background: var(--orange); - } - - &[data-reward-segment="fees"] { - background: var(--green); - } - - &[data-muted] { - opacity: 0.25; - } - } - } - - &[data-group="fees"] { - figure[data-fee-chart] { - --chart-xy-height: 7.5rem; - } - } + [data-title-height] [data-dim] { + opacity: 0.5; } } diff --git a/website_next/explore/block/title.js b/website_next/explore/block/title.js new file mode 100644 index 000000000..668b0f8c1 --- /dev/null +++ b/website_next/explore/block/title.js @@ -0,0 +1,27 @@ +/** @param {number} height */ +function createBlockHeight(height) { + const element = document.createElement("span"); + const prefix = document.createElement("span"); + const value = document.createElement("span"); + const text = String(height); + + prefix.dataset.dim = ""; + prefix.textContent = `#${"0".repeat(Math.max(0, 7 - text.length))}`; + value.textContent = text; + element.append(prefix, value); + + return element; +} + +/** @param {number} height */ +export function createBlockTitle(height) { + const label = document.createElement("span"); + const value = document.createElement("span"); + + label.dataset.titleLabel = ""; + value.dataset.titleHeight = ""; + label.textContent = "Block"; + value.append(createBlockHeight(height)); + + return /** @type {const} */ ([label, value]); +} diff --git a/website_next/explore/block/transactions.js b/website_next/explore/block/transactions.js index 5151ec204..d498cc361 100644 --- a/website_next/explore/block/transactions.js +++ b/website_next/explore/block/transactions.js @@ -1,24 +1,17 @@ +import { formatBlockFill, formatBytes, formatNumber } from "./format.js"; + /** @typedef {import("../../modules/brk-client/index.js").BlockInfoV1} Block */ -const MAX_BLOCK_WEIGHT = 4_000_000; - -/** @param {number} bytes */ -function formatBytes(bytes) { - return bytes >= 1_000_000 - ? `${(bytes / 1_000_000).toFixed(2)} MB` - : `${bytes.toLocaleString()} B`; -} - /** * @param {string} label * @param {(string | Node)[]} values */ -function createInlineRow(label, values) { +function createBlockRow(label, values) { const row = document.createElement("div"); const name = document.createElement("span"); const data = document.createElement("strong"); - row.dataset.inlineRow = ""; + row.dataset.blockRow = ""; name.textContent = label; data.append(...values); row.append(name, data); @@ -29,22 +22,17 @@ function createInlineRow(label, values) { /** * @param {string} label * @param {string | Node} value - * @param {string} [type] + * @param {string} type */ -function createInlineBox(label, value, type = "inline") { +function createBlockBox(label, value, type) { const box = document.createElement("div"); box.dataset.blockBox = type; - box.append(createInlineRow(label, [value])); + box.append(createBlockRow(label, [value])); return box; } -/** @param {Block} block */ -function formatBlockFill(block) { - return `${((block.weight / MAX_BLOCK_WEIGHT) * 100).toFixed(1)}%`; -} - /** @param {Block} block */ export function createTransactionPane(block) { const { extras } = block; @@ -56,15 +44,17 @@ export function createTransactionPane(block) { transactions.dataset.blockBox = "tx"; io.dataset.blockIo = ""; io.append( - createInlineBox("Input", extras.totalInputs.toLocaleString(), "input"), - createInlineBox("Output", extras.totalOutputs.toLocaleString(), "output"), + createBlockBox("Input", formatNumber(extras.totalInputs), "input"), + createBlockBox("Output", formatNumber(extras.totalOutputs), "output"), ); transactions.append( - createInlineRow("Tx", [block.txCount.toLocaleString()]), + createBlockRow("Tx", [formatNumber(block.txCount)]), io, ); box.append( - createInlineRow("Block", [`${formatBytes(block.size)} · ${formatBlockFill(block)}`]), + createBlockRow("Block", [ + `${formatBytes(block.size)} · ${formatBlockFill(block.weight)}`, + ]), transactions, ); diff --git a/website_next/explore/block/transactions/style.css b/website_next/explore/block/transactions/style.css new file mode 100644 index 000000000..ce5811a6d --- /dev/null +++ b/website_next/explore/block/transactions/style.css @@ -0,0 +1,93 @@ +#block-details section[data-group="block"] { + --section-color: var(--cyan); + + [data-block-box] { + display: grid; + gap: 0.5rem; + min-width: 0; + border: 1px solid color-mix(in oklch, var(--section-color) 35%, transparent); + border-radius: 0.25rem; + padding: 0.75rem; + + &[data-block-box="tx"] { + border-color: color-mix(in oklch, var(--orange) 35%, transparent); + } + + &[data-block-box="input"] { + border-color: color-mix(in oklch, var(--yellow) 55%, transparent); + } + + &[data-block-box="output"] { + border-color: color-mix(in oklch, var(--red) 55%, transparent); + } + } + + [data-block-row] { + display: grid; + grid-template-columns: minmax(4.5rem, auto) minmax(0, 1fr); + gap: 0.75rem; + align-items: center; + min-width: 0; + + > span { + color: var(--section-color); + font-size: var(--font-size-xs); + line-height: var(--line-height-xs); + text-transform: uppercase; + } + + strong { + display: flex; + gap: 0.5rem; + align-items: center; + justify-content: flex-end; + min-width: 0; + color: var(--section-color); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-strong); + line-height: var(--line-height-sm); + text-align: right; + } + } + + [data-block-io] { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 0.75rem; + min-width: 0; + + [data-block-box] { + align-content: center; + padding: 0.5rem; + } + + [data-block-row] { + grid-template-columns: auto auto; + justify-content: space-between; + gap: 0.5rem; + width: 100%; + } + } + + [data-block-box="tx"] > [data-block-row] { + > span, + strong { + color: var(--orange); + } + } + + :is([data-block-box="input"], [data-block-box="output"]) > [data-block-row] { + > span, + strong { + color: var(--block-box-color); + } + } + + [data-block-box="input"] { + --block-box-color: var(--yellow); + } + + [data-block-box="output"] { + --block-box-color: var(--red); + } +} diff --git a/website_next/explore/chain/block-cube.js b/website_next/explore/chain/block-cube.js index 3f4ae7525..8fbca1458 100644 --- a/website_next/explore/chain/block-cube.js +++ b/website_next/explore/chain/block-cube.js @@ -6,6 +6,7 @@ import { createHeightElement, dim, formatHHMM, + formatNumber, formatShortDate, } from "./format.js"; @@ -15,7 +16,7 @@ import { export function createPlaceholderCube() { const cube = document.createElement("div"); - cube.dataset.cube = ""; + cube.dataset.cube = "block"; cube.dataset.placeholder = ""; return cube; @@ -44,7 +45,7 @@ function createConfirmedCube(block, onSelect) { cube.element.dataset.hash = block.id; cube.element.dataset.height = String(block.height); cube.element.dataset.timestamp = String(block.timestamp); - cube.element.title = `Block ${block.height.toLocaleString()}`; + cube.element.title = `Block ${formatNumber(block.height)}`; onPlainClick(cube.element, () => onSelect(cube.element)); const date = document.createElement("p"); @@ -145,7 +146,7 @@ export function updateProjectedCube(cube, block) { String(Math.min(1, block.blockVSize / 1_000_000)), ); - cube.parts.txs.nodeValue = block.nTx.toLocaleString(); + cube.parts.txs.nodeValue = formatNumber(block.nTx); cube.parts.txsUnit.nodeValue = block.nTx === 1 ? "tx" : "txs"; cube.parts.median.nodeValue = formatFeeRate(block.medianFee); cube.parts.rangeLo.nodeValue = formatFeeRate(block.feeRange[0]); diff --git a/website_next/explore/chain/cube/index.js b/website_next/explore/chain/cube/index.js index 7d77ba333..b518fc740 100644 --- a/website_next/explore/chain/cube/index.js +++ b/website_next/explore/chain/cube/index.js @@ -1,3 +1,5 @@ +import { CUBE_FACE_SPECS, createCubeFace } from "../../../cube/faces.js"; + /** * @param {number} [fill] */ @@ -23,25 +25,16 @@ export function createCubeDiv(fill = 1) { * @param {number} fill */ function populateCube(element, fill) { - const topFace = createFace("face-text", "top"); - const rightFace = createFace("face-text", "right"); - const leftFace = createFace("face-text", "left"); + const topFace = createCubeFace("div", "text", "top"); + const rightFace = createCubeFace("div", "text", "right"); + const leftFace = createCubeFace("div", "text", "left"); - element.dataset.cube = ""; + element.dataset.cube = "block"; element.style.setProperty("--fill", String(fill)); element.append( - createFace("glass", "bottom"), - createFace("glass", "rear-right"), - createFace("glass", "rear-left"), - createFace("liquid", "bottom"), - createFace("liquid", "rear-right"), - createFace("liquid", "rear-left"), - createFace("liquid", "right"), - createFace("liquid", "left"), - createFace("liquid", "top"), - createFace("glass", "right"), - createFace("glass", "left"), - createFace("glass", "top"), + ...CUBE_FACE_SPECS.map(([role, side]) => + createCubeFace("div", role, side), + ), rightFace, leftFace, topFace, @@ -49,15 +42,3 @@ function populateCube(element, fill) { return { topFace, rightFace, leftFace }; } - -/** - * @param {string} role - * @param {string} position - */ -function createFace(role, position) { - const element = document.createElement("div"); - - element.className = `face ${role} ${position}`; - - return element; -} diff --git a/website_next/explore/chain/cube/style.css b/website_next/explore/chain/cube/style.css index 23cf38cc2..b2eabf8ec 100644 --- a/website_next/explore/chain/cube/style.css +++ b/website_next/explore/chain/cube/style.css @@ -96,11 +96,11 @@ visibility: hidden; } - &[data-skeleton] .face-text { + &[data-skeleton] [data-role="text"] { visibility: hidden; } - .face { + [data-face] { position: absolute; top: 0; left: 0; @@ -117,7 +117,7 @@ pointer-events: auto; } - .liquid { + [data-role="liquid"] { --face-scale-y: calc(var(--iso-scale) * var(--fill)); --face-stack-shift: calc(var(--iso-scale) * (1 - var(--fill))); @@ -125,14 +125,14 @@ opacity: calc(1 - var(--is-empty)); } - .glass { + [data-role="glass"] { --face-scale-y: calc(var(--iso-scale) * (1 - var(--fill))); --face-stack-shift: 0; background: oklch(from var(--face-color) l c h / var(--cube-empty-alpha)); } - .face-text { + [data-role="text"] { --face-scale-y: var(--iso-scale); --face-stack-shift: 0; @@ -142,21 +142,21 @@ padding: 0.1rem; font-family: var(--font-mono); font-size: var(--font-size-xs); - font-weight: 450; + font-weight: var(--font-weight-strong); line-height: var(--line-height-base); text-align: center; pointer-events: none; - &.top { + &[data-side="top"] { justify-content: center; text-transform: uppercase; } - &.right { + &[data-side="right"] { justify-content: space-between; } - &.left { + &[data-side="left"] { justify-content: center; } @@ -165,72 +165,72 @@ } } - .top, - .bottom { + [data-side="top"], + [data-side="bottom"] { --face-orient: rotate(30deg) skewX(-30deg); --face-scale-y: var(--iso-scale); } - .right, - .rear-left { + [data-side="right"], + [data-side="rear-left"] { --face-orient: rotate(-30deg) skewX(-30deg); } - .left, - .rear-right { + [data-side="left"], + [data-side="rear-right"] { --face-orient: rotate(30deg) skewX(30deg); } - .top, - .rear-right { + [data-side="top"], + [data-side="rear-right"] { --face-y: calc(var(--face-stack-shift) - var(--iso-scale)); } - .left, - .rear-left { + [data-side="left"], + [data-side="rear-left"] { --face-y: var(--face-stack-shift); } - .right { + [data-side="right"] { --face-y: calc(var(--face-stack-shift) + var(--iso-scale)); } - .bottom { + [data-side="bottom"] { --face-y: 0; } - .top { + [data-side="top"] { --face-color: var(--face-top); --face-x: 0; } - .bottom { + [data-side="bottom"] { --face-color: var(--face-bottom); --face-x: 1; } - .right { + [data-side="right"] { --face-color: var(--face-right); --face-x: 1; } - .left { + [data-side="left"] { --face-color: var(--face-left); --face-x: 0; } - .rear-right { + [data-side="rear-right"] { --face-color: var(--face-left); --face-x: 1; } - .rear-left { + [data-side="rear-left"] { --face-color: var(--face-top); --face-scale-x: -1; --face-x: 1; } - .liquid.top { + [data-role="liquid"][data-side="top"] { --face-x: calc(1 - var(--fill)); } } diff --git a/website_next/explore/chain/format.js b/website_next/explore/chain/format.js index 4c0fa8fbf..09a3f43f1 100644 --- a/website_next/explore/chain/format.js +++ b/website_next/explore/chain/format.js @@ -37,6 +37,11 @@ export function createHeightElement(height) { return container; } +/** @param {number} value */ +export function formatNumber(value) { + return value.toLocaleString(); +} + /** @param {number} unixSeconds */ export function formatShortDate(unixSeconds) { const date = new Date(unixSeconds * 1_000); diff --git a/website_next/explore/chain/index.js b/website_next/explore/chain/index.js index 5acb042a2..9bbb08b8c 100644 --- a/website_next/explore/chain/index.js +++ b/website_next/explore/chain/index.js @@ -18,7 +18,7 @@ import { preserveScrollPosition, scrollToElement, } from "./scroll.js"; -import { transitionMs } from "./transition.js"; +import { createJumpController } from "./jump.js"; const BLOCK_BATCH_SIZE = 15; const EDGE_LOAD_DISTANCE = 50; @@ -32,6 +32,16 @@ const TIP_BLOCK_THRESHOLD = 10; /** @typedef {Awaited>[number]} MempoolBlock */ /** @typedef {{ generation: number, startHeight: number, placeholders: HTMLElement[] }} OlderBatch */ +/** @param {string | number | null | undefined} hashOrHeight */ +function normalizeTarget(hashOrHeight) { + if (hashOrHeight === "tip") return null; + if (typeof hashOrHeight === "string" && /^\d+$/.test(hashOrHeight)) { + return Number(hashOrHeight); + } + + return hashOrHeight; +} + /** * @param {{ onSelect?: (block: Block) => void }} [options] */ @@ -42,6 +52,9 @@ export function createChain({ onSelect = () => {} } = {}) { const tipButton = createEdgeButton("tip", "↑", "←", "Jump to chain tip", () => { jumpToTip(); }); + const jump = createJumpController(element, () => { + if (tipCube) selectCube(tipCube, { scroll: "instant" }); + }); element.id = "chain"; setTipVisible(false); @@ -77,14 +90,19 @@ export function createChain({ onSelect = () => {} } = {}) { /** @type {number | undefined} */ let pollId; - /** @type {number | undefined} */ - let jumpTimeout; let tipSyncFrame = 0; - let jumping = false; /** @type {AbortController} */ let controller = new AbortController(); + /** + * @param {string} label + * @param {unknown} error + */ + function logChainError(label, error) { + if (!controller.signal.aborted) console.error(label, error); + } + /** @param {string | number | null | undefined} hashOrHeight */ function findCube(hashOrHeight) { if (hashOrHeight == null) { @@ -124,41 +142,7 @@ export function createChain({ onSelect = () => {} } = {}) { } function jumpToTip() { - if (!tipCube || jumping) return; - - jumping = true; - - element.dataset.jumping = ""; - element.addEventListener("transitionend", finishJumpToTip); - jumpTimeout = window.setTimeout( - finishJumpToTip, - transitionMs(element, "opacity") + 50, - ); - } - - /** @param {Event} [event] */ - function finishJumpToTip(event) { - if ( - event instanceof TransitionEvent && - (event.target !== element || event.propertyName !== "opacity") - ) { - return; - } - - if (tipCube) selectCube(tipCube, { scroll: "instant" }); - - cancelJump(); - } - - function cancelJump() { - if (jumpTimeout !== undefined) { - window.clearTimeout(jumpTimeout); - jumpTimeout = undefined; - } - - element.removeEventListener("transitionend", finishJumpToTip); - delete element.dataset.jumping; - jumping = false; + if (tipCube) jump.jump(); } /** @@ -182,6 +166,14 @@ export function createChain({ onSelect = () => {} } = {}) { } } + function markConfirmedSkeletons() { + for (const cube of blocksElement.children) { + if (!cube.hasAttribute("data-projected")) { + cube.setAttribute("data-skeleton", ""); + } + } + } + function isHorizontal() { return isHorizontalLayout(blocksElement); } @@ -342,10 +334,7 @@ export function createChain({ onSelect = () => {} } = {}) { async function goToCube(hashOrHeight) { if (!active) return; - if (hashOrHeight === "tip") hashOrHeight = null; - if (typeof hashOrHeight === "string" && /^\d+$/.test(hashOrHeight)) { - hashOrHeight = Number(hashOrHeight); - } + hashOrHeight = normalizeTarget(hashOrHeight); const existing = findCube(hashOrHeight); if (existing) { @@ -353,12 +342,7 @@ export function createChain({ onSelect = () => {} } = {}) { return; } - for (const cube of blocksElement.children) { - if (!cube.hasAttribute("data-projected")) { - cube.setAttribute("data-skeleton", ""); - } - } - + markConfirmedSkeletons(); element.dataset.loading = ""; try { @@ -367,9 +351,7 @@ export function createChain({ onSelect = () => {} } = {}) { const cube = findCube(startHash); if (cube) selectCube(cube, { scroll: "instant" }); } catch (error) { - if (!controller.signal.aborted) { - console.error("explore chain load:", error); - } + logChainError("explore chain load:", error); } finally { delete element.dataset.loading; } @@ -381,7 +363,7 @@ export function createChain({ onSelect = () => {} } = {}) { await brk.getMempoolBlocks({ signal: controller.signal }), ); } catch (error) { - if (!controller.signal.aborted) console.error("explore mempool:", error); + logChainError("explore mempool:", error); } } @@ -394,7 +376,7 @@ export function createChain({ onSelect = () => {} } = {}) { try { appendNewerBlocks(await brk.getBlocksV1({ signal: controller.signal })); } catch (error) { - if (!controller.signal.aborted) console.error("explore chain poll:", error); + logChainError("explore chain poll:", error); } finally { polling = false; } @@ -454,11 +436,11 @@ export function createChain({ onSelect = () => {} } = {}) { reserveOlderRunway(); } catch (error) { - if (!controller.signal.aborted) { - for (const placeholder of batch.placeholders) placeholder.remove(); - oldestReservedHeight = oldestHeight; - console.error("explore older:", error); - } + if (controller.signal.aborted) return; + + for (const placeholder of batch.placeholders) placeholder.remove(); + oldestReservedHeight = oldestHeight; + logChainError("explore older:", error); } } @@ -479,7 +461,7 @@ export function createChain({ onSelect = () => {} } = {}) { await pollProjected(); } } catch (error) { - if (!controller.signal.aborted) console.error("explore newer:", error); + logChainError("explore newer:", error); } finally { loadingNewer = false; } @@ -552,6 +534,13 @@ export function createChain({ onSelect = () => {} } = {}) { }); } + function cancelTipVisibilitySync() { + if (!tipSyncFrame) return; + + window.cancelAnimationFrame(tipSyncFrame); + tipSyncFrame = 0; + } + /** @param {boolean} visible */ function setTipVisible(visible) { tipButton.toggleAttribute("data-visible", visible); @@ -635,12 +624,8 @@ export function createChain({ onSelect = () => {} } = {}) { pollId = undefined; } - if (tipSyncFrame) { - window.cancelAnimationFrame(tipSyncFrame); - tipSyncFrame = 0; - } - - cancelJump(); + cancelTipVisibilitySync(); + jump.cancel(); controller.abort(); } diff --git a/website_next/explore/chain/jump.js b/website_next/explore/chain/jump.js new file mode 100644 index 000000000..6b1d8873d --- /dev/null +++ b/website_next/explore/chain/jump.js @@ -0,0 +1,52 @@ +import { transitionMs } from "./transition.js"; + +/** + * @param {HTMLElement} element + * @param {() => void} onJump + */ +export function createJumpController(element, onJump) { + /** @type {number | undefined} */ + let timeout; + let jumping = false; + + /** @param {Event} [event] */ + function finish(event) { + if ( + event instanceof TransitionEvent && + (event.target !== element || event.propertyName !== "opacity") + ) { + return; + } + + onJump(); + cancel(); + } + + function cancel() { + if (timeout !== undefined) { + window.clearTimeout(timeout); + timeout = undefined; + } + + element.removeEventListener("transitionend", finish); + delete element.dataset.jumping; + jumping = false; + } + + function jump() { + if (jumping) return; + + jumping = true; + element.dataset.jumping = ""; + element.addEventListener("transitionend", finish); + timeout = window.setTimeout( + finish, + transitionMs(element, "opacity") + 50, + ); + } + + return /** @type {const} */ ({ + cancel, + jump, + }); +} diff --git a/website_next/explore/chain/style.css b/website_next/explore/chain/style.css index 95854033c..04edaf3af 100644 --- a/website_next/explore/chain/style.css +++ b/website_next/explore/chain/style.css @@ -61,12 +61,12 @@ margin-bottom: var(--block-gap); } - .face-text [data-cube-height] { + [data-role="text"] [data-cube-height] { font-size: var(--font-size-sm); - font-weight: normal; + font-weight: var(--font-weight-regular); } - .face-text [data-cube-fees] { + [data-role="text"] [data-cube-fees] { display: flex; flex-direction: column; align-items: center; @@ -74,7 +74,7 @@ height: 100%; } - .face-text [data-cube-pool] { + [data-role="text"] [data-cube-pool] { display: flex; align-items: center; justify-content: center; @@ -108,7 +108,7 @@ border-radius: 999rem; padding: 0; font-size: var(--font-size-base); - font-weight: 500; + font-weight: var(--font-weight-strong); line-height: 1; letter-spacing: 0; opacity: 0; diff --git a/website_next/explore/index.js b/website_next/explore/index.js index a8db1487c..76e1ac122 100644 --- a/website_next/explore/index.js +++ b/website_next/explore/index.js @@ -8,7 +8,7 @@ export function createExplorePage() { onSelect: blockDetails.update, }); - main.className = "explore"; + main.dataset.page = "explore"; main.append(chain.element, blockDetails.element); const syncChain = () => chain.setActive(!main.hidden && !document.hidden); diff --git a/website_next/explore/style.css b/website_next/explore/style.css index 12986fad2..c134204b2 100644 --- a/website_next/explore/style.css +++ b/website_next/explore/style.css @@ -1,4 +1,4 @@ -main.explore { +main[data-page="explore"] { --explore-max-width: 80rem; --explore-gap: 2rem; --chain-width: calc(4.5rem * sqrt(3)); @@ -14,7 +14,7 @@ main.explore { } @media (max-width: 48rem) { - main.explore { + main[data-page="explore"] { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); gap: 0; diff --git a/website_next/header/index.js b/website_next/header/index.js index 7bea60ab9..9ee0af892 100644 --- a/website_next/header/index.js +++ b/website_next/header/index.js @@ -1,16 +1,8 @@ -import { createCube } from "../cube/index.js"; +import { createBrand } from "../brand/index.js"; export function createHeader() { const header = document.createElement("header"); - const home = document.createElement("a"); - const cube = document.createElement("span"); - - home.href = "/"; - home.setAttribute("aria-label", "bitview home"); - cube.append(createCube()); - home.append(cube, "bitview"); - - header.append(home); + header.append(createBrand({ href: "/" })); return header; } diff --git a/website_next/header/style.css b/website_next/header/style.css index b818fceaf..ad46e2901 100644 --- a/website_next/header/style.css +++ b/website_next/header/style.css @@ -6,47 +6,5 @@ body { z-index: var(--layer-header); line-height: 1; mix-blend-mode: difference; - - > a { - --color: var(--white); - opacity: 0.8; - - display: flex; - align-items: center; - gap: 0.5rem; - color: var(--color); - font-size: var(--font-size-base); - text-decoration: none; - text-transform: lowercase; - - @media (hover: hover) and (pointer: fine) { - &:hover { - opacity: 1; - } - } - - &:active { - opacity: 1; - --color: var(--orange); - } - - &[data-press] { - opacity: 1; - --color: var(--white); - } - - > span { - display: inline-grid; - padding: 0.2rem 0.3rem; - color: var(--black); - background-color: var(--color); - border-radius: 0.25rem; - } - - .cube { - --size: 0.75rem; - animation: cube-fill 5s linear infinite alternate; - } - } } } diff --git a/website_next/home/index.js b/website_next/home/index.js index 4b4488ec9..6f5ad88d2 100644 --- a/website_next/home/index.js +++ b/website_next/home/index.js @@ -7,7 +7,7 @@ const links = [ export function createHomePage() { const main = document.createElement("main"); - main.className = "home"; + main.dataset.page = "home"; const title = document.createElement("h1"); const nav = document.createElement("nav"); diff --git a/website_next/home/style.css b/website_next/home/style.css index cdc103d25..f4297139f 100644 --- a/website_next/home/style.css +++ b/website_next/home/style.css @@ -1,4 +1,4 @@ -main.home { +main[data-page="home"] { display: grid; gap: 2rem; place-items: center; diff --git a/website_next/index.html b/website_next/index.html index 447463127..983025d17 100644 --- a/website_next/index.html +++ b/website_next/index.html @@ -92,6 +92,7 @@ crossorigin /> + @@ -102,13 +103,21 @@ + + + + + + + + @@ -124,7 +133,6 @@ - diff --git a/website_next/learn/contents/style.css b/website_next/learn/contents/style.css index f8d1715f6..3a5dcb9e7 100644 --- a/website_next/learn/contents/style.css +++ b/website_next/learn/contents/style.css @@ -1,4 +1,4 @@ -main.learn { +main[data-page="learn"] { > nav { --nav-offset: calc(var(--offset) + 2rem); --line-gap: 0.5rem; diff --git a/website_next/learn/details.js b/website_next/learn/details.js index eb4af1b00..dc5583f21 100644 --- a/website_next/learn/details.js +++ b/website_next/learn/details.js @@ -58,6 +58,15 @@ function openSection(main, section) { } } +/** @param {Event} event */ +function getSectionDetailsEventTarget(event) { + if (!(event.target instanceof HTMLDetailsElement)) return null; + + const section = event.target.parentElement; + + return section?.matches("article section[id]") ? event.target : null; +} + /** @param {HTMLElement} main */ export function initSectionDetails(main) { const sections = [ @@ -72,9 +81,10 @@ export function initSectionDetails(main) { } main.addEventListener("toggle", (event) => { - const details = /** @type {HTMLDetailsElement} */ (event.target); + const details = getSectionDetailsEventTarget(event); + if (!details) return; + const section = /** @type {HTMLElement} */ (details.parentElement); - if (!section.matches("article section[id]")) return; localStorage.setItem(getStorageKey(section.id), details.open ? "1" : "0"); syncNavSection(main, section); diff --git a/website_next/learn/index.js b/website_next/learn/index.js index f2775cafc..85c4c7afc 100644 --- a/website_next/learn/index.js +++ b/website_next/learn/index.js @@ -50,7 +50,7 @@ function createSection(section, path = []) { export function createLearnPage() { const main = document.createElement("main"); - main.className = "learn"; + main.dataset.page = "learn"; const article = document.createElement("article"); for (const section of sections) { diff --git a/website_next/learn/style.css b/website_next/learn/style.css index cb02caac1..b7946375b 100644 --- a/website_next/learn/style.css +++ b/website_next/learn/style.css @@ -1,4 +1,4 @@ -main.learn { +main[data-page="learn"] { --offset: 4rem; --content-width: 52rem; --heading-padding-bottom: 0.5rem; diff --git a/website_next/legend/style.css b/website_next/legend/style.css index 42703fec1..0b67ddf92 100644 --- a/website_next/legend/style.css +++ b/website_next/legend/style.css @@ -42,7 +42,7 @@ background: none; font-family: var(--font-mono); font-size: var(--font-size-xs); - font-weight: 450; + font-weight: var(--font-weight-strong); line-height: var(--line-height-xs); text-align: left; text-transform: uppercase; diff --git a/website_next/main.js b/website_next/main.js index df473c9e5..91b9608e5 100644 --- a/website_next/main.js +++ b/website_next/main.js @@ -4,16 +4,18 @@ import "./utils/press.js"; import { getEventAnchor, isPlainLeftClick } from "./utils/event.js"; import { revealPage, transitionPage } from "./utils/transition.js"; +/** @typedef {import("./routes.js").RoutePath} RoutePath */ + /** @type {HTMLElement | undefined} */ let currentPage; -/** @type {Map} */ +/** @type {Map} */ const pageByPath = new Map(); const header = createHeader(); document.body.append(header); -/** @param {string} pathname */ +/** @param {RoutePath} pathname */ function getPage(pathname) { let page = pageByPath.get(pathname); diff --git a/website_next/qr/index.js b/website_next/qr/index.js new file mode 100644 index 000000000..3b2a3f16f --- /dev/null +++ b/website_next/qr/index.js @@ -0,0 +1,26 @@ +import * as leanQr from "../modules/lean-qr/2.7.1/index.mjs"; + +/** + * @typedef {Object} QrDataUrlOptions + * @property {number} [padX] + * @property {number} [padY] + * @property {number} [scale] + */ + +/** + * @typedef {Object} QrCode + * @property {(options?: QrDataUrlOptions) => string} toDataURL + */ + +const generateQr = + /** @type {(value: string) => QrCode | undefined} */ ( + /** @type {unknown} */ (leanQr.generate) + ); + +/** + * @param {string} value + * @param {QrDataUrlOptions} [options] + */ +export function createQrDataUrl(value, options) { + return generateQr(value)?.toDataURL(options) ?? ""; +} diff --git a/website_next/routes.js b/website_next/routes.js index 282bfad3b..112127d60 100644 --- a/website_next/routes.js +++ b/website_next/routes.js @@ -4,14 +4,15 @@ import { createHomePage } from "./home/index.js"; import { createLearnPage } from "./learn/index.js"; import { createWalletsPage } from "./wallets/index.js"; -/** @type {Record HTMLElement>} */ -const routes = { +const routes = /** @type {const} */ ({ "/": createHomePage, "/explore": createExplorePage, "/learn": createLearnPage, "/build": createBuildPage, "/wallets": createWalletsPage, -}; +}); + +/** @typedef {keyof typeof routes} RoutePath */ /** @param {string} pathname */ function canonicalPath(pathname) { @@ -20,19 +21,25 @@ function canonicalPath(pathname) { : pathname; } -/** @param {string} pathname */ +/** + * @param {string} pathname + * @returns {RoutePath | undefined} + */ export function resolvePath(pathname) { const path = canonicalPath(pathname); - return path in routes ? path : undefined; + return path in routes ? /** @type {RoutePath} */ (path) : undefined; } -/** @param {string} pathname */ +/** + * @param {string} pathname + * @returns {RoutePath} + */ export function normalizePath(pathname) { return resolvePath(pathname) ?? "/"; } -/** @param {string} pathname */ +/** @param {RoutePath} pathname */ export function createRoutePage(pathname) { return routes[pathname](); } diff --git a/website_next/service-worker.js b/website_next/service-worker.js index 17f0f569f..690ac56e8 100644 --- a/website_next/service-worker.js +++ b/website_next/service-worker.js @@ -13,15 +13,26 @@ const offline = () => headers: { "Content-Type": "text/plain" }, }); +/** + * @param {Request | string} req + * @param {Response} res + */ +function cacheResponse(req, res) { + const clone = res.clone(); + + void caches + .open(CACHE) + .then((cache) => cache.put(req, clone)) + .catch(() => {}); +} + /** * @param {Request | string} req */ function fetchAndCache(req) { return fetch(req).then((res) => { - if (res.ok) { - const clone = res.clone(); - caches.open(CACHE).then((c) => c.put(req, clone)); - } + if (res.ok) cacheResponse(req, res); + return res; }); } diff --git a/website_next/styles/variables.css b/website_next/styles/variables.css index 007f4b0fc..481dc08ea 100644 --- a/website_next/styles/variables.css +++ b/website_next/styles/variables.css @@ -31,6 +31,8 @@ --line-height-base: calc(1.5 / 1); --font-size-lg: 1.25rem; --line-height-lg: calc(1.5 / 1.25); + --font-weight-regular: 400; + --font-weight-strong: 450; --control-radius: 0.3125rem; --control-padding: 0.75rem 1rem; diff --git a/website_next/wallets/add/index.js b/website_next/wallets/add/index.js index 06c02c807..1888bebd3 100644 --- a/website_next/wallets/add/index.js +++ b/website_next/wallets/add/index.js @@ -1,5 +1,5 @@ import { createField } from "../form/index.js"; -import { createElement } from "../dom.js"; +import { createWalletPart } from "../dom.js"; import { redaction } from "../redaction/index.js"; /** @@ -34,7 +34,7 @@ function createSourceInput() { * @param {AddWalletFormOptions} options */ export function createAddForm(options) { - const form = createElement("form", "add"); + const form = createWalletPart("form", "add"); const title = document.createElement("h2"); const description = document.createElement("p"); const name = document.createElement("input"); diff --git a/website_next/wallets/add/style.css b/website_next/wallets/add/style.css index 3e781460f..bff730501 100644 --- a/website_next/wallets/add/style.css +++ b/website_next/wallets/add/style.css @@ -1,5 +1,5 @@ -main.wallets { - .add { +main[data-page="wallets"] { + [data-wallet="add"] { display: grid; gap: 1.25rem; diff --git a/website_next/wallets/amount/index.js b/website_next/wallets/amount/index.js index ad110b8fd..20c638a27 100644 --- a/website_next/wallets/amount/index.js +++ b/website_next/wallets/amount/index.js @@ -2,7 +2,6 @@ import { renderBtcAmount as renderVisibleBtcAmount } from "../../btc/index.js"; import { redaction } from "../redaction/index.js"; const FIXED_PRIVATE_TEXT = "*****"; -const amounts = /** @type {BtcAmountRecord[]} */ ([]); /** * @typedef {Object} BtcAmountOptions @@ -11,10 +10,6 @@ const amounts = /** @type {BtcAmountRecord[]} */ ([]); * @typedef {Object} BtcAmount * @property {number} sats * @property {boolean} signed - * - * @typedef {Object} BtcAmountRecord - * @property {HTMLElement} element - * @property {BtcAmount} amount */ /** @@ -45,20 +40,7 @@ export function createBtcAmount(tag, sats, options = {}) { signed: options.signed === true, }; - amounts.push({ element, amount }); - renderBtcAmount(element, amount); + redaction.addEffect(element, () => renderBtcAmount(element, amount)); return element; } - -export function syncBtcAmounts() { - for (let index = amounts.length - 1; index >= 0; index -= 1) { - const { element, amount } = amounts[index]; - - if (!element.isConnected) { - amounts.splice(index, 1); - } else { - renderBtcAmount(element, amount); - } - } -} diff --git a/website_next/wallets/dom.js b/website_next/wallets/dom.js index 97d58a914..2debff8d1 100644 --- a/website_next/wallets/dom.js +++ b/website_next/wallets/dom.js @@ -1,12 +1,12 @@ /** * @template {keyof HTMLElementTagNameMap} Tag * @param {Tag} tag - * @param {string} className + * @param {string} part */ -export function createElement(tag, className) { +export function createWalletPart(tag, part) { const element = document.createElement(tag); - element.className = className; + element.dataset.wallet = part; return element; } diff --git a/website_next/wallets/empty/index.js b/website_next/wallets/empty/index.js index b73ed0abe..f54331001 100644 --- a/website_next/wallets/empty/index.js +++ b/website_next/wallets/empty/index.js @@ -1,4 +1,4 @@ -import { createElement } from "../dom.js"; +import { createWalletPart } from "../dom.js"; /** * @typedef {Object} EmptyOptions @@ -10,7 +10,7 @@ import { createElement } from "../dom.js"; * @param {EmptyOptions} options */ export function createEmpty(options) { - const empty = createElement("section", "empty"); + const empty = createWalletPart("section", "empty"); const title = document.createElement("h1"); const text = document.createElement("p"); const actions = document.createElement("menu"); diff --git a/website_next/wallets/empty/style.css b/website_next/wallets/empty/style.css index 5102bf2eb..f7adb609d 100644 --- a/website_next/wallets/empty/style.css +++ b/website_next/wallets/empty/style.css @@ -1,5 +1,5 @@ -main.wallets { - .empty { +main[data-page="wallets"] { + [data-wallet="empty"] { display: grid; gap: 1rem; place-content: center; @@ -9,7 +9,7 @@ main.wallets { h1 { margin: 0; font-size: 4rem; - font-weight: 400; + font-weight: var(--font-weight-regular); line-height: 1; } diff --git a/website_next/wallets/form/style.css b/website_next/wallets/form/style.css index 06e2e3892..ac03a322c 100644 --- a/website_next/wallets/form/style.css +++ b/website_next/wallets/form/style.css @@ -1,4 +1,4 @@ -main.wallets { +main[data-page="wallets"] { label { display: grid; gap: 0.375rem; diff --git a/website_next/wallets/hold/index.js b/website_next/wallets/hold/index.js index 48c928199..eb6e8b585 100644 --- a/website_next/wallets/hold/index.js +++ b/website_next/wallets/hold/index.js @@ -1,4 +1,4 @@ -import { createElement } from "../dom.js"; +import { createWalletPart } from "../dom.js"; const FILL_MS = 2_000; const DRAIN_MS = 600; @@ -24,7 +24,7 @@ function bindHold(button, onHold) { function render() { button.style.setProperty("--hold-progress", String(progress)); button.style.setProperty("--hold-progress-width", `${progress * 100}%`); - button.classList.toggle("active", progress > 0); + button.toggleAttribute("data-active", progress > 0); } function stop() { @@ -49,7 +49,7 @@ function bindHold(button, onHold) { stop(); holding = false; progress = 0; - button.classList.remove("holding"); + button.removeAttribute("data-holding"); render(); onHold(); return; @@ -74,7 +74,7 @@ function bindHold(button, onHold) { if (!holding) return; holding = false; - button.classList.remove("holding"); + button.removeAttribute("data-holding"); run(); } @@ -82,7 +82,7 @@ function bindHold(button, onHold) { stop(); holding = true; - button.classList.add("holding"); + button.dataset.holding = ""; run(); } @@ -115,16 +115,14 @@ function bindHold(button, onHold) { * @param {Object} options * @param {string} options.label * @param {string} options.title - * @param {string} [options.className] + * @param {string} [options.variant] * @param {() => void} options.onHold */ export function createHoldButton(options) { - const button = createElement("button", "hold"); + const button = createWalletPart("button", "hold"); const label = document.createElement("span"); - if (options.className) { - button.classList.add(options.className); - } + if (options.variant) button.dataset.variant = options.variant; button.type = "button"; button.dataset.label = options.label; button.title = options.title; diff --git a/website_next/wallets/hold/style.css b/website_next/wallets/hold/style.css index 52b807f20..fb19af5b5 100644 --- a/website_next/wallets/hold/style.css +++ b/website_next/wallets/hold/style.css @@ -1,5 +1,5 @@ -main.wallets { - .hold { +main[data-page="wallets"] { + [data-wallet="hold"] { position: relative; isolation: isolate; overflow: hidden; @@ -40,23 +40,23 @@ main.wallets { } @media (hover: hover) and (pointer: fine) { - &:hover:not(.holding) { + &:hover:not([data-holding]) { color: var(--red); background: transparent; } } - &:is(:focus-visible, :active, [data-press]):not(.holding) { + &:is(:focus-visible, :active, [data-press]):not([data-holding]) { color: var(--red); background: transparent; } - &.active { + &[data-active] { color: var(--red); } - &.active::before, - &.active::after { + &[data-active]::before, + &[data-active]::after { opacity: 1; } } diff --git a/website_next/wallets/index.js b/website_next/wallets/index.js index cde8374c4..30afbc419 100644 --- a/website_next/wallets/index.js +++ b/website_next/wallets/index.js @@ -18,7 +18,6 @@ import { } from "./wallet/index.js"; import { createVault } from "./vault/index.js"; import { generateAddressesFromWalletSource } from "./derive/index.js"; -import { syncBtcAmounts } from "./amount/index.js"; /** * @typedef {import("./scan/index.js").WalletScan} WalletScan @@ -106,7 +105,6 @@ export function createWalletsPage() { privacyButton.addEventListener("click", () => { redaction.toggle(privacyButton); - syncBtcAmounts(); }); sessionButton.addEventListener("click", () => { diff --git a/website_next/wallets/layout/index.js b/website_next/wallets/layout/index.js index 679dbb6ba..af38c6c1d 100644 --- a/website_next/wallets/layout/index.js +++ b/website_next/wallets/layout/index.js @@ -1,4 +1,4 @@ -import { createElement } from "../dom.js"; +import { createWalletPart } from "../dom.js"; /** * @typedef {Object} WalletsLayout @@ -16,15 +16,16 @@ import { createElement } from "../dom.js"; * @returns {WalletsLayout} */ export function createLayout() { - const main = createElement("main", "wallets"); + const main = document.createElement("main"); const utilities = document.createElement("footer"); const privacyButton = document.createElement("button"); const sessionButton = document.createElement("button"); - const selector = createElement("section", "selector"); + const selector = createWalletPart("section", "selector"); const walletList = document.createElement("nav"); const content = document.createElement("article"); const addDialog = document.createElement("dialog"); + main.dataset.page = "wallets"; privacyButton.type = "button"; sessionButton.type = "button"; sessionButton.append("Lock"); diff --git a/website_next/wallets/layout/style.css b/website_next/wallets/layout/style.css index 44165455c..ddad688fc 100644 --- a/website_next/wallets/layout/style.css +++ b/website_next/wallets/layout/style.css @@ -1,4 +1,4 @@ -main.wallets { +main[data-page="wallets"] { > footer { position: fixed; right: var(--page-x); diff --git a/website_next/wallets/lookup/index.js b/website_next/wallets/lookup/index.js index 54fe4c38b..df7567312 100644 --- a/website_next/wallets/lookup/index.js +++ b/website_next/wallets/lookup/index.js @@ -106,22 +106,31 @@ function isNotFound(error) { ); } +/** + * @param {AddressClient} client + * @param {string} address + * @param {Map>} cache + */ +function getBucketMetadata(client, address, cache) { + let metadata = cache.get(address); + + if (!metadata) { + metadata = client.getAddress(address, { cache: false }); + cache.set(address, metadata); + } + + return metadata; +} + /** * @param {AddressClient} client * @param {readonly string[]} addresses * @param {Map>} cache */ async function fetchBucketMetadata(client, addresses, cache) { - for (const address of addresses) { - if (!cache.has(address)) { - cache.set( - address, - client.getAddress(address, { cache: false }), - ); - } - } - - await Promise.all(addresses.map((address) => cache.get(address))); + await Promise.all( + addresses.map((address) => getBucketMetadata(client, address, cache)), + ); } /** @@ -160,18 +169,18 @@ async function fetchWalletAddress(client, generated, metadataCache) { await fetchBucketMetadata(client, matches.addresses, metadataCache); - const stats = await metadataCache.get(generated.address); - - if (!stats) { - return createEmptyWalletAddress(generated); - } + const stats = await getBucketMetadata( + client, + generated.address, + metadataCache, + ); const historyAddresses = []; for (const address of matches.addresses) { - const bucketStats = await metadataCache.get(address); + const bucketStats = await getBucketMetadata(client, address, metadataCache); - if (bucketStats && getAddressTxCount(bucketStats) > 0) { + if (getAddressTxCount(bucketStats) > 0) { historyAddresses.push(address); } } diff --git a/website_next/wallets/lookup/local.js b/website_next/wallets/lookup/local.js index 63397aac1..ee1f14337 100644 --- a/website_next/wallets/lookup/local.js +++ b/website_next/wallets/lookup/local.js @@ -6,20 +6,27 @@ const localDomains = new Set([ "[::1]", ]); +/** + * @param {string} domain + */ +function isIpv4Octet(domain) { + return /^\d+$/.test(domain); +} + /** * @param {string} domain */ function isPrivateIpv4(domain) { - const parts = domain.split(".").map(Number); + const parts = domain.split("."); if ( parts.length !== 4 || - parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255) + parts.some((part) => !isIpv4Octet(part) || Number(part) > 255) ) { return false; } - const [a, b] = parts; + const [a, b] = parts.map(Number); return ( a === 10 || diff --git a/website_next/wallets/read.js b/website_next/wallets/read.js new file mode 100644 index 000000000..a53b93047 --- /dev/null +++ b/website_next/wallets/read.js @@ -0,0 +1,28 @@ +/** @param {unknown} value */ +export function readObject(value) { + return value && typeof value === "object" + ? /** @type {Record} */ (value) + : undefined; +} + +/** @param {unknown} value */ +export function readNumber(value) { + return typeof value === "number" && Number.isFinite(value) + ? value + : undefined; +} + +/** @param {unknown} value */ +export function readString(value) { + return typeof value === "string" ? value : undefined; +} + +/** + * @param {unknown} value + * @param {string} key + */ +export function readArray(value, key) { + const array = readObject(value)?.[key]; + + return Array.isArray(array) ? array : []; +} diff --git a/website_next/wallets/redaction/index.js b/website_next/wallets/redaction/index.js index 5cf1ac8c9..60ee71cf2 100644 --- a/website_next/wallets/redaction/index.js +++ b/website_next/wallets/redaction/index.js @@ -132,6 +132,7 @@ function toggle(button) { export const redaction = /** @type {const} */ ({ isHidden, createText, + addEffect, setValue, setTitle, setAddress, diff --git a/website_next/wallets/scan/activity.js b/website_next/wallets/scan/activity.js new file mode 100644 index 000000000..2da2eaf84 --- /dev/null +++ b/website_next/wallets/scan/activity.js @@ -0,0 +1,4 @@ +/** @param {{ received: number, sent: number, txCount: number }} address */ +export function isUsedAddress(address) { + return address.received > 0 || address.sent > 0 || address.txCount > 0; +} diff --git a/website_next/wallets/scan/branch.js b/website_next/wallets/scan/branch.js index 43bb7e559..b9b440262 100644 --- a/website_next/wallets/scan/branch.js +++ b/website_next/wallets/scan/branch.js @@ -1,5 +1,6 @@ import { fetchWalletAddresses } from "../lookup/index.js"; import { generateAddressesFromWalletSource } from "../derive/index.js"; +import { isUsedAddress } from "./activity.js"; export const GAP_LIMIT = 10; @@ -34,13 +35,6 @@ const MAX_SCANNED_ADDRESSES = 1_000; * @property {boolean} maxed */ -/** - * @param {WalletAddress} address - */ -function isUsedAddress(address) { - return address.received > 0 || address.sent > 0 || address.txCount > 0; -} - /** * @param {AddressClient} client * @param {string} source diff --git a/website_next/wallets/scan/branches.js b/website_next/wallets/scan/branches.js index 1d573d977..12885c15f 100644 --- a/website_next/wallets/scan/branches.js +++ b/website_next/wallets/scan/branches.js @@ -6,6 +6,7 @@ import { getOutputDescriptorBranchIds, isOutputDescriptor, } from "../derive/index.js"; +import { isUsedAddress } from "./activity.js"; const keyBranches = /** @type {const} */ ([ { id: "receive", label: "Receive", path: [0] }, @@ -58,13 +59,6 @@ const descriptorBranches = /** @type {const} */ ([ * @property {boolean} maxed */ -/** - * @param {WalletAddress} address - */ -function isUsedAddress(address) { - return address.received > 0 || address.sent > 0 || address.txCount > 0; -} - /** * @param {ScannedAddress} a * @param {ScannedAddress} b diff --git a/website_next/wallets/selector/index.js b/website_next/wallets/selector/index.js index a774db374..a83606b55 100644 --- a/website_next/wallets/selector/index.js +++ b/website_next/wallets/selector/index.js @@ -30,7 +30,7 @@ function renderButtons(walletList, wallets, options) { const remove = createHoldButton({ label: "DELETE", title: "Hold to delete", - className: "delete", + variant: "delete", onHold: options.onDelete, }); diff --git a/website_next/wallets/selector/style.css b/website_next/wallets/selector/style.css index c8ac0889f..a99a9cff2 100644 --- a/website_next/wallets/selector/style.css +++ b/website_next/wallets/selector/style.css @@ -1,5 +1,5 @@ -main.wallets { - .selector { +main[data-page="wallets"] { + [data-wallet="selector"] { min-width: 0; > nav { @@ -18,7 +18,7 @@ main.wallets { background: var(--gray); color: var(--white); font-size: var(--font-size-lg); - font-weight: 400; + font-weight: var(--font-weight-regular); line-height: 1; @media (max-width: 56rem) { @@ -47,7 +47,7 @@ main.wallets { background: var(--white); } - &.delete { + &[data-variant="delete"] { color: color-mix(in oklch, var(--gray) 76%, transparent); background: transparent; diff --git a/website_next/wallets/start/index.js b/website_next/wallets/start/index.js index 356f56262..bc619bee8 100644 --- a/website_next/wallets/start/index.js +++ b/website_next/wallets/start/index.js @@ -1,4 +1,4 @@ -import { createElement } from "../dom.js"; +import { createWalletPart } from "../dom.js"; import { createPersistentVault } from "./persistent.js"; import { createStartStory } from "./story.js"; import { createTemporaryVault } from "./temporary.js"; @@ -15,12 +15,11 @@ import { createTemporaryVault } from "./temporary.js"; * @property {() => void} [onReset] */ - /** * @param {StartOptions} options */ export function createStart(options) { - const section = createElement("section", "start"); + const section = createWalletPart("section", "start"); const modes = document.createElement("div"); const divider = document.createElement("p"); const persistent = createPersistentVault({ diff --git a/website_next/wallets/start/reset/index.js b/website_next/wallets/start/reset/index.js index 47b92266b..058c130d1 100644 --- a/website_next/wallets/start/reset/index.js +++ b/website_next/wallets/start/reset/index.js @@ -7,7 +7,7 @@ export function createResetButton(onReset) { return createHoldButton({ label: "Reset vault", title: "Hold to reset", - className: "reset", + variant: "reset", onHold: onReset, }); } diff --git a/website_next/wallets/start/reset/style.css b/website_next/wallets/start/reset/style.css index 994d204b2..e17698842 100644 --- a/website_next/wallets/start/reset/style.css +++ b/website_next/wallets/start/reset/style.css @@ -1,6 +1,6 @@ -main.wallets { - .start { - .reset { +main[data-page="wallets"] { + [data-wallet="start"] { + [data-variant="reset"] { justify-self: start; width: 100%; } diff --git a/website_next/wallets/start/style.css b/website_next/wallets/start/style.css index b7340f199..34f945727 100644 --- a/website_next/wallets/start/style.css +++ b/website_next/wallets/start/style.css @@ -1,5 +1,5 @@ -main.wallets { - .start { +main[data-page="wallets"] { + [data-wallet="start"] { display: grid; grid-template-columns: minmax(0, 1fr) minmax(19rem, 26rem); gap: 4rem; @@ -24,7 +24,7 @@ main.wallets { h1 { margin: 0; font-size: 4.5rem; - font-weight: 400; + font-weight: var(--font-weight-regular); line-height: 0.95; span { @@ -126,7 +126,7 @@ main.wallets { color: var(--white); font-family: var(--font-mono); font-size: var(--font-size-sm); - font-weight: 400; + font-weight: var(--font-weight-regular); line-height: var(--line-height-sm); } diff --git a/website_next/wallets/style.css b/website_next/wallets/style.css index 08ce94b3e..d6e180781 100644 --- a/website_next/wallets/style.css +++ b/website_next/wallets/style.css @@ -1,4 +1,4 @@ -main.wallets { +main[data-page="wallets"] { --offset: 4rem; --content-width: 72rem; @@ -20,7 +20,7 @@ main.wallets { line-height: var(--line-height-sm); } - .results { + [data-wallet="results"] { min-width: 0; } diff --git a/website_next/wallets/vault/storage.js b/website_next/wallets/vault/storage.js index e58a4c447..3aebb6aee 100644 --- a/website_next/wallets/vault/storage.js +++ b/website_next/wallets/vault/storage.js @@ -1,3 +1,4 @@ +import { readArray, readObject } from "../read.js"; import { encryption } from "./encryption.js"; const STORAGE_KEY = "bitview.wallets.v3"; @@ -31,9 +32,9 @@ const STORAGE_KEY = "bitview.wallets.v3"; * @returns {EncryptedSecret | undefined} */ function readEncryptedVault(value) { - return value && typeof value === "object" - ? /** @type {EncryptedSecret} */ (value) - : undefined; + const vault = readObject(value); + + return vault ? /** @type {EncryptedSecret} */ (vault) : undefined; } /** @@ -41,13 +42,9 @@ function readEncryptedVault(value) { * @returns {WalletVault} */ function readVault(value) { - if (!value || typeof value !== "object" || !("wallets" in value)) { - return { wallets: [] }; - } - - return Array.isArray(value.wallets) - ? /** @type {WalletVault} */ (value) - : { wallets: [] }; + return { + wallets: /** @type {StoredWallet[]} */ (readArray(value, "wallets")), + }; } function createWalletId() { diff --git a/website_next/wallets/wallet/address/index.js b/website_next/wallets/wallet/address/index.js index ccc4ec506..076d9b89d 100644 --- a/website_next/wallets/wallet/address/index.js +++ b/website_next/wallets/wallet/address/index.js @@ -1,4 +1,4 @@ -import { createElement } from "../../dom.js"; +import { createWalletPart } from "../../dom.js"; import { formatNumber } from "../../format.js"; import { redaction } from "../../redaction/index.js"; @@ -10,7 +10,7 @@ import { redaction } from "../../redaction/index.js"; * @param {string} text */ export function createGroupedAddress(text) { - const element = createElement("code", "address"); + const element = createWalletPart("code", "address"); const groups = text.match(/.{1,4}/g) ?? []; for (let groupIndex = 0; groupIndex < groups.length; groupIndex += 1) { @@ -65,7 +65,7 @@ function createAddressBadge(row) { * @param {WalletAddress} row */ export function createAddressCellContent(row) { - const element = createElement("div", "address-cell"); + const element = createWalletPart("div", "address-cell"); const anonSet = document.createElement("small"); anonSet.append(`anon set: ${formatNumber(row.historyBucketSize)}`); diff --git a/website_next/wallets/wallet/address/style.css b/website_next/wallets/wallet/address/style.css index 0b04f24b4..e9a7f16ce 100644 --- a/website_next/wallets/wallet/address/style.css +++ b/website_next/wallets/wallet/address/style.css @@ -1,5 +1,5 @@ -main.wallets { - .address-cell { +main[data-page="wallets"] { + [data-wallet="address-cell"] { display: grid; gap: 0.25rem; @@ -12,7 +12,7 @@ main.wallets { border-radius: 0.25rem; padding: 0 0.25rem; color: color-mix(in oklch, var(--white) 76%, var(--gray)); - font-weight: 400; + font-weight: var(--font-weight-regular); line-height: 1; } @@ -23,7 +23,7 @@ main.wallets { } } - .address { + [data-wallet="address"] { display: flex; flex-wrap: wrap; gap: 0 0.375rem; diff --git a/website_next/wallets/wallet/addresses/index.js b/website_next/wallets/wallet/addresses/index.js index 000f9a600..8f4375f3f 100644 --- a/website_next/wallets/wallet/addresses/index.js +++ b/website_next/wallets/wallet/addresses/index.js @@ -1,5 +1,5 @@ import { createBtcAmount } from "../../amount/index.js"; -import { createElement } from "../../dom.js"; +import { createWalletPart } from "../../dom.js"; import { formatNumber } from "../../format.js"; import { createAddressCellContent } from "../address/index.js"; @@ -31,7 +31,7 @@ function createAddressRow(address) { * @param {readonly WalletAddress[]} addresses */ function renderAddresses(panel, addresses) { - const section = createElement("section", "addresses"); + const section = createWalletPart("section", "addresses"); const title = document.createElement("h2"); const list = document.createElement("ol"); diff --git a/website_next/wallets/wallet/addresses/style.css b/website_next/wallets/wallet/addresses/style.css index 65805ac2b..19724e683 100644 --- a/website_next/wallets/wallet/addresses/style.css +++ b/website_next/wallets/wallet/addresses/style.css @@ -1,5 +1,5 @@ -main.wallets { - .addresses { +main[data-page="wallets"] { + [data-wallet="addresses"] { display: grid; gap: 1rem; @@ -10,7 +10,7 @@ main.wallets { h2 { color: var(--white); font-size: var(--font-size-lg); - font-weight: 400; + font-weight: var(--font-weight-regular); line-height: var(--line-height-lg); } diff --git a/website_next/wallets/wallet/bucket-key.js b/website_next/wallets/wallet/bucket-key.js new file mode 100644 index 000000000..43622d391 --- /dev/null +++ b/website_next/wallets/wallet/bucket-key.js @@ -0,0 +1,4 @@ +/** @param {readonly string[]} addresses */ +export function createBucketKey(addresses) { + return [...addresses].sort().join("\n"); +} diff --git a/website_next/wallets/wallet/history/address.js b/website_next/wallets/wallet/history/address.js index 44d1c8fe2..e040967fd 100644 --- a/website_next/wallets/wallet/history/address.js +++ b/website_next/wallets/wallet/history/address.js @@ -1,4 +1,5 @@ import { mapConcurrent } from "../../concurrent.js"; +import { createBucketKey } from "../bucket-key.js"; const HISTORY_CONCURRENCY = 4; const MAX_SELECTED_ADDRESS_TXS = 100; @@ -21,13 +22,6 @@ const historyByBucketKey = * @property {ApiTransaction[]} transactions */ -/** - * @param {readonly string[]} addresses - */ -function createBucketKey(addresses) { - return [...addresses].sort().join("\n"); -} - /** * @param {AddressHistoryClient} client * @param {readonly string[]} addresses diff --git a/website_next/wallets/wallet/history/index.js b/website_next/wallets/wallet/history/index.js index a4adf8330..41629afd8 100644 --- a/website_next/wallets/wallet/history/index.js +++ b/website_next/wallets/wallet/history/index.js @@ -1,4 +1,4 @@ -import { createElement } from "../../dom.js"; +import { createWalletPart } from "../../dom.js"; import { historyCache } from "./cache.js"; import { createTransactionSection } from "./section.js"; @@ -29,7 +29,7 @@ function groupTransactionsByDate(transactions) { * @param {readonly WalletTransaction[]} transactions */ function renderHistory(element, transactions) { - const activity = createElement("section", "activity"); + const activity = createWalletPart("section", "activity"); const title = document.createElement("h2"); const groups = groupTransactionsByDate(transactions); diff --git a/website_next/wallets/wallet/history/row.js b/website_next/wallets/wallet/history/row.js index 23d25271c..4e6e7133b 100644 --- a/website_next/wallets/wallet/history/row.js +++ b/website_next/wallets/wallet/history/row.js @@ -61,10 +61,10 @@ export function createTransactionRow(transaction) { label.append(typeLabels[transaction.type]); if (transaction.amount > 0) { - amount.classList.add("positive"); + amount.dataset.tone = "positive"; } if (transaction.amount < 0) { - amount.classList.add("negative"); + amount.dataset.tone = "negative"; } redaction.setTitle(txid, transaction.txid); redaction.setValue(txid, formatTxid(transaction.txid)); diff --git a/website_next/wallets/wallet/history/style.css b/website_next/wallets/wallet/history/style.css index 3e594c415..1aa6e263d 100644 --- a/website_next/wallets/wallet/history/style.css +++ b/website_next/wallets/wallet/history/style.css @@ -1,5 +1,5 @@ -main.wallets { - .activity { +main[data-page="wallets"] { + [data-wallet="activity"] { display: grid; gap: 1.25rem; @@ -10,14 +10,14 @@ main.wallets { h2 { color: var(--white); font-size: var(--font-size-lg); - font-weight: 400; + font-weight: var(--font-weight-regular); line-height: var(--line-height-lg); } h3 { color: var(--gray); font-size: var(--font-size-xs); - font-weight: 400; + font-weight: var(--font-weight-regular); line-height: var(--line-height-xs); text-transform: uppercase; } @@ -68,18 +68,18 @@ main.wallets { strong { color: var(--white); - font-weight: 500; + font-weight: var(--font-weight-strong); } > span { color: var(--white); white-space: nowrap; - &.positive { + &[data-tone="positive"] { color: var(--green); } - &.negative { + &[data-tone="negative"] { color: var(--red); } } diff --git a/website_next/wallets/wallet/history/transaction.js b/website_next/wallets/wallet/history/transaction.js index f8321898a..69934260c 100644 --- a/website_next/wallets/wallet/history/transaction.js +++ b/website_next/wallets/wallet/history/transaction.js @@ -1,3 +1,5 @@ +import { readArray, readNumber, readObject, readString } from "../../read.js"; + /** * @typedef {import("../../scan/index.js").WalletAddress} WalletAddress * @typedef {Record} ApiTransaction @@ -20,41 +22,6 @@ * @property {unknown} raw */ -/** - * @param {unknown} value - */ -function readObject(value) { - return value && typeof value === "object" - ? /** @type {Record} */ (value) - : undefined; -} - -/** - * @param {unknown} value - */ -function readNumber(value) { - return typeof value === "number" && Number.isFinite(value) - ? value - : undefined; -} - -/** - * @param {unknown} value - */ -function readString(value) { - return typeof value === "string" ? value : undefined; -} - -/** - * @param {unknown} value - * @param {string} key - */ -function readArray(value, key) { - const array = readObject(value)?.[key]; - - return Array.isArray(array) ? array : []; -} - /** * @param {unknown} output * @param {string} address diff --git a/website_next/wallets/wallet/holdings/cache.js b/website_next/wallets/wallet/holdings/cache.js index b7013e144..fbd585466 100644 --- a/website_next/wallets/wallet/holdings/cache.js +++ b/website_next/wallets/wallet/holdings/cache.js @@ -1,4 +1,5 @@ import { mapConcurrent } from "../../concurrent.js"; +import { createBucketKey } from "../bucket-key.js"; const UTXO_CONCURRENCY = 4; @@ -40,13 +41,6 @@ function isNotFound(error) { ); } -/** - * @param {readonly string[]} addresses - */ -function createBucketKey(addresses) { - return [...addresses].sort().join("\n"); -} - /** * @param {UtxoClient} client * @param {string} address diff --git a/website_next/wallets/wallet/holdings/index.js b/website_next/wallets/wallet/holdings/index.js index dcc451295..758cbb741 100644 --- a/website_next/wallets/wallet/holdings/index.js +++ b/website_next/wallets/wallet/holdings/index.js @@ -1,5 +1,5 @@ import { createBtcAmount } from "../../amount/index.js"; -import { createElement } from "../../dom.js"; +import { createWalletPart } from "../../dom.js"; import { redaction } from "../../redaction/index.js"; import { createAddressCellContent } from "../address/index.js"; import { utxoCache } from "./cache.js"; @@ -75,7 +75,7 @@ async function loadWalletUtxos(client, addresses) { * @param {readonly WalletUtxo[]} utxos */ function renderHoldings(panel, utxos) { - const section = createElement("section", "holdings"); + const section = createWalletPart("section", "holdings"); const title = document.createElement("h2"); const list = document.createElement("ol"); diff --git a/website_next/wallets/wallet/holdings/style.css b/website_next/wallets/wallet/holdings/style.css index 1d3176e5f..e92809fba 100644 --- a/website_next/wallets/wallet/holdings/style.css +++ b/website_next/wallets/wallet/holdings/style.css @@ -1,5 +1,5 @@ -main.wallets { - .holdings { +main[data-page="wallets"] { + [data-wallet="holdings"] { display: grid; gap: 1rem; @@ -10,7 +10,7 @@ main.wallets { h2 { color: var(--white); font-size: var(--font-size-lg); - font-weight: 400; + font-weight: var(--font-weight-regular); line-height: var(--line-height-lg); } @@ -52,7 +52,7 @@ main.wallets { strong { color: var(--white); - font-weight: 500; + font-weight: var(--font-weight-strong); } } @@ -68,7 +68,7 @@ main.wallets { } } - > .address-cell { + > [data-wallet="address-cell"] { grid-column: 1 / -1; } } diff --git a/website_next/wallets/wallet/holdings/utxo.js b/website_next/wallets/wallet/holdings/utxo.js index a71fe1dea..49a01652d 100644 --- a/website_next/wallets/wallet/holdings/utxo.js +++ b/website_next/wallets/wallet/holdings/utxo.js @@ -1,3 +1,5 @@ +import { readNumber, readObject, readString } from "../../read.js"; + /** * @typedef {import("../../scan/index.js").WalletAddress} WalletAddress * @@ -9,31 +11,6 @@ * @property {boolean} confirmed */ -/** - * @param {unknown} value - */ -function readObject(value) { - return value && typeof value === "object" - ? /** @type {Record} */ (value) - : undefined; -} - -/** - * @param {unknown} value - */ -function readNumber(value) { - return typeof value === "number" && Number.isFinite(value) - ? value - : undefined; -} - -/** - * @param {unknown} value - */ -function readString(value) { - return typeof value === "string" ? value : undefined; -} - /** * @param {unknown} utxo */ diff --git a/website_next/wallets/wallet/index.js b/website_next/wallets/wallet/index.js index 65fab8132..3b1277eb8 100644 --- a/website_next/wallets/wallet/index.js +++ b/website_next/wallets/wallet/index.js @@ -1,4 +1,4 @@ -import { createElement } from "../dom.js"; +import { createWalletPart } from "../dom.js"; import { createAddressesTab } from "./addresses/index.js"; import { createHistoryTab } from "./history/index.js"; import { createHoldingsTab } from "./holdings/index.js"; @@ -21,9 +21,9 @@ import { createWalletTabs } from "./tabs/index.js"; * @returns {WalletPanel} */ export function createWalletPanel() { - const summary = createElement("section", "summary"); + const summary = createWalletPart("section", "summary"); const status = document.createElement("output"); - const results = createElement("section", "results"); + const results = createWalletPart("section", "results"); summary.setAttribute("aria-label", "Wallets summary"); results.setAttribute("aria-label", "Wallets results"); diff --git a/website_next/wallets/wallet/receive/index.js b/website_next/wallets/wallet/receive/index.js index 45b88b87d..976490bcf 100644 --- a/website_next/wallets/wallet/receive/index.js +++ b/website_next/wallets/wallet/receive/index.js @@ -1,31 +1,13 @@ -import * as leanQr from "../../../modules/lean-qr/2.7.1/index.mjs"; +import { createQrDataUrl } from "../../../qr/index.js"; +import { openDialog } from "../../../dialog/index.js"; import { createGroupedAddress } from "../address/index.js"; -import { createElement } from "../../dom.js"; +import { createWalletPart } from "../../dom.js"; import { formatNumber } from "../../format.js"; /** * @typedef {import("../../scan/index.js").WalletAddress} ReceiveAddress */ -/** - * @typedef {Object} QrCode - * @property {(options?: { scale?: number }) => string} toDataURL - */ - -const generateQr = - /** @type {(value: string) => QrCode | undefined} */ ( - /** @type {unknown} */ (leanQr.generate) - ); - -/** - * @param {string} value - */ -function createQrDataUrl(value) { - const qr = generateQr(value); - - return qr?.toDataURL({ scale: 8 }) ?? ""; -} - /** * @param {ReceiveAddress} receiveAddress */ @@ -47,7 +29,7 @@ function createReceiveQr(receiveAddress) { const uri = `bitcoin:${receiveAddress.address}`; image.alt = `QR code for ${receiveAddress.address}`; - image.src = createQrDataUrl(uri); + image.src = createQrDataUrl(uri, { scale: 8 }); return image; } @@ -77,7 +59,7 @@ async function copyReceiveAddress(receiveAddress, copy) { * @param {ReceiveAddress} receiveAddress */ function openReceiveDialog(host, receiveAddress) { - const dialog = createElement("dialog", "receive"); + const dialog = createWalletPart("dialog", "receive"); const content = document.createElement("article"); const actions = document.createElement("footer"); const copy = document.createElement("button"); @@ -98,22 +80,13 @@ function openReceiveDialog(host, receiveAddress) { actions, ); dialog.append(content); - host.append(dialog); copy.addEventListener("click", () => { void copyReceiveAddress(receiveAddress, copy).catch(() => { copy.textContent = "Copy failed"; }); }); - dialog.addEventListener("close", () => { - dialog.remove(); - }); - dialog.addEventListener("click", (event) => { - if (event.target === dialog) { - dialog.close(); - } - }); - dialog.showModal(); + openDialog(dialog, host); } /** diff --git a/website_next/wallets/wallet/receive/style.css b/website_next/wallets/wallet/receive/style.css index 9ae6bbaf9..9bd989688 100644 --- a/website_next/wallets/wallet/receive/style.css +++ b/website_next/wallets/wallet/receive/style.css @@ -1,5 +1,5 @@ -main.wallets { - dialog.receive { +main[data-page="wallets"] { + dialog[data-wallet="receive"] { width: min(100% - 2rem, 32rem); > article { @@ -9,7 +9,7 @@ main.wallets { > h2 { margin: 0; font-size: var(--font-size-lg); - font-weight: 400; + font-weight: var(--font-weight-regular); line-height: var(--line-height-lg); } diff --git a/website_next/wallets/wallet/summary/style.css b/website_next/wallets/wallet/summary/style.css index db2fce315..ec2c9d4d7 100644 --- a/website_next/wallets/wallet/summary/style.css +++ b/website_next/wallets/wallet/summary/style.css @@ -1,5 +1,5 @@ -main.wallets { - .summary { +main[data-page="wallets"] { + [data-wallet="summary"] { display: flex; gap: 1rem; align-items: end; @@ -25,7 +25,7 @@ main.wallets { overflow-wrap: anywhere; color: var(--white); font-size: 4rem; - font-weight: 400; + font-weight: var(--font-weight-regular); line-height: 1; } diff --git a/website_next/wallets/wallet/tabs/index.js b/website_next/wallets/wallet/tabs/index.js index 5a34a90cf..47bb407bf 100644 --- a/website_next/wallets/wallet/tabs/index.js +++ b/website_next/wallets/wallet/tabs/index.js @@ -1,4 +1,4 @@ -import { createElement } from "../../dom.js"; +import { createWalletPart } from "../../dom.js"; /** * @typedef {Object} WalletTab @@ -36,7 +36,7 @@ function setupTabPanel(tab) { * @param {readonly WalletTab[]} tabs */ export function createWalletTabs(tabs) { - const element = createElement("section", "tabs"); + const element = createWalletPart("section", "tabs"); const nav = document.createElement("nav"); const panels = document.createElement("div"); const buttons = tabs.map(createTabButton); diff --git a/website_next/wallets/wallet/tabs/style.css b/website_next/wallets/wallet/tabs/style.css index 44c1182c5..ad14ad1c0 100644 --- a/website_next/wallets/wallet/tabs/style.css +++ b/website_next/wallets/wallet/tabs/style.css @@ -1,5 +1,5 @@ -main.wallets { - .tabs { +main[data-page="wallets"] { + [data-wallet="tabs"] { display: grid; gap: 1rem; min-width: 0;