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
10 changes: 7 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Each blog is an MDX file that combines article content with interactive illustra

**Routing:** File-based. `src/pages/index.astro` is the homepage. `src/pages/blog/[slug].astro` is the dynamic blog route — it calls `getStaticPaths()` to pre-render one page per MDX post at build time.

**Content:** One folder per post under `src/posts/<slug>/`, holding `index.mdx`, that post's own data in `data/`, its own figures in `diagrams/`, the logic those figures read in `lib/`, and its stage art and 3D models in `assets/`. Everything a post owns is inside its folder, and nothing else reaches in except the stage. The collection loader globs `**/*.mdx`, so the JSON and TypeScript sitting beside the prose is never mistaken for an entry, and `index` is stripped from the id — `bitcoin/index.mdx` still serves `/bitcoin`. A post's own files are imported relatively (`../data/bars.json`, `../lib/bitcoin-timeline`), never through `@/`. `src/lib/` now holds only the site-wide modules — `posts.ts`, `stage.ts`. The schema (title, description, date, tags) is enforced in `src/content.config.ts` via Zod. Posts can import and render any React component inline.
**Content:** One folder per post under `src/posts/<slug>/`, holding `index.mdx`, that post's own data in `data/`, its own figures in `diagrams/`, the logic those figures read in `lib/`, and its stage art and 3D models in `assets/`. Everything a post owns is inside its folder, and nothing else reaches in except the stage. The collection loader globs `**/*.mdx`, so the JSON and TypeScript sitting beside the prose is never mistaken for an entry, and `index` is stripped from the id — `bitcoin/index.mdx` still serves `/bitcoin`. A post's own files are imported relatively (`../data/pseudo-bars.json`, `../lib/bitcoin-timeline`), never through `@/`. `src/lib/` now holds only the site-wide modules — `posts.ts`, `stage.ts`. The schema (title, description, date, tags) is enforced in `src/content.config.ts` via Zod. Posts can import and render any React component inline.

**Per-post color — two axes, deliberately separate.** `color` in frontmatter is **identity**: one custom hue per post, free to be loud, never required to be legible over anything. It drives `--hero-accent` (the fan's `N-00x · date` line, the stage caption's hover) and the post page's own `--accent`. `stageTone: "dark" | "light"` is **legibility**: it declares whether that post's hero art is dark or light, and selects a whole set of readable tones — `--stage-title` and `--stage-body` for the caption, and the same `--stage-title` for the header chrome (site label + clock), which sits directly on the art. Both are emitted as per-id rules generated in `index.astro`; the light pair is named in `global.css` (`--stage-title-light`/`--stage-body-light`) because Tailwind drops any `@theme` token that nothing in that stylesheet references, and the generated CSS is invisible to it. A post that sets neither gets signal orange and the dark tones.

Expand All @@ -35,9 +35,13 @@ Each blog is an MDX file that combines article content with interactive illustra

**Design language:** "Engineering plan set / spec sheet." Cold blue-slate `ink` neutrals + one oxide-orange `signal` accent (`#c2410c`-family) — signal appears only on rules, indices, and hover states, never on body text. Mono (`font-mono`, JetBrains Mono) carries all UI chrome — nav, labels, dates — uppercase with wide tracking (enforced globally by a `.font-mono` rule in `global.css`, not per-component). Fraunces drives its variable axes (`opsz`/`SOFT`/`WONK`) on every heading, sitewide, not just in prose. Zero radius everywhere (`--radius-*: 0`); panels use hairline rules (`shadow-panel` token), not blurred drop shadows. Interactions are mechanical — no opacity fades or translate-Y lifts; see `.spec-card` (hard offset border, print-misregistration style) and `.nav-index` (mono index number that shutters open, not fades in) in `global.css`. Apply `prose-tech` class to article content for the blog prose styles — includes auto-numbered `§01`/`§02` section counters on `h2`. Every `h2`/`h3` also gets a hover-revealed `#` permalink (`.heading-anchor`, injected at build time by `rehype-autolink-headings` in `astro.config.mjs`).

**Onchain revenue timeline** (`src/posts/bitcoin/diagrams/BitcoinTimeline.tsx`, on the bitcoin stage and in the post): one bar per period, and every figure is a **period total** — what the chain earned over the whole bar, roughly a month — never an average per block, since dividing by however many blocks were mined is the one part of the question nobody asked. The bar is **tx fees only**, priced in **dollars of the day**: a fee in BTC says nothing across seventeen years, because the unit itself changed value ten-thousandfold along the axis it is plotted against, so each row carries its own BTC/USD and a 2012 month is priced at 2012 rates. The x axis is **block height, not the calendar**: one bar per **4,375 blocks**, which is 210,000 / 48, so a halving is always a bar _edge_ and never a point inside a bar (a calendar month would drift across boundaries). Subsidy is never stored — `subsidyAt(height)` is `50 / 2 ** floor(h / 210_000)`, and `subsidyBtcOverBar` scales it to the bar so it can be added to a period total — and neither is the dollar figure, which is one multiplication away from the price each row carries (`usdWorth`). The log scale is floored at a dollar (`MIN_USD`) — a 2009 month of fees is worth hundred-thousandths of a cent, and letting that end in stretches the scale over thirteen orders and pins everything after 2012 to the top. `src/posts/bitcoin/data/bars.json` is **pseudo data**: the heights are exact and the month labels follow the real halving dates, but the fees and the price are invented. There is **no generator script** for it — replace the file with real per-bar measurements. Reading is by hover only: the bar under the pointer is redrawn in the accent — one mark, the same colour as the halving tick and the axis cursor — and the cursor stays where the pointer left rather than snapping back to the last bar. It also carries the **annualized percentage**: a year of that bar's pay over every coin issued (`onchainShare`, annualized by `BARS_PER_YEAR` since the rows are period totals), with `supplyAt` walking the halving schedule the way `subsidyAt` does and `supplyAfter` supplying an end-of-bar denominator that is never zero. Both sides are BTC, so the price cancels and that column owes nothing to the price beside it. Nothing animates, and nothing is fetched at runtime (an earlier version grew the bars in on a `requestAnimationFrame`, which never fires in a hidden tab and left the chart at `scaleY(0)`).
**Onchain revenue timeline** (`src/posts/bitcoin/diagrams/BitcoinTimeline.tsx`, on the bitcoin stage and in the post): one bar per period, and every figure is a **period total** — what the chain earned over the whole bar, roughly a month — never an average per block, since dividing by however many blocks were mined is the one part of the question nobody asked. The bar is **tx fees only**, priced in **dollars of the day**: a fee in BTC says nothing across seventeen years, because the unit itself changed value ten-thousandfold along the axis it is plotted against, so each row carries its own BTC/USD and a 2012 month is priced at 2012 rates. The x axis is **block height, not the calendar**: one bar per **4,375 blocks**, which is 210,000 / 48, so a halving is always a bar _edge_ and never a point inside a bar (a calendar month would drift across boundaries). Subsidy is never stored — `subsidyAt(height)` is `50 / 2 ** floor(h / 210_000)`, and `subsidyBtcOverBar` scales it to the bar so it can be added to a period total — and neither is the dollar figure, which is one multiplication away from the price each row carries (`usdWorth`). The log scale is floored at a dollar (`MIN_USD`) — a 2009 month of fees is worth hundred-thousandths of a cent, and letting that end in stretches the scale over thirteen orders and pins everything after 2012 to the top. `src/posts/bitcoin/data/pseudo-bars.json` is **pseudo data**: the heights are exact and the month labels follow the real halving dates, but the fees and the price are invented. There is **no generator script** for it — replace the file with real per-bar measurements. Reading is by hover only: the bar under the pointer is redrawn in the accent — one mark, the same colour as the halving tick and the axis cursor — and the cursor stays where the pointer left rather than snapping back to the last bar. It also carries the **annualized percentage**: a year of that bar's pay over every coin issued (`onchainShare`, annualized by `BARS_PER_YEAR` since the rows are period totals), with `supplyAt` walking the halving schedule the way `subsidyAt` does and `supplyAfter` supplying an end-of-bar denominator that is never zero. Both sides are BTC, so the price cancels and that column owes nothing to the price beside it. Nothing animates, and nothing is fetched at runtime (an earlier version grew the bars in on a `requestAnimationFrame`, which never fires in a hidden tab and left the chart at `scaleY(0)`).

**Implied off-chain revenue figure** (`src/posts/bitcoin/diagrams/OffchainRevenue.tsx`, in the post): one mark per block that mined a transaction paying under its own floor feerate, `(floor_B − actual) × vsize` on a log scale, with a per-day coverage rail underneath for the blocks that carried nothing. The numbers in `src/posts/bitcoin/data/offchain-revenue-jan2023.json` are **pseudo data** — a deterministic stand-in written by `npm run offchain-pseudo`, there so the figure could be built before the real measurements exist. Real data replaces that one file and nothing else; the generator script then goes away. Two things the generator has to keep true, because the tooltip prints all three numbers side by side: `impliedSats` is derived from the _rounded_ feerates, so a reader who multiplies them out lands on the printed figure; and only flagged blocks are listed, with `totalBlocks`/`blocksPerDay` carrying the rest (the rail's denominator). Marks are painted through `.chart-mark` in `global.css` rather than a token utility — a post's `color` is chosen for identity, not contrast, so on the light theme's paper the figure swaps to `--accent-dark`.
**Data files, real vs invented.** Anything under a post's `data/` that is not a measurement carries a `pseudo-` filename prefix — today `pseudo-bars.json` and `pseudo-offchain-revenue-jan2023.json`. The prefix is the marker, so a file's status is visible in an import line and in a directory listing, not only in a note inside it. Replacing one with real numbers means dropping the prefix and updating the import.

**Monthly BTC/USD** (`src/posts/bitcoin/data/price.json`): real data, written by `npm run bitcoin-price` (`scripts/generate-bitcoin-price.mjs`). The shape is flat on purpose — `{ "yyyy-mm": avgUsd }`, nothing else — so a figure that needs a price for a month does one lookup and owes nothing to whatever else that month's row might have carried. The average is over the daily closes of that month, from blockchain.info's `market-price` chart (cross-checked against CoinGecko: the two agree to about 0.1%). The series runs from **2010-08**, the first month with a traded price, to the last finished month; the source reports every earlier day as `0`, and those days are dropped rather than averaged in, since a zero is "no exchange yet", not a price. Figures must therefore treat a missing month as missing, never as free. Values are kept to six significant figures — enough for both a $0.07 month and a $100k one. **Only finished months are written.** A month is averaged once the source covers its final calendar day; the month in progress is skipped, because a part-month average would be wrong until the next run and nothing marks it as provisional. Completeness is read off the data (the last day the series carries), not off the clock, so a source that lags a few days is handled too. That is also what makes the script **append rather than rebuild**: every committed month is final, so it reads the committed file and asks the source only for `start=<month after the newest committed one>-01`, and never refetches what it already has. A fetch that fails leaves the committed file alone and exits 0 (a network hiccup in CI just ships the last good data); with no committed file to fall back on it is a real error and throws. The script is not wired into `prebuild`: a finished month never changes, and the open month would go stale between deploys anyway.

**Implied off-chain revenue figure** (`src/posts/bitcoin/diagrams/OffchainRevenue.tsx`, in the post): one mark per block that mined a transaction paying under its own floor feerate, `(floor_B − actual) × vsize` on a log scale, with a per-day coverage rail underneath for the blocks that carried nothing. The numbers in `src/posts/bitcoin/data/pseudo-offchain-revenue-jan2023.json` are **pseudo data** — a deterministic stand-in written by `npm run offchain-pseudo`, there so the figure could be built before the real measurements exist. Real data replaces that one file and nothing else; the generator script then goes away. Two things the generator has to keep true, because the tooltip prints all three numbers side by side: `impliedSats` is derived from the _rounded_ feerates, so a reader who multiplies them out lands on the printed figure; and only flagged blocks are listed, with `totalBlocks`/`blocksPerDay` carrying the rest (the rail's denominator). Marks are painted through `.chart-mark` in `global.css` rather than a token utility — a post's `color` is chosen for identity, not contrast, so on the light theme's paper the figure swaps to `--accent-dark`.

**Interactive diagrams:** a post's own figures live in `src/posts/<slug>/diagrams/`, beside the prose and the data they read, so the MDX imports them relatively (`./diagrams/BitcoinTimeline.astro`) and `src/components/` keeps only what the whole site uses. They are heavier React components (e.g. `BitcoinCoin3D.tsx`, `three` + `@react-three/fiber`) and must be hydrated explicitly at the call site — `client:load`/`client:visible` in MDX posts, `client:idle` for the stage furniture in `HeroStage.astro`. A diagram with two homes gets a thin `.astro` mount point beside it (`BitcoinTimeline.astro`) so the hydration directive stays one decision; that is also the only reason `HeroStage.astro` reaches across into a post folder (`@/posts/bitcoin/diagrams/…`) — the coin and the timeline belong to the bitcoin post, the stage only borrows them. Their binary payloads live in that post's own `assets/` folder and are pulled in with a relative `?url` import (`import modelUrl from "../assets/coin.glb?url"`), not from `public/`: the build then fingerprints the file for long-term caching and fails at compile time if it is ever moved or renamed, where a `public/` path only 404s at runtime.

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +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/posts/bitcoin/data/offchain-revenue-jan2023.json",
"bitcoin-price": "node scripts/generate-bitcoin-price.mjs",
"offchain-pseudo": "node scripts/generate-offchain-pseudo.mjs src/posts/bitcoin/data/pseudo-offchain-revenue-jan2023.json",
"test": "tsx --test \"src/**/*.test.ts\""
},
"dependencies": {
Expand Down
110 changes: 110 additions & 0 deletions scripts/generate-bitcoin-price.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
#!/usr/bin/env node
import { readFile, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";

const OUT = fileURLToPath(
new URL("../src/posts/bitcoin/data/price.json", import.meta.url),
);
const CHART = "https://api.blockchain.info/charts/market-price";
const MONTH = /^\d{4}-\d{2}$/;

function nextMonth(month) {
const [year, index] = month.split("-").map(Number);
return index === 12
? `${year + 1}-01`
: `${year}-${String(index + 1).padStart(2, "0")}`;
}

function lastDayOf(month) {
const [year, index] = month.split("-").map(Number);
return new Date(Date.UTC(year, index, 0)).toISOString().slice(0, 10);
}

async function fetchDailyPrices(fromMonth) {
const range = fromMonth ? `start=${fromMonth}-01` : "timespan=all";
const url = `${CHART}?${range}&format=csv&sampled=false`;
const res = await fetch(url);
if (!res.ok) throw new Error(`${url} -> HTTP ${res.status}`);
return (await res.text())
.trim()
.split("\n")
.filter(Boolean)
.map((line) => {
const [stamp, value] = line.split(",");
return { day: stamp.slice(0, 10), usd: Number(value) };
})
.filter((row) => /^\d{4}-\d{2}-\d{2}$/.test(row.day));
}

function completeMonthlyAverages(days) {
if (days.length === 0) return {};
// A month is only averaged once the source covers its final calendar day.
// The month in progress would otherwise be written as a part-month average
// and then be wrong until the next run.
const covered = days[days.length - 1].day;
const buckets = new Map();
// Days before the first exchange quote are reported as 0, not as missing.
// Averaging them in would drag a month below what it ever traded at.
for (const { day, usd } of days) {
if (!Number.isFinite(usd) || usd <= 0) continue;
const month = day.slice(0, 7);
const bucket = buckets.get(month) ?? { sum: 0, n: 0 };
bucket.sum += usd;
bucket.n += 1;
buckets.set(month, bucket);
}
const out = {};
for (const month of [...buckets.keys()].sort()) {
if (lastDayOf(month) > covered) continue;
const { sum, n } = buckets.get(month);
out[month] = Number((sum / n).toPrecision(6));
}
return out;
}

async function readExisting() {
try {
const parsed = JSON.parse(await readFile(OUT, "utf8"));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
return {};
return Object.fromEntries(
Object.entries(parsed).filter(
([month, usd]) => MONTH.test(month) && Number.isFinite(usd) && usd > 0,
),
);
} catch {
return {};
}
}

const existing = await readExisting();
const known = Object.keys(existing).sort();
// Every committed month is a finished month, so the newest one is final and
// the window can start after it.
const from = known.length > 0 ? nextMonth(known.at(-1)) : undefined;

let fetched;
try {
fetched = completeMonthlyAverages(await fetchDailyPrices(from));
} catch (error) {
if (known.length === 0) throw error;
console.warn(`price.json: kept ${known.length} committed months — ${error}`);
process.exit(0);
}

const merged = { ...existing, ...fetched };
const months = Object.keys(merged).sort();
if (months.length === 0) throw new Error("no complete month in source series");

const added = months.filter((month) => !(month in existing));
await writeFile(
OUT,
JSON.stringify(
Object.fromEntries(months.map((month) => [month, merged[month]])),
null,
2,
) + "\n",
);
console.log(
`price.json: ${months.length} months, ${months[0]} to ${months.at(-1)} (+${added.length})`,
);
Loading