mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-07-03 15:23:41 -07:00
website_next: snapshot
This commit is contained in:
@@ -18,6 +18,8 @@ ALWAYS
|
||||
- light (memory)
|
||||
- KISS
|
||||
- DRY
|
||||
- lean
|
||||
- YAGNI
|
||||
- very well organized
|
||||
- contained
|
||||
- colocated
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<ReturnType<typeof brk.getBlocksV1>>[number]} Block */
|
||||
/** @typedef {Awaited<ReturnType<typeof brk.getMempoolBlocks>>[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<string, Block>} */
|
||||
const blocksByHash = new Map();
|
||||
|
||||
/** @type {ReturnType<typeof createProjectedCube>[]} */
|
||||
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<typeof createProjectedCube>} 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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
main.explore {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 4rem;
|
||||
display: flex;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -103,6 +103,8 @@
|
||||
|
||||
<link rel="stylesheet" href="/home/style.css" />
|
||||
<link rel="stylesheet" href="/explore/style.css" />
|
||||
<link rel="stylesheet" href="/explore/chain/cube/style.css" />
|
||||
<link rel="stylesheet" href="/explore/chain/style.css" />
|
||||
<link rel="stylesheet" href="/learn/style.css" />
|
||||
<link rel="stylesheet" href="/learn/charts/style.css" />
|
||||
<link rel="stylesheet" href="/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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user