Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions CLAUDE.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
"verify": "npm run format:check && npm run check && npm run build && npm test",
"art": "node scripts/generate-art.mjs",
"bitcoin-epochs": "node scripts/generate-bitcoin-epochs.mjs",
"offchain-pseudo": "node scripts/generate-offchain-pseudo.mjs src/content/bitcoin/data/offchain-revenue-jan2023.json",
"test": "tsx --test src/**/*.test.ts"
"offchain-pseudo": "node scripts/generate-offchain-pseudo.mjs src/posts/bitcoin/data/offchain-revenue-jan2023.json",
"test": "tsx --test \"src/**/*.test.ts\""
},
"dependencies": {
"@astrojs/markdown-remark": "^7.2.2",
Expand Down
23 changes: 16 additions & 7 deletions scripts/generate-art.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/**
* Turns a full-size master in `art-src/` into the one committed webp the site
* builds from, in `src/assets/`. Subdirectories are mirrored, so a master at
* `art-src/bitcoin/bg.png` becomes `src/assets/bitcoin/bg.webp` — one folder
* per post, matching `src/content/`.
* builds from. The first folder under `art-src/` is the post slug, so a master
* at `art-src/bitcoin/bg.png` becomes `src/posts/bitcoin/assets/bg.webp` — the
* art sits in the post folder, beside that post's prose, data and diagrams.
*
* Why this exists rather than handing the master to Astro: a master is tens of
* megabytes of lossless pixels, and committing one puts it in git history
Expand All @@ -27,7 +27,7 @@ import path from "node:path";
import sharp from "sharp";

const SRC_DIR = "art-src";
const OUT_DIR = "src/assets";
const OUT_DIR = "src/posts";
/** The full-bleed stage width. Astro derives every smaller size from it. */
const DEFAULT_SIZES = [2560];
const DEFAULT_QUALITY = 90;
Expand Down Expand Up @@ -64,6 +64,16 @@ function parseArgs(argv) {

const kb = (bytes) => `${Math.round(bytes / 1024)} KB`;

function outPath(name) {
const [slug, ...rest] = name.split("/");
if (rest.length === 0) {
throw new Error(
`${name}: a master must sit in a post folder, e.g. art-src/<slug>/${slug}.png`,
);
}
return path.join(OUT_DIR, slug, "assets", ...rest);
}

const { names, quality, sizes } = parseArgs(process.argv.slice(2));

const entries = (await readdir(SRC_DIR, { recursive: true }))
Expand All @@ -89,7 +99,7 @@ console.log(`webp q${quality} · widths ${sizes.join(", ")}`);
for (const file of entries) {
const name = file.replace(MASTERS, "");
const source = path.join(SRC_DIR, file);
await mkdir(path.join(OUT_DIR, path.dirname(name)), { recursive: true });
await mkdir(path.dirname(outPath(name)), { recursive: true });
const master = await stat(source);
console.log(`\n${name} (master ${kb(master.size)})`);

Expand All @@ -104,8 +114,7 @@ for (const file of entries) {
// One width per master is the normal case, so the plain name is the file
// the posts import. A second --sizes entry gets the width appended rather
// than silently overwriting the first.
const out = path.join(
OUT_DIR,
const out = outPath(
sizes.length === 1 ? `${name}.webp` : `${name}-${width}.webp`,
);
await writeFile(out, buffer);
Expand Down
4 changes: 2 additions & 2 deletions scripts/generate-bitcoin-epochs.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* Updates src/content/bitcoin/data/epochs.json — one row per *finished* halving epoch,
* Updates src/posts/bitcoin/data/epochs.json — one row per *finished* halving epoch,
* aggregated from public history, plus the average BTC/USD needed to say what
* that epoch's own tx fees were worth at the time.
*
Expand Down Expand Up @@ -45,7 +45,7 @@ import { access, readFile, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";

const OUT = fileURLToPath(
new URL("../src/content/bitcoin/data/epochs.json", import.meta.url),
new URL("../src/posts/bitcoin/data/epochs.json", import.meta.url),
);
// The protocol's own constants — a halving every 210,000 blocks, subsidy
// halving each time from 50 BTC. Deriving epoch heights and subsidies from
Expand Down
File renamed without changes.
8 changes: 4 additions & 4 deletions src/components/HeroStage.astro
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
---
import { BitcoinCoin3D } from "@/content/bitcoin/diagrams/BitcoinCoin3D";
import BitcoinTimeline from "@/content/bitcoin/diagrams/BitcoinTimeline.astro";
import { PrologueSheet } from "@/content/prologue/PrologueSheet";
import { BitcoinCoin3D } from "@/posts/bitcoin/diagrams/BitcoinCoin3D";
import BitcoinTimeline from "@/posts/bitcoin/diagrams/BitcoinTimeline.astro";
import { PrologueSheet } from "@/posts/prologue/PrologueSheet";
import { backdropFor, type StageSummary } from "@/lib/stage";
import { cardNo } from "@/lib/fan";
import { cardNo } from "@/components/PostsFan";

interface Props {
/** One per card in the fan — every card has a stage to put up. */
Expand Down
File renamed without changes.
31 changes: 29 additions & 2 deletions src/components/PostsFan.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,35 @@ import {
stageServerSnapshot,
subscribeStage,
} from "@/lib/stage";
import { cardNo, sortCurated } from "@/lib/fan";
import { PrologueSheet } from "@/content/prologue/PrologueSheet";
import { PrologueSheet } from "@/posts/prologue/PrologueSheet";

/**
* The fan's order, and the index number that comes out of it. Exported
* because two things show that number: the raised panel above the hand, and
* the stage caption underneath it. They sit on different sides of the
* hydration line — one React island, one static Astro — so both read the
* order from here, or the same post gets two different numbers. index.astro
* sorts the one list before handing it to either.
*/
export const CURATED_ORDER: string[] = ["prologue", "bitcoin", "rust-vecdb"];

/** Anything not in CURATED_ORDER ranks after everything that is. */
const rankOf = (id: string) => {
const rank = CURATED_ORDER.indexOf(id);
return rank === -1 ? CURATED_ORDER.length : rank;
};

export function sortCurated<T extends { id: string; date: string }>(
posts: T[],
): T[] {
return [...posts].sort(
(a, b) =>
rankOf(a.id) - rankOf(b.id) ||
new Date(b.date).getTime() - new Date(a.date).getTime(),
);
}

export const cardNo = (i: number) => String(i).padStart(3, "0");

export interface PostSummary {
id: string;
Expand Down
File renamed without changes.
2 changes: 1 addition & 1 deletion src/content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { glob } from "astro/loaders";
import { z } from "astro/zod";

const posts = defineCollection({
loader: glob({ pattern: "**/*.mdx", base: "./src/content" }),
loader: glob({ pattern: "**/*.mdx", base: "./src/posts" }),
schema: ({ image }) =>
z.object({
title: z.string(),
Expand Down
30 changes: 0 additions & 30 deletions src/lib/fan.ts

This file was deleted.

2 changes: 1 addition & 1 deletion src/lib/posts.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { getCollection, type CollectionEntry } from "astro:content";

/** All posts, newest first. The fan's own order is pinned in lib/fan.ts. */
/** All posts, newest first. The fan's own order is pinned in PostsFan.tsx. */
export async function getSortedPosts(): Promise<CollectionEntry<"posts">[]> {
const posts = await getCollection("posts");
return posts.sort(
Expand Down
9 changes: 4 additions & 5 deletions src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@
import { getImage } from "astro:assets";
import BaseLayout from "../layouts/BaseLayout.astro";
import HeroStage from "../components/HeroStage.astro";
import { PostsFan } from "../components/PostsFan";
import { PostsFan, sortCurated } from "../components/PostsFan";
import { Clock } from "../components/Clock";
import { getSortedPosts } from "../lib/posts";
import { backdropFor } from "../lib/stage";
import { sortCurated } from "../lib/fan";

// The committed art is already the stage's size and format — `npm run art`
// made it that way from a master that never enters the repo — so the stage
Expand All @@ -29,9 +28,9 @@ async function cardSrc(image: ImageMetadata | undefined) {
// One list feeds both islands: every post gets a card in the fan and a stage
// behind it, so any card can be raised onto the stage. `heroImage` is the card
// thumbnail, `stageImage` the same art full-bleed. It is sorted into the fan's
// curated order here (src/lib/fan.ts) rather than inside the fan, because the
// stage caption numbers itself off the same positions and the two indexes have
// to agree.
// curated order here (sortCurated, from PostsFan) rather than inside the fan,
// because the stage caption numbers itself off the same positions and the two
// indexes have to agree.
const posts = await getSortedPosts();
const featured = posts.filter((post) => post.data.feat);
const unordered = await Promise.all(
Expand Down
File renamed without changes
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ import {
} from "@react-three/drei";
// Imported as a URL rather than served from public/ so the build fingerprints
// it and fails loudly if the file ever moves.
import modelUrl from "@/assets/bitcoin/coin.glb?url";
import modelUrl from "../assets/coin.glb?url";

/**
* A downloaded bitcoin coin model (src/assets/bitcoin/coin.glb — a Sketchfab export
* A downloaded bitcoin coin model (src/posts/bitcoin/assets/coin.glb — a Sketchfab export
* with baked baseColor/metallicRoughness/normal maps already built in, so no
* custom material work is needed) rendered with a small studio lighting rig
* for a polished-metal look. Drag to orbit the camera via OrbitControls;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
normalize,
spineY,
usdWorth,
} from "@/lib/bitcoin-timeline";
} from "../lib/bitcoin-timeline";

/**
* Placeholder for a real off-chain measurement that does not exist yet: a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
quarterTicks,
RESPONSE_LAG,
xAt,
} from "@/lib/flag-rate";
} from "../lib/flag-rate";

// Both ceilings come from the data with a little headroom — a fixed one
// silently flat-tops the tallest spike, which is the one column that matters.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
poolShare,
segments,
yearTicks,
} from "@/lib/flagged-blocks";
} from "../lib/flagged-blocks";

const PLOT_HEIGHT = 168;
const accent = "var(--accent,var(--color-signal-500))";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
topBlocks,
Y_DECADES,
type FlaggedBlock,
} from "@/lib/offchain-revenue";
} from "../lib/offchain-revenue";

// The viewBox is sized close to the width this figure actually renders at
// inside the prose measure, so the 9-unit labels land near 9 real pixels
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import { FLAG_ROWS } from "@/lib/pool-flag-sample";
import { FLAG_ROWS } from "../lib/pool-flag-sample";
import {
buildHeatmap,
formatBlocks,
Expand All @@ -8,7 +8,7 @@ import {
shade,
type Cell,
type Series,
} from "@/lib/pool-flag-rate";
} from "../lib/pool-flag-rate";

interface Hover {
pool: string;
Expand Down
6 changes: 2 additions & 4 deletions src/content/bitcoin/index.mdx → src/posts/bitcoin/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description: "Pools, farms and miners"
date: "2026-07-28"
feat: true
tags: ["bitcoin", "crypto", "cryptocurrency"]
stageImage: "../../assets/bitcoin/bg.webp"
stageImage: "./assets/bg.webp"
color: "#F7941D"
stageTone: "dark"
---
Expand All @@ -15,8 +15,6 @@ import FlaggedBlocks from "./diagrams/FlaggedBlocks.astro";
import PoolFlagHeatmap from "./diagrams/PoolFlagHeatmap.astro";
import { OffchainRevenue } from "./diagrams/OffchainRevenue";



Bitcoin's subsidy havles every 210000 blocks(~4 years). By the time of 33 halving(~year 2140), it will shrink to 0. Then transaction fee will be the only compensation for maintaining the network.

How does transaction fee look like?
Expand All @@ -33,7 +31,7 @@ if we omit subsidy:

## offchain revenue

Meanwhile, let's look at offchain revenue. mempool.space provides services publish acceleration data, records accelerated txs via its router.
Meanwhile, let's look at offchain revenue. mempool.space provides services publish acceleration data, records accelerated txs via its router.

I paid $120 to accelerate a tx 8ab712793f52be691bfd70a971d30634168e3b71d0473bccab5f6486062c79be
$120 was worth of ~155000 sats and the fee delta is 30000 sats. The actual fee boost is 0 since the tx has paid above the block's rate.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import {
subsidyWorth,
usdWorth,
type Bar,
} from "@/lib/bitcoin-timeline";
} from "./bitcoin-timeline.ts";

const BAR = (over: Partial<Bar> = {}): Bar => ({
startHeight: 240_000,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
* of history — which also means nothing here is fetched at runtime, and no
* figure on the chart can be unavailable.
*/
import barData from "@/content/bitcoin/data/bars.json";
import barData from "../data/bars.json";

export interface Bar {
/** The first block this bar covers; it covers `BLOCKS_PER_BAR` of them. */
Expand Down
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
MONTH,
monthFraction,
monthStats,
} from "@/lib/offchain-revenue";
} from "./offchain-revenue.ts";

test("every row matches the formula the figure prints", () => {
for (const b of MONTH.blocks) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
* listed; `totalBlocks` and `blocksPerDay` carry the blocks that were not
* flagged, which is what the coverage rail is drawn from.
*/
import data from "@/content/bitcoin/data/offchain-revenue-jan2023.json";
import data from "../data/offchain-revenue-jan2023.json";

export interface FlaggedBlock {
/** Block height. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
subsidyAt,
supplyAfter,
supplyAt,
} from "@/lib/bitcoin-timeline";
} from "./bitcoin-timeline.ts";

test("a year is the bar count the period totals are annualized by", () => {
assert.equal(BARS_PER_YEAR, BLOCKS_PER_YEAR / BLOCKS_PER_BAR);
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@ color: "#d72d1a"
stageTone: "light"
---

AI freed me from lots of work, now I'm able to build many things end to end.(While being careful not to let cognition be eaten)
AI freed me from lots of work, now I'm able to build many things end to end.(While being careful not to let cognition be eaten)