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: 4 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

Checkout commands in package.json.

Prettier runs automatically on staged files via the `.githooks/pre-commit` hook. No test suite is configured.
Prettier runs automatically on staged files via the `.githooks/pre-commit` hook. `npm test` runs the `*.test.ts` files beside the logic they cover; `npm run verify` is the full gate — format check, `astro check`, build, then tests.

## Website structure

Expand All @@ -31,17 +31,15 @@ Each blog is an MDX file that combines article content with interactive illustra

**Light theme — dark is blueprint, light is paper.** `ThemeToggle.tsx` sits in the post header, beside the date, and pins `<html data-theme>`, persisted to `localStorage` (a reading preference outlives a visit, unlike the homepage stage's `sessionStorage`). No component knows about it: `global.css` re-points the whole `--color-ink-*` ramp at warm paper tones, and since that ramp is a _value_ ramp (950 furthest back, 50 furthest forward) every existing `bg-ink-900`/`text-ink-300` flips at once with its contrast relationship intact. **It is scoped `html[data-theme="light"]:has(#post)`** — the homepage is full-bleed art whose legibility comes from each post's `stageTone`, and inverting the ramp under it would put near-black type on a dark photograph. `--color-ink-400` is pinned by measured contrast (4.6:1), not by eye, because it carries the date and every mono label. The `<head>` bootstrap in `BaseLayout.astro` resolves `localStorage` then `prefers-color-scheme` before the body is parsed, and the toggle ships both glyphs with CSS hiding one — picking in JSX would flip the icon at hydration, which is the flash the bootstrap exists to prevent. Same `var`-only IIFE + `astro:after-swap` rules as the stage bootstrap.

**Chart series palette:** `--series-1` … `--series-14` in `global.css`, stepped separately for dark ink and light paper. `--pool-1` … `--pool-4` are **aliases onto the same slots**, not a second palette — a pool must keep one color across every figure. `--pool-5` stays its own neutral because it is the residual bucket (`.pool-rest` hatches it), not a series. Slots 1-4 were run through the palette validator; slots 5-14 exist only because the accelerator chart has fourteen pools and are separated by hover and click as much as by hue.

**Design tokens:** All colors, fonts, and spacing live in `src/styles/global.css` under the `@theme` block (Tailwind v4 CSS-native config). Tailwind generates utility classes directly from these CSS variables — e.g. `bg-ink-900`, `text-signal-500`. Never hardcode colors in components; always use token-derived utilities.

**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/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)`).

**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`.
**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`.

**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
Loading