diff --git a/website_next/AGENTS.md b/website_next/AGENTS.md index dfe5e91b2..76310922e 100644 --- a/website_next/AGENTS.md +++ b/website_next/AGENTS.md @@ -18,6 +18,8 @@ ALWAYS - light (memory) - KISS - DRY +- lean +- YAGNI - very well organized - contained - colocated diff --git a/website_next/explore/chain/cube/index.js b/website_next/explore/chain/cube/index.js new file mode 100644 index 000000000..9b5020d1e --- /dev/null +++ b/website_next/explore/chain/cube/index.js @@ -0,0 +1,63 @@ +/** + * @param {number} [fill] + */ +export function createCubeButton(fill = 1) { + const element = document.createElement("button"); + + element.type = "button"; + + return { element, ...populateCube(element, fill) }; +} + +/** + * @param {number} [fill] + */ +export function createCubeDiv(fill = 1) { + const element = document.createElement("div"); + + return { element, ...populateCube(element, fill) }; +} + +/** + * @param {HTMLElement} element + * @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"); + + element.classList.add("cube"); + 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"), + rightFace, + leftFace, + topFace, + ); + + 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 new file mode 100644 index 000000000..da51a39d8 --- /dev/null +++ b/website_next/explore/chain/cube/style.css @@ -0,0 +1,247 @@ +#chain { + --cube-size: 4.5rem; + --iso-scale: calc(sqrt(3) / 2); + --cube-empty-alpha: 0.4; + --face-step: 0.033; + + .cube { + --cube-width: calc(var(--cube-size) * 2 * var(--iso-scale)); + --cube-height: calc(var(--cube-size) * 2); + + --cube-face-base: var(--border-color); + --face-top: light-dark( + var(--cube-face-base), + oklch(from var(--cube-face-base) calc(l + var(--face-step) * 2) c h) + ); + --face-right: light-dark( + oklch(from var(--cube-face-base) calc(l - var(--face-step) * 2) c h), + var(--cube-face-base) + ); + --face-left: light-dark( + oklch(from var(--cube-face-base) calc(l - var(--face-step)) c h), + oklch(from var(--cube-face-base) calc(l + var(--face-step)) c h) + ); + --face-bottom: oklch( + from var(--cube-face-base) calc(l - var(--face-step) * 3) c h + ); + + --is-full: round(down, var(--fill), 1); + --is-empty: round(down, calc(1 - var(--fill)), 1); + + display: block; + position: relative; + flex-shrink: 0; + width: var(--cube-width); + height: var(--cube-height); + border-radius: 0; + padding: 0; + overflow: visible; + color: var(--color); + background: transparent; + text-transform: none; + -webkit-user-select: none; + user-select: none; + pointer-events: none; + + @media (hover: hover) and (pointer: fine) { + &:is(button):hover { + color: var(--background-color); + --face-color-base: var(--inv-border-color); + --face-top: var(--face-color-base); + --face-right: oklch( + from var(--face-color-base) calc(l - var(--face-step) * 2) c h + ); + --face-left: oklch( + from var(--face-color-base) calc(l - var(--face-step)) c h + ); + --face-bottom: oklch( + from var(--face-color-base) calc(l - var(--face-step) * 3) c h + ); + } + } + + &:is(button):active, + &.selected { + color: var(--black); + --face-color-base: var(--orange); + --face-top: var(--face-color-base); + --face-right: oklch( + from var(--face-color-base) calc(l - var(--face-step) * 2) c h + ); + --face-left: oklch( + from var(--face-color-base) calc(l - var(--face-step)) c h + ); + --face-bottom: oklch( + from var(--face-color-base) calc(l - var(--face-step) * 3) c h + ); + } + + &[data-press]:not(.selected) { + color: var(--background-color); + --face-color-base: var(--inv-border-color); + --face-top: var(--face-color-base); + --face-right: oklch( + from var(--face-color-base) calc(l - var(--face-step) * 2) c h + ); + --face-left: oklch( + from var(--face-color-base) calc(l - var(--face-step)) c h + ); + --face-bottom: oklch( + from var(--face-color-base) calc(l - var(--face-step) * 3) c h + ); + } + + &.projected { + animation: projected-cube-pulse 4s ease-in-out infinite; + } + + &.skeleton .face-text { + visibility: hidden; + } + + .face { + position: absolute; + top: 0; + left: 0; + box-sizing: border-box; + width: var(--cube-size); + height: var(--cube-size); + transform-origin: 0 0; + transform: translateY(50%) var(--face-orient) + translate( + calc(var(--cube-size) * var(--face-x)), + calc(var(--cube-size) * var(--face-y)) + ) + scale(var(--face-scale-x, 1), var(--face-scale-y)); + pointer-events: auto; + } + + .liquid { + --face-scale-y: calc(var(--iso-scale) * var(--fill)); + --face-stack-shift: calc(var(--iso-scale) * (1 - var(--fill))); + + background: var(--face-color); + opacity: calc(1 - var(--is-empty)); + } + + .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 { + --face-scale-y: var(--iso-scale); + --face-stack-shift: 0; + + display: flex; + flex-direction: column; + align-items: center; + padding: 0.1rem; + font-family: var(--font-mono); + font-size: var(--font-size-xs); + font-weight: 450; + line-height: var(--line-height-base); + text-align: center; + pointer-events: none; + + &.top { + justify-content: center; + text-transform: uppercase; + } + + &.right { + justify-content: space-between; + } + + &.left { + justify-content: center; + } + + p { + margin: 0; + } + } + + .top, + .bottom { + --face-orient: rotate(30deg) skewX(-30deg); + --face-scale-y: var(--iso-scale); + } + + .right, + .rear-left { + --face-orient: rotate(-30deg) skewX(-30deg); + } + + .left, + .rear-right { + --face-orient: rotate(30deg) skewX(30deg); + } + + .top, + .rear-right { + --face-y: calc(var(--face-stack-shift) - var(--iso-scale)); + } + + .left, + .rear-left { + --face-y: var(--face-stack-shift); + } + + .right { + --face-y: calc(var(--face-stack-shift) + var(--iso-scale)); + } + + .bottom { + --face-y: 0; + } + + .top { + --face-color: var(--face-top); + --face-x: 0; + } + + .bottom { + --face-color: var(--face-bottom); + --face-x: 1; + } + + .right { + --face-color: var(--face-right); + --face-x: 1; + } + + .left { + --face-color: var(--face-left); + --face-x: 0; + } + + .rear-right { + --face-color: var(--face-left); + --face-x: 1; + } + + .rear-left { + --face-color: var(--face-top); + --face-scale-x: -1; + --face-x: 1; + } + + .liquid.top { + --face-x: calc(1 - var(--fill)); + } + } +} + +@keyframes projected-cube-pulse { + 0%, + 100% { + filter: brightness(1); + } + + 50% { + filter: brightness(2.5); + } +} diff --git a/website_next/explore/chain/index.js b/website_next/explore/chain/index.js new file mode 100644 index 000000000..994646f3c --- /dev/null +++ b/website_next/explore/chain/index.js @@ -0,0 +1,704 @@ +import { brk } from "../../utils/client.js"; +import { isPlainLeftClick } from "../../utils/event.js"; +import { createCubeButton, createCubeDiv } from "./cube/index.js"; + +const LOOKAHEAD = 15; +const POLL_INTERVAL = 1_000; +const PROJECTED_LIMIT = 8; +const TARGET_BLOCK_SECONDS = 600; +const TIP_BLOCK_THRESHOLD = 10; +const MONTHS = /** @type {const} */ ([ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +]); + +/** @typedef {Awaited>[number]} Block */ +/** @typedef {Awaited>[number]} MempoolBlock */ + +/** @param {number} rate */ +function formatFeeRate(rate) { + if (rate >= 1_000_000) return `${(rate / 1_000_000).toFixed(1)}M`; + if (rate >= 100_000) return `${Math.round(rate / 1_000)}k`; + if (rate >= 1_000) return `${(rate / 1_000).toFixed(1)}k`; + if (rate >= 100) return Math.round(rate).toLocaleString(); + if (rate >= 10) return rate.toFixed(1); + return rate.toFixed(2); +} + +/** @param {number} height */ +function createHeightElement(height) { + const container = document.createElement("span"); + const prefix = document.createElement("span"); + const value = document.createElement("span"); + + prefix.classList.add("dim"); + prefix.style.userSelect = "none"; + prefix.textContent = `#${"0".repeat(Math.max(0, 7 - String(height).length))}`; + value.textContent = String(height); + container.append(prefix, value); + + return container; +} + +/** @param {HTMLElement} element @param {() => void} handler */ +function onPlainClick(element, handler) { + element.addEventListener("click", (event) => { + if (!(event instanceof MouseEvent) || !isPlainLeftClick(event)) return; + + event.preventDefault(); + handler(); + }); +} + +/** @param {string} text @param {string} [className] */ +function span(text, className) { + const element = document.createElement("span"); + + if (className) element.classList.add(className); + element.textContent = text; + + return element; +} + +/** @param {string} name */ +function poolSlug(name) { + return name.toLowerCase().replace(/[^a-z0-9]/g, ""); +} + +/** @param {number} unixSeconds */ +function formatShortDate(unixSeconds) { + const date = new Date(unixSeconds * 1_000); + + return `${MONTHS[date.getMonth()]} ${date.getDate()}`; +} + +/** @param {number} unixSeconds */ +function formatHHMM(unixSeconds) { + const date = new Date(unixSeconds * 1_000); + + return [ + String(date.getHours()).padStart(2, "0"), + String(date.getMinutes()).padStart(2, "0"), + ]; +} + +/** + * @param {"tip"} className + * @param {string} label + * @param {string} mobileLabel + * @param {string} title + * @param {() => void} handler + */ +function createEdgeButton(className, label, mobileLabel, title, handler) { + const button = document.createElement("button"); + + button.classList.add("edge", className); + button.type = "button"; + button.title = title; + button.ariaLabel = title; + button.dataset.mobileLabel = mobileLabel; + button.textContent = label; + onPlainClick(button, handler); + + return button; +} + +export function createChain() { + const element = document.createElement("div"); + const scrollElement = document.createElement("div"); + const blocksElement = document.createElement("div"); + const tipButton = createEdgeButton("tip", "↑", "←", "Jump to chain tip", () => { + void goToCube(null); + }); + + element.id = "chain"; + tipButton.hidden = true; + scrollElement.classList.add("scroll"); + blocksElement.classList.add("blocks"); + scrollElement.append(blocksElement); + element.append(tipButton, scrollElement); + + /** @type {HTMLButtonElement | null} */ + let selectedCube = null; + + /** @type {IntersectionObserver | undefined} */ + let olderEdgeObserver; + + /** @type {Map} */ + const blocksByHash = new Map(); + + /** @type {ReturnType[]} */ + const projectedCubes = []; + + let active = false; + let newestHeight = -1; + let oldestHeight = Infinity; + let newestTimestamp = 0; + let loadingOlder = false; + let loadingNewer = false; + let polling = false; + let reachedTip = false; + + /** @type {number | undefined} */ + let pollId; + let tipSyncFrame = 0; + + /** @type {AbortController} */ + let controller = new AbortController(); + + /** @param {string | number | null | undefined} hashOrHeight */ + function findCube(hashOrHeight) { + if (hashOrHeight == null) { + return reachedTip && newestHeight >= 0 ? newestConfirmedCube() : null; + } + + const attribute = typeof hashOrHeight === "number" ? "height" : "hash"; + + return /** @type {HTMLButtonElement | null} */ ( + blocksElement.querySelector(`[data-${attribute}="${hashOrHeight}"]`) + ); + } + + function firstProjectedCube() { + return projectedCubes[0]?.element ?? null; + } + + function newestConfirmedCube() { + const firstProjected = firstProjectedCube(); + + return /** @type {HTMLButtonElement | null} */ ( + firstProjected + ? firstProjected.previousElementSibling + : blocksElement.lastElementChild + ); + } + + function deselectCube() { + if (selectedCube) selectedCube.classList.remove("selected"); + selectedCube = null; + } + + /** @param {HTMLButtonElement} cube @param {{ scroll?: "smooth" | "instant" }} [options] */ + function selectCube(cube, { scroll } = {}) { + if (cube !== selectedCube) { + deselectCube(); + selectedCube = cube; + cube.classList.add("selected"); + } + + if (scroll) { + cube.scrollIntoView({ + behavior: scroll, + block: "center", + inline: "center", + }); + scheduleTipVisibilitySync(); + } + } + + function clear() { + newestHeight = -1; + oldestHeight = Infinity; + newestTimestamp = 0; + loadingOlder = false; + loadingNewer = false; + reachedTip = false; + selectedCube = null; + blocksByHash.clear(); + blocksElement.textContent = ""; + projectedCubes.length = 0; + tipButton.hidden = true; + olderEdgeObserver?.disconnect(); + } + + function observeOldestEdge() { + olderEdgeObserver?.disconnect(); + + const oldest = blocksElement.firstElementChild; + if (oldest) olderEdgeObserver?.observe(oldest); + } + + /** @param {Block[]} blocks */ + function appendNewerBlocks(blocks) { + if (!blocks.length) return false; + + const anchor = newestConfirmedCube(); + const anchorRect = anchor?.getBoundingClientRect(); + + for (let i = blocks.length - 1; i >= 0; i--) { + const block = blocks[i]; + + if (block.height > newestHeight) { + appendConfirmed(createConfirmedCube(block)); + } else { + blocksByHash.set(block.id, block); + } + } + + newestHeight = Math.max(newestHeight, blocks[0].height); + newestTimestamp = blocks[0].timestamp; + refreshProjected(); + + if (anchor && anchorRect) { + const rect = anchor.getBoundingClientRect(); + scrollElement.scrollTop += rect.top - anchorRect.top; + scrollElement.scrollLeft += rect.left - anchorRect.left; + } + + syncTipVisibility(); + + return true; + } + + /** @param {number | null} [height] */ + async function loadInitial(height) { + const blocks = + height != null + ? await brk.getBlocksV1FromHeight(height, { signal: controller.signal }) + : await brk.getBlocksV1({ signal: controller.signal }); + + clear(); + + for (const block of blocks) prependConfirmed(createConfirmedCube(block)); + + newestHeight = blocks[0].height; + oldestHeight = blocks[blocks.length - 1].height; + newestTimestamp = blocks[0].timestamp; + reachedTip = height == null; + observeOldestEdge(); + + if (reachedTip) await pollProjected(); + else await loadNewer(); + + return blocks[0].id; + } + + /** @param {string | number | null | undefined} hashOrHeight */ + async function resolveHeight(hashOrHeight) { + if (typeof hashOrHeight === "number") return hashOrHeight; + + if (typeof hashOrHeight === "string") { + const cached = blocksByHash.get(hashOrHeight); + if (cached) return cached.height; + + const block = await brk.getBlockV1(hashOrHeight, { + signal: controller.signal, + }); + blocksByHash.set(hashOrHeight, block); + + return block.height; + } + + return null; + } + + /** @param {string | number | null | undefined} [hashOrHeight] */ + async function goToCube(hashOrHeight) { + if (!active) return; + + if (hashOrHeight === "tip") hashOrHeight = null; + if (typeof hashOrHeight === "string" && /^\d+$/.test(hashOrHeight)) { + hashOrHeight = Number(hashOrHeight); + } + + const existing = findCube(hashOrHeight); + if (existing) { + selectCube(existing, { scroll: "smooth" }); + return; + } + + for (const cube of blocksElement.children) { + if (!cube.classList.contains("projected")) cube.classList.add("skeleton"); + } + + element.classList.add("loading"); + + try { + const height = await resolveHeight(hashOrHeight); + const startHash = await loadInitial(height); + const cube = findCube(startHash); + if (cube) selectCube(cube, { scroll: "instant" }); + } catch (error) { + if (!controller.signal.aborted) { + console.error("explore chain load:", error); + } + } finally { + element.classList.remove("loading"); + } + } + + async function pollProjected() { + try { + renderProjected( + await brk.getMempoolBlocks({ signal: controller.signal }), + ); + } catch (error) { + if (!controller.signal.aborted) console.error("explore mempool:", error); + } + } + + async function poll() { + if (!active || !reachedTip || polling) return; + + polling = true; + await pollProjected(); + + try { + appendNewerBlocks(await brk.getBlocksV1({ signal: controller.signal })); + } catch (error) { + if (!controller.signal.aborted) console.error("explore chain poll:", error); + } finally { + polling = false; + } + } + + async function loadOlder() { + if (!active || loadingOlder || oldestHeight <= 0) return; + + loadingOlder = true; + + try { + const blocks = await brk.getBlocksV1FromHeight(oldestHeight - 1, { + signal: controller.signal, + }); + + for (const block of blocks) prependConfirmed(createConfirmedCube(block)); + + if (blocks.length) { + oldestHeight = blocks[blocks.length - 1].height; + observeOldestEdge(); + } + } catch (error) { + if (!controller.signal.aborted) console.error("explore older:", error); + } finally { + loadingOlder = false; + } + } + + async function loadNewer() { + if (!active || loadingNewer || newestHeight === -1 || reachedTip) return; + + loadingNewer = true; + + try { + const prevNewest = newestHeight; + const blocks = await brk.getBlocksV1FromHeight( + newestHeight + LOOKAHEAD, + { signal: controller.signal }, + ); + + if (!appendNewerBlocks(blocks) || newestHeight === prevNewest) { + reachedTip = true; + await pollProjected(); + } + } catch (error) { + if (!controller.signal.aborted) console.error("explore newer:", error); + } finally { + loadingNewer = false; + } + } + + /** @param {Block} block */ + function createConfirmedCube(block) { + const { pool, medianFee, feeRange, virtualSize } = block.extras; + const cube = createCubeButton(Math.min(1, virtualSize / 1_000_000)); + + 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()}`; + blocksByHash.set(block.id, block); + onPlainClick(cube.element, () => selectCube(cube.element)); + + const date = document.createElement("p"); + const time = document.createElement("p"); + const [hh, mm] = formatHHMM(block.timestamp); + date.textContent = formatShortDate(block.timestamp); + time.append(hh, span(":", "dim"), mm); + cube.topFace.append(date, time); + + const height = document.createElement("p"); + height.classList.add("height"); + height.append(createHeightElement(block.height)); + + const poolElement = document.createElement("div"); + const logo = document.createElement("img"); + const name = document.createElement("span"); + poolElement.classList.add("pool"); + logo.src = `/assets/pools/${poolSlug(pool.name)}.svg`; + logo.alt = ""; + logo.onerror = () => { + logo.onerror = null; + logo.src = "/assets/pools/default.svg"; + }; + name.textContent = pool.name.replace(/\s+(Pool|USA)$/i, "").trim(); + poolElement.append(logo, name); + cube.rightFace.append(height, poolElement); + + const fees = document.createElement("div"); + const median = document.createElement("p"); + const range = document.createElement("p"); + const unit = document.createElement("p"); + fees.classList.add("fees"); + median.append(span("~", "dim"), formatFeeRate(medianFee)); + range.append( + formatFeeRate(feeRange[0]), + span("-", "dim"), + formatFeeRate(feeRange[6]), + ); + unit.classList.add("dim"); + unit.textContent = "sat/vB"; + fees.append(median, range, unit); + cube.leftFace.append(fees); + + return cube.element; + } + + /** @param {HTMLElement} cube */ + function setConfirmedInterval(cube) { + const prev = /** @type {HTMLElement | null} */ (cube.previousElementSibling); + if (!prev) return; + + cube.style.setProperty( + "--block-interval", + String( + Math.max( + 0, + Number(cube.dataset.timestamp) - Number(prev.dataset.timestamp), + ), + ), + ); + } + + /** @param {HTMLButtonElement} cube */ + function prependConfirmed(cube) { + const oldFirst = /** @type {HTMLElement | null} */ ( + blocksElement.firstElementChild + ); + + blocksElement.insertBefore(cube, oldFirst); + if (oldFirst) setConfirmedInterval(oldFirst); + } + + /** @param {HTMLButtonElement} cube */ + function appendConfirmed(cube) { + blocksElement.insertBefore(cube, firstProjectedCube()); + setConfirmedInterval(cube); + } + + /** @param {MempoolBlock[]} blocks */ + function renderProjected(blocks) { + const want = Math.min(blocks.length, PROJECTED_LIMIT); + + while (projectedCubes.length > want) { + projectedCubes.pop()?.element.remove(); + } + + while (projectedCubes.length < want) { + const cube = createProjectedCube(); + projectedCubes.push(cube); + blocksElement.append(cube.element); + } + + for (let i = 0; i < want; i++) { + updateProjectedCube(projectedCubes[i], blocks[i]); + } + + refreshProjected(); + } + + function createProjectedCube() { + const cube = createCubeDiv(); + const date = document.createTextNode(""); + const hh = document.createTextNode(""); + const mm = document.createTextNode(""); + const txs = document.createTextNode(""); + const txsUnit = document.createTextNode(""); + const median = document.createTextNode(""); + const rangeLo = document.createTextNode(""); + const rangeHi = document.createTextNode(""); + + const dateElement = document.createElement("p"); + const timeElement = document.createElement("p"); + const txsElement = document.createElement("p"); + const txsUnitElement = document.createElement("p"); + const medianElement = document.createElement("p"); + const rangeElement = document.createElement("p"); + const unitElement = document.createElement("p"); + + cube.element.classList.add("projected"); + dateElement.append(date); + timeElement.append(hh, span(":", "dim"), mm); + cube.topFace.append(dateElement, timeElement); + + txsElement.append(txs); + txsUnitElement.classList.add("dim"); + txsUnitElement.append(txsUnit); + cube.rightFace.append(txsElement, txsUnitElement); + + medianElement.append(span("~", "dim"), median); + rangeElement.append(rangeLo, span("-", "dim"), rangeHi); + unitElement.classList.add("dim"); + unitElement.textContent = "sat/vB"; + cube.leftFace.append(medianElement, rangeElement, unitElement); + + return { + ...cube, + parts: { date, hh, mm, txs, txsUnit, median, rangeLo, rangeHi }, + }; + } + + /** @param {ReturnType} cube @param {MempoolBlock} block */ + function updateProjectedCube(cube, block) { + cube.element.style.setProperty( + "--fill", + String(Math.min(1, block.blockVSize / 1_000_000)), + ); + + cube.parts.txs.nodeValue = block.nTx.toLocaleString(); + cube.parts.txsUnit.nodeValue = block.nTx === 1 ? "tx" : "txs"; + cube.parts.median.nodeValue = formatFeeRate(block.medianFee); + cube.parts.rangeLo.nodeValue = formatFeeRate(block.feeRange[0]); + cube.parts.rangeHi.nodeValue = formatFeeRate(block.feeRange[6]); + } + + function refreshProjected() { + if (!projectedCubes.length || !newestTimestamp) return; + + const now = Math.floor(Date.now() / 1_000); + const elapsed = Math.max(0, now - newestTimestamp); + + for (let i = 0; i < projectedCubes.length; i++) { + const cube = projectedCubes[i]; + const interval = i === 0 ? elapsed : TARGET_BLOCK_SECONDS; + const timestamp = now + i * TARGET_BLOCK_SECONDS; + const [hh, mm] = formatHHMM(timestamp); + + cube.element.style.setProperty("--block-interval", String(interval)); + cube.parts.date.nodeValue = formatShortDate(timestamp); + cube.parts.hh.nodeValue = hh; + cube.parts.mm.nodeValue = mm; + } + } + + function scheduleTipVisibilitySync() { + if (tipSyncFrame) return; + + tipSyncFrame = window.requestAnimationFrame(() => { + tipSyncFrame = 0; + syncTipVisibility(); + }); + } + + function syncTipVisibility() { + if (!reachedTip || newestHeight < 0) { + tipButton.hidden = true; + return; + } + + const visibleHeight = findVisibleConfirmedHeight(); + tipButton.hidden = + visibleHeight == null || + newestHeight - visibleHeight <= TIP_BLOCK_THRESHOLD; + } + + function findVisibleConfirmedHeight() { + const viewport = scrollElement.getBoundingClientRect(); + const horizontal = getComputedStyle(blocksElement).flexDirection.startsWith( + "row", + ); + const viewportStart = horizontal ? viewport.left : viewport.top; + const viewportEnd = horizontal ? viewport.right : viewport.bottom; + const target = (viewportStart + viewportEnd) / 2; + + let closestHeight = null; + let closestDistance = Infinity; + + for (const element of blocksElement.children) { + if ( + !(element instanceof HTMLElement) || + element.classList.contains("projected") + ) { + continue; + } + + const rect = element.getBoundingClientRect(); + const start = horizontal ? rect.left : rect.top; + const end = horizontal ? rect.right : rect.bottom; + + if (end < viewportStart || start > viewportEnd) continue; + + const distance = Math.abs((start + end) / 2 - target); + if (distance >= closestDistance) continue; + + closestDistance = distance; + closestHeight = Number(element.dataset.height); + } + + return closestHeight; + } + + olderEdgeObserver = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) void loadOlder(); + }, + { root: scrollElement }, + ); + + scrollElement.addEventListener( + "scroll", + () => { + scheduleTipVisibilitySync(); + + if (reachedTip || loadingNewer) return; + if (scrollElement.scrollTop <= 50 && scrollElement.scrollLeft <= 50) { + void loadNewer(); + } + }, + { passive: true }, + ); + + /** @param {boolean} nextActive */ + function setActive(nextActive) { + if (active === nextActive) return; + + active = nextActive; + + if (active) { + controller = new AbortController(); + + if (newestHeight === -1) void goToCube(null); + else void poll(); + + pollId = window.setInterval(() => void poll(), POLL_INTERVAL); + return; + } + + if (pollId !== undefined) { + window.clearInterval(pollId); + pollId = undefined; + } + + if (tipSyncFrame) { + window.cancelAnimationFrame(tipSyncFrame); + tipSyncFrame = 0; + } + + controller.abort(); + } + + return /** @type {const} */ ({ + element, + setActive, + }); +} diff --git a/website_next/explore/chain/style.css b/website_next/explore/chain/style.css new file mode 100644 index 000000000..368d77e02 --- /dev/null +++ b/website_next/explore/chain/style.css @@ -0,0 +1,177 @@ +#chain { + --background-color: var(--black); + --color: var(--white); + --border-color: oklch(20% 0 0); + --inv-border-color: oklch(90% 0 0); + --min-gap: calc(var(--cube-size) * -1); + --max-gap: calc(var(--cube-size) * 6); + --min-block-interval: 0; + --max-block-interval: 10800; + --main-padding: var(--page-x); + + position: relative; + display: grid; + flex: 1; + min-width: 0; + height: 100%; + min-height: 0; + overflow: hidden; + opacity: 1; + transition: opacity 200ms ease; + + &.loading { + opacity: 0; + } + + .dim { + opacity: 0.5; + } + + .scroll { + height: 100%; + padding: 0; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: none; + } + + .blocks { + display: flex; + flex-direction: column-reverse; + min-height: 100%; + } + + .cube { + --cube-fall-off: pow( + clamp( + 0, + (var(--block-interval, 600) - var(--min-block-interval)) / + (var(--max-block-interval) - var(--min-block-interval)), + 1 + ), + 0.7 + ); + --block-gap: calc( + var(--min-gap) + var(--cube-fall-off) * + (var(--max-gap) - var(--min-gap)) + ); + + & + & { + margin-bottom: var(--block-gap); + } + + .face-text .height { + font-size: var(--font-size-sm); + font-weight: normal; + } + + .face-text .fees { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + } + + .face-text .pool { + display: flex; + align-items: center; + justify-content: center; + gap: 0.1em; + width: 100%; + + img { + width: 1.25em; + height: 1.25em; + flex-shrink: 0; + } + + span { + min-width: 0; + overflow: hidden; + line-height: 1; + text-overflow: ellipsis; + white-space: nowrap; + } + } + } + + .edge { + position: absolute; + top: var(--main-padding); + left: calc(var(--main-padding) / 2); + z-index: 1; + width: auto; + height: auto; + border-radius: 999rem; + padding: 0.375rem 0.625rem; + color: var(--black); + background: var(--white); + font-size: var(--font-size-xs); + font-weight: 500; + line-height: 1; + letter-spacing: 0; + + @media (hover: hover) and (pointer: fine) { + &:hover { + background: var(--gray); + } + } + + &:active { + background: var(--orange); + } + + &[data-press] { + background: var(--gray); + } + } +} + +@media (max-width: 48rem) { + #chain { + --min-gap: 0rem; + + grid-template-columns: minmax(0, 1fr); + height: 14rem; + min-height: 14rem; + + .scroll { + display: grid; + align-items: center; + grid-column: 1; + grid-row: 1; + padding: 0; + overflow-x: auto; + overflow-y: hidden; + overscroll-behavior: contain; + touch-action: pan-x; + } + + .blocks { + flex-direction: row-reverse; + align-items: center; + width: max-content; + min-height: 0; + } + + .cube { + & + & { + margin-right: var(--block-gap); + margin-bottom: 0; + } + } + + .edge { + top: 50%; + left: 0.75rem; + translate: 0 -50%; + font-size: 0; + + &::before { + content: attr(data-mobile-label); + font-size: var(--font-size-xs); + } + } + } +} diff --git a/website_next/explore/index.js b/website_next/explore/index.js index 13c591fdf..5d0bf263d 100644 --- a/website_next/explore/index.js +++ b/website_next/explore/index.js @@ -1,8 +1,20 @@ +import { createChain } from "./chain/index.js"; + export function createExplorePage() { const main = document.createElement("main"); + const chain = createChain(); + main.className = "explore"; - const title = document.createElement("h1"); - title.append("Explore"); - main.append(title); + main.append(chain.element); + + const syncChain = () => chain.setActive(!main.hidden && !document.hidden); + + main.addEventListener("pageactive", syncChain); + document.addEventListener("visibilitychange", syncChain); + new MutationObserver(syncChain).observe(main, { + attributes: true, + attributeFilter: ["hidden"], + }); + return main; } diff --git a/website_next/explore/style.css b/website_next/explore/style.css index a7c94fc76..b84aa3742 100644 --- a/website_next/explore/style.css +++ b/website_next/explore/style.css @@ -1,5 +1,6 @@ main.explore { - display: grid; - place-items: center; - font-size: 4rem; + display: flex; + height: 100dvh; + overflow: hidden; + padding: 0; } diff --git a/website_next/header/style.css b/website_next/header/style.css index 070af9842..b818fceaf 100644 --- a/website_next/header/style.css +++ b/website_next/header/style.css @@ -19,8 +19,10 @@ body { text-decoration: none; text-transform: lowercase; - &:hover { - opacity: 1; + @media (hover: hover) and (pointer: fine) { + &:hover { + opacity: 1; + } } &:active { @@ -28,6 +30,11 @@ body { --color: var(--orange); } + &[data-press] { + opacity: 1; + --color: var(--white); + } + > span { display: inline-grid; padding: 0.2rem 0.3rem; diff --git a/website_next/index.html b/website_next/index.html index afe1d59ce..2259d0655 100644 --- a/website_next/index.html +++ b/website_next/index.html @@ -103,6 +103,8 @@ + + diff --git a/website_next/learn/charts/controls/style.css b/website_next/learn/charts/controls/style.css index 303155292..9e8bd65bf 100644 --- a/website_next/learn/charts/controls/style.css +++ b/website_next/learn/charts/controls/style.css @@ -42,7 +42,7 @@ main.learn { display: block; } - label:has(:checked):not(:hover) span { + label:has(:checked) span { color: var(--black); background: var(--gray); } @@ -68,9 +68,11 @@ main.learn { color: var(--gray); } - :is(label:hover span, button[data-chart="fullscreen"]:hover) { - color: var(--black); - background: var(--white); + @media (hover: hover) and (pointer: fine) { + :is(label:hover span, button[data-chart="fullscreen"]:hover) { + color: var(--black); + background: var(--white); + } } :is(label:active span, button[data-chart="fullscreen"]:active) { @@ -78,6 +80,11 @@ main.learn { background: var(--orange); } + :is(label[data-press] span, button[data-chart="fullscreen"][data-press]) { + color: var(--black); + background: var(--white); + } + :is( label:has(:focus-visible) span, button[data-chart="fullscreen"]:focus-visible diff --git a/website_next/learn/charts/legend/style.css b/website_next/learn/charts/legend/style.css index e8ee39287..6907fd133 100644 --- a/website_next/learn/charts/legend/style.css +++ b/website_next/learn/charts/legend/style.css @@ -60,7 +60,29 @@ main.learn { text-transform: inherit; cursor: pointer; - &:is(:hover, :focus-visible, [data-active], [data-preview]) { + @media (hover: hover) and (pointer: fine) { + &:hover { + color: var(--black); + background: var(--color); + + span, + output { + color: inherit; + } + } + } + + &[data-press] { + color: var(--black); + background: var(--color); + + span, + output { + color: inherit; + } + } + + &:is(:focus-visible, [data-active], [data-preview]) { color: var(--black); background: var(--color); diff --git a/website_next/learn/contents/style.css b/website_next/learn/contents/style.css index 44fbd8307..fdd0c926a 100644 --- a/website_next/learn/contents/style.css +++ b/website_next/learn/contents/style.css @@ -49,6 +49,8 @@ main.learn { margin-inline: 0 1rem; padding: 0.25rem; padding-inline-start: calc(var(--line-width) + var(--line-gap)); + -webkit-user-select: none; + user-select: none; &::after { content: ""; @@ -66,15 +68,22 @@ main.learn { color: var(--white); } - &:hover { - color: var(--black); - background-color: var(--white); + @media (hover: hover) and (pointer: fine) { + &:hover { + color: var(--black); + background-color: var(--white); + } } &:active { color: var(--black); background-color: var(--orange); } + + &[data-press] { + color: var(--black); + background-color: var(--white); + } } ol ol { diff --git a/website_next/learn/style.css b/website_next/learn/style.css index c19a94e46..cb02caac1 100644 --- a/website_next/learn/style.css +++ b/website_next/learn/style.css @@ -115,19 +115,32 @@ main.learn { text-decoration: none; } - &:hover::before { - opacity: 0.5; - } + @media (hover: hover) and (pointer: fine) { + &:hover::before { + opacity: 0.5; + } - &:hover { - text-decoration: underline; - text-decoration-thickness: 1px; - text-underline-offset: 0.125em; + &:hover { + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 0.125em; + } } &:active { color: var(--orange); } + + &[data-press]::before { + opacity: 0.5; + } + + &[data-press] { + color: var(--white); + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 0.125em; + } } } } diff --git a/website_next/main.js b/website_next/main.js index 865fb3615..df473c9e5 100644 --- a/website_next/main.js +++ b/website_next/main.js @@ -1,5 +1,6 @@ import { createHeader } from "./header/index.js"; import { createRoutePage, normalizePath, resolvePath } from "./routes.js"; +import "./utils/press.js"; import { getEventAnchor, isPlainLeftClick } from "./utils/event.js"; import { revealPage, transitionPage } from "./utils/transition.js"; diff --git a/website_next/styles/main.css b/website_next/styles/main.css index 250b07ec4..c65f4853e 100644 --- a/website_next/styles/main.css +++ b/website_next/styles/main.css @@ -12,6 +12,17 @@ body > main { color: var(--white); } +a, +button, +input, +label, +select, +summary, +textarea { + -webkit-tap-highlight-color: transparent; + -webkit-touch-callout: none; +} + button, input, select, @@ -41,16 +52,24 @@ button, background: var(--button-background); text-decoration: none; text-transform: uppercase; + -webkit-user-select: none; + user-select: none; cursor: pointer; - &:hover { - background: var(--button-hover-background); + @media (hover: hover) and (pointer: fine) { + &:hover { + background: var(--button-hover-background); + } } &:active { background: var(--button-active-background); } + &[data-press] { + background: var(--button-hover-background); + } + &:disabled { opacity: 0.5; cursor: progress; @@ -64,9 +83,11 @@ textarea { color: var(--field-color); background: transparent; - &:hover { - border-color: var(--field-color); - color: var(--field-color); + @media (hover: hover) and (pointer: fine) { + &:hover { + border-color: var(--field-color); + color: var(--field-color); + } } &:active { @@ -74,6 +95,11 @@ textarea { color: var(--field-active-color); } + &[data-press] { + border-color: var(--field-color); + color: var(--field-color); + } + &[aria-invalid="true"] { border-color: var(--red); color: var(--red); diff --git a/website_next/utils/press.js b/website_next/utils/press.js new file mode 100644 index 000000000..e35d92441 --- /dev/null +++ b/website_next/utils/press.js @@ -0,0 +1,34 @@ +const PRESS_SELECTOR = [ + "a[href]", + "button:not(:disabled)", + "input:not(:disabled)", + "label", + "select:not(:disabled)", + "summary", + "textarea:not(:disabled)", +].join(","); + +/** @type {Element | null} */ +let pressedElement = null; + +function clearPress() { + pressedElement?.removeAttribute("data-press"); + pressedElement = null; +} + +document.addEventListener( + "pointerdown", + (event) => { + if (event.pointerType === "mouse") return; + if (!(event.target instanceof Element)) return; + + clearPress(); + pressedElement = event.target.closest(PRESS_SELECTOR); + pressedElement?.setAttribute("data-press", ""); + }, + { passive: true }, +); + +document.addEventListener("pointerup", clearPress, { passive: true }); +document.addEventListener("pointercancel", clearPress, { passive: true }); +window.addEventListener("blur", clearPress); diff --git a/website_next/wallets/hold/style.css b/website_next/wallets/hold/style.css index c6bf6909a..52b807f20 100644 --- a/website_next/wallets/hold/style.css +++ b/website_next/wallets/hold/style.css @@ -39,7 +39,14 @@ main.wallets { color: inherit; } - &:is(:hover, :focus-visible, :active):not(.holding) { + @media (hover: hover) and (pointer: fine) { + &:hover:not(.holding) { + color: var(--red); + background: transparent; + } + } + + &:is(:focus-visible, :active, [data-press]):not(.holding) { color: var(--red); background: transparent; } diff --git a/website_next/wallets/selector/style.css b/website_next/wallets/selector/style.css index d2f79d10a..c8ac0889f 100644 --- a/website_next/wallets/selector/style.css +++ b/website_next/wallets/selector/style.css @@ -25,9 +25,11 @@ main.wallets { font-size: var(--font-size-base); } - &:hover { - color: var(--black); - background: var(--white); + @media (hover: hover) and (pointer: fine) { + &:hover { + color: var(--black); + background: var(--white); + } } &:active { @@ -35,6 +37,11 @@ main.wallets { background: var(--orange); } + &[data-press] { + color: var(--black); + background: var(--white); + } + &[aria-pressed="true"] { color: var(--black); background: var(--white); @@ -44,11 +51,22 @@ main.wallets { color: color-mix(in oklch, var(--gray) 76%, transparent); background: transparent; - &:hover, + @media (hover: hover) and (pointer: fine) { + &:hover { + color: var(--red); + background: transparent; + } + } + &:active { color: var(--red); background: transparent; } + + &[data-press] { + color: var(--red); + background: transparent; + } } } }