From f96d54ab30035a51c51f4d8c9a3cfe44c400e3ae Mon Sep 17 00:00:00 2001 From: 37ng Date: Tue, 8 Sep 2026 09:57:52 -0700 Subject: [PATCH 1/2] refactor: rename content to posts and move every post-owned file into it A post's files were spread over three trees: prose and data in src/content//, stage art and 3D models in src/assets//, and the logic its figures read in src/lib/ mixed in with the site-wide modules. Nothing said which lib file belonged to which post. Now one folder holds all of it: src/posts/bitcoin/ index.mdx assets/ bg.webp, coin.glb (was src/assets/bitcoin/) data/ bars.json, epochs.json, ... diagrams/ BitcoinTimeline.tsx, ... lib/ bitcoin-timeline.ts, flag-rate.ts, flagged-blocks.ts, offchain-revenue.ts, pool-flag-rate.ts, pool-flag-sample.ts, sha256.ts and their tests src/lib/ keeps only what the whole site uses: fan.ts, posts.ts, stage.ts. src/assets/ is gone. The import rule follows from the layout: a post's own files are relative (../lib/bitcoin-timeline, ../data/bars.json, ../assets/coin.glb?url), and @/ is only for site-wide modules. HeroStage.astro still reaches into the post folder for the coin and the timeline, now as @/posts/bitcoin/diagrams/. Two things outside the move had to follow it: generate-art.mjs mirrored art-src/ into src/assets/, but the slug folder now sits before assets/, so a plain prefix swap would not do. It maps art-src//x.png to src/posts//assets/x.webp, and a master with no slug folder is an error rather than a file written somewhere no post reads. The test glob src/**/*.test.ts was unquoted, so sh expanded it to one level and matched src/lib/ only. The tests now sit four levels deep. Quoting it hands the glob to node, which walks the tree. Verified: 39/39 tests pass, astro check reports 0 errors, the build emits all three pages with the fingerprinted coin.glb and bg.webp, and the dev server renders the homepage art, the coin and all five figures. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 16 ++++++------ package.json | 4 +-- scripts/generate-art.mjs | 23 ++++++++++++------ scripts/generate-bitcoin-epochs.mjs | 4 +-- src/components/HeroStage.astro | 6 ++--- src/components/PostsFan.tsx | 2 +- src/content.config.ts | 2 +- .../bitcoin => posts/bitcoin/assets}/bg.webp | Bin .../bitcoin => posts/bitcoin/assets}/coin.glb | Bin src/{content => posts}/bitcoin/data/bars.json | 0 .../bitcoin/data/epochs.json | 0 .../bitcoin/data/mempool-space-by-month.json | 0 .../bitcoin/data/mempool-space-by-pool.json | 0 .../data/offchain-revenue-jan2023.json | 0 .../bitcoin/diagrams/BitcoinCoin3D.tsx | 4 +-- .../bitcoin/diagrams/BitcoinTimeline.astro | 0 .../bitcoin/diagrams/BitcoinTimeline.tsx | 2 +- .../bitcoin/diagrams/FlagRateMempool.astro | 0 .../bitcoin/diagrams/FlagRateMempool.tsx | 2 +- .../bitcoin/diagrams/FlaggedBlocks.astro | 0 .../bitcoin/diagrams/FlaggedBlocks.tsx | 2 +- .../bitcoin/diagrams/OffchainRevenue.tsx | 2 +- .../bitcoin/diagrams/PoolFlagHeatmap.astro | 0 .../bitcoin/diagrams/PoolFlagHeatmap.tsx | 4 +-- src/{content => posts}/bitcoin/index.mdx | 6 ++--- .../bitcoin}/lib/bitcoin-timeline.test.ts | 2 +- .../bitcoin}/lib/bitcoin-timeline.ts | 2 +- src/{ => posts/bitcoin}/lib/flag-rate.ts | 0 .../bitcoin}/lib/flagged-blocks.test.ts | 0 src/{ => posts/bitcoin}/lib/flagged-blocks.ts | 0 .../bitcoin}/lib/offchain-revenue.test.ts | 2 +- .../bitcoin}/lib/offchain-revenue.ts | 2 +- .../bitcoin}/lib/onchain-share.test.ts | 2 +- src/{ => posts/bitcoin}/lib/pool-flag-rate.ts | 0 .../bitcoin}/lib/pool-flag-sample.ts | 0 src/{ => posts/bitcoin}/lib/sha256.ts | 0 .../prologue/PrologueSheet.tsx | 0 src/{content => posts}/prologue/index.mdx | 2 +- 38 files changed, 49 insertions(+), 42 deletions(-) rename src/{assets/bitcoin => posts/bitcoin/assets}/bg.webp (100%) rename src/{assets/bitcoin => posts/bitcoin/assets}/coin.glb (100%) rename src/{content => posts}/bitcoin/data/bars.json (100%) rename src/{content => posts}/bitcoin/data/epochs.json (100%) rename src/{content => posts}/bitcoin/data/mempool-space-by-month.json (100%) rename src/{content => posts}/bitcoin/data/mempool-space-by-pool.json (100%) rename src/{content => posts}/bitcoin/data/offchain-revenue-jan2023.json (100%) rename src/{content => posts}/bitcoin/diagrams/BitcoinCoin3D.tsx (98%) rename src/{content => posts}/bitcoin/diagrams/BitcoinTimeline.astro (100%) rename src/{content => posts}/bitcoin/diagrams/BitcoinTimeline.tsx (99%) rename src/{content => posts}/bitcoin/diagrams/FlagRateMempool.astro (100%) rename src/{content => posts}/bitcoin/diagrams/FlagRateMempool.tsx (99%) rename src/{content => posts}/bitcoin/diagrams/FlaggedBlocks.astro (100%) rename src/{content => posts}/bitcoin/diagrams/FlaggedBlocks.tsx (99%) rename src/{content => posts}/bitcoin/diagrams/OffchainRevenue.tsx (99%) rename src/{content => posts}/bitcoin/diagrams/PoolFlagHeatmap.astro (100%) rename src/{content => posts}/bitcoin/diagrams/PoolFlagHeatmap.tsx (98%) rename src/{content => posts}/bitcoin/index.mdx (97%) rename src/{ => posts/bitcoin}/lib/bitcoin-timeline.test.ts (99%) rename src/{ => posts/bitcoin}/lib/bitcoin-timeline.ts (99%) rename src/{ => posts/bitcoin}/lib/flag-rate.ts (100%) rename src/{ => posts/bitcoin}/lib/flagged-blocks.test.ts (100%) rename src/{ => posts/bitcoin}/lib/flagged-blocks.ts (100%) rename src/{ => posts/bitcoin}/lib/offchain-revenue.test.ts (98%) rename src/{ => posts/bitcoin}/lib/offchain-revenue.ts (98%) rename src/{ => posts/bitcoin}/lib/onchain-share.test.ts (98%) rename src/{ => posts/bitcoin}/lib/pool-flag-rate.ts (100%) rename src/{ => posts/bitcoin}/lib/pool-flag-sample.ts (100%) rename src/{ => posts/bitcoin}/lib/sha256.ts (100%) rename src/{content => posts}/prologue/PrologueSheet.tsx (100%) rename src/{content => posts}/prologue/index.mdx (78%) diff --git a/CLAUDE.md b/CLAUDE.md index dc2082e..7d91c9e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,9 +10,9 @@ Prettier runs automatically on staged files via the `.githooks/pre-commit` hook. ## Website structure -- **The stage** (`src/components/HeroStage.astro`): one full-screen subject at a time — backdrop art, live furniture (the bitcoin coin), and the bottom-left caption, all belonging to the same post and swapping together. Every post in the fan has a stage; the prologue gets `src/content/prologue/PrologueSheet.tsx`, a sheet of manila carrying a pencil signature (the same component draws the prologue's card thumbnail in the fan, so the two can never drift — which is why it sits in the post folder even though only the stage and the fan import it, never the prose), and any other post with no `stageImage` gets a plain surface in its `stageTone` — and `index.astro` derives `light` for it rather than reading `stageTone`, since paper is always light and trusting the field there fails invisibly (white type on cream) instead of visibly. **The homepage does not scroll** — every layer is fixed to the viewport and the fan is the only way through the work; `LANDING_STAGE` in `index.astro` is what a first visit sees. Which subject is up is ``. Hovering a card is the only thing that writes it (`setStage`, `src/lib/stage.ts`) and **nothing clears it**: a stage stays up after the fan drops, and is saved to `sessionStorage` so opening a post and coming home returns you to it rather than to the landing stage. **Visibility is pure CSS, and it has to be** — the browser paints `HeroStage`'s markup long before the island hydrates, so a stage chosen in JSX shows the landing stage first and cross-fades off it on the way home. So `HeroStage.astro` renders every stage and holds no state — it is **not an island**; only `` inside it runs on the client. The per-id rules and the `` bootstrap that sets `data-stage` before the body is parsed are both **generated from the post list in `index.astro`** (adding a post needs no edit). Adding new per-stage furniture means writing another block in `HeroStage.astro` by hand: a client directive is compile-time, so it can't be looked up from a map the way the old React version did. Two traps if you touch that bootstrap: the storage key must stay in step with `stage.ts`, and it must stay one `var`-only IIFE — a view transition re-executes it in the same window, so a top-level `const` (what `define:vars` emits) throws on the second run. It also re-applies on `astro:after-swap`, since a swap replaces ``'s attributes. +- **The stage** (`src/components/HeroStage.astro`): one full-screen subject at a time — backdrop art, live furniture (the bitcoin coin), and the bottom-left caption, all belonging to the same post and swapping together. Every post in the fan has a stage; the prologue gets `src/posts/prologue/PrologueSheet.tsx`, a sheet of manila carrying a pencil signature (the same component draws the prologue's card thumbnail in the fan, so the two can never drift — which is why it sits in the post folder even though only the stage and the fan import it, never the prose), and any other post with no `stageImage` gets a plain surface in its `stageTone` — and `index.astro` derives `light` for it rather than reading `stageTone`, since paper is always light and trusting the field there fails invisibly (white type on cream) instead of visibly. **The homepage does not scroll** — every layer is fixed to the viewport and the fan is the only way through the work; `LANDING_STAGE` in `index.astro` is what a first visit sees. Which subject is up is ``. Hovering a card is the only thing that writes it (`setStage`, `src/lib/stage.ts`) and **nothing clears it**: a stage stays up after the fan drops, and is saved to `sessionStorage` so opening a post and coming home returns you to it rather than to the landing stage. **Visibility is pure CSS, and it has to be** — the browser paints `HeroStage`'s markup long before the island hydrates, so a stage chosen in JSX shows the landing stage first and cross-fades off it on the way home. So `HeroStage.astro` renders every stage and holds no state — it is **not an island**; only `` inside it runs on the client. The per-id rules and the `` bootstrap that sets `data-stage` before the body is parsed are both **generated from the post list in `index.astro`** (adding a post needs no edit). Adding new per-stage furniture means writing another block in `HeroStage.astro` by hand: a client directive is compile-time, so it can't be looked up from a map the way the old React version did. Two traps if you touch that bootstrap: the storage key must stay in step with `stage.ts`, and it must stay one `var`-only IIFE — a view transition re-executes it in the same window, so a top-level `const` (what `define:vars` emits) throws on the second run. It also re-applies on `astro:after-swap`, since a swap replaces ``'s attributes. - **Stage → post**: clicking the stage caption opens that blog post, which has richer interactive animations per concept and an optional link to the project's GitHub repo. -- **Index**: `src/components/PostsFan.tsx` (`client:load`) — a hand of cream index cards fanned along the bottom edge of the landing page. It is furniture, not an overlay: `--fan-peek` of each card stays on screen at rest, and the hand rises when the pointer reaches it (or on focus, or on tap). Every card is a link — hovering raises that post onto the stage (`setStage`), which is also what lifts the card out of the hand; the fan keeps no selection state of its own. While the hand is up, the card's title and description are written centered above it and the stage caption steps aside — same content, only the position changes, so lowering the hand is a handover rather than a reset. Both write the same `N-00x · date` index line, so the handover carries the number too. That number is the post's **position in the fan's order**, which is why `CURATED_ORDER`/`sortCurated`/`cardNo` live in `src/lib/fan.ts` and not in the island: `index.astro` sorts the one list before handing it to both, so the static caption and the hydrated panel can never number a post differently. Card **N-000** is the prologue: not a bespoke component, just `src/content/prologue/index.mdx` pinned first in `CURATED_ORDER`, same as every other post. Live per-stage furniture (the bitcoin coin) lives in `STAGE_FURNITURE` in `HeroStage.tsx`, mounted once into a fixed layer rather than inside a snap section: a card can raise it from any scroll position, and one WebGL context is enough. +- **Index**: `src/components/PostsFan.tsx` (`client:load`) — a hand of cream index cards fanned along the bottom edge of the landing page. It is furniture, not an overlay: `--fan-peek` of each card stays on screen at rest, and the hand rises when the pointer reaches it (or on focus, or on tap). Every card is a link — hovering raises that post onto the stage (`setStage`), which is also what lifts the card out of the hand; the fan keeps no selection state of its own. While the hand is up, the card's title and description are written centered above it and the stage caption steps aside — same content, only the position changes, so lowering the hand is a handover rather than a reset. Both write the same `N-00x · date` index line, so the handover carries the number too. That number is the post's **position in the fan's order**, which is why `CURATED_ORDER`/`sortCurated`/`cardNo` live in `src/lib/fan.ts` and not in the island: `index.astro` sorts the one list before handing it to both, so the static caption and the hydrated panel can never number a post differently. Card **N-000** is the prologue: not a bespoke component, just `src/posts/prologue/index.mdx` pinned first in `CURATED_ORDER`, same as every other post. Live per-stage furniture (the bitcoin coin) lives in `STAGE_FURNITURE` in `HeroStage.tsx`, mounted once into a fixed layer rather than inside a snap section: a card can raise it from any scroll position, and one WebGL context is enough. - **Header**: site label on the left, live clock on the right. No nav links. ## Blog @@ -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/content//`, holding `index.mdx`, that post's own data in `data/` (`bitcoin/data/bars.json`, `bitcoin/data/mempool-space-by-pool.json`, …) and its own figures in `diagrams/`. The collection loader globs `**/*.mdx`, so the JSON sitting beside the prose is never mistaken for an entry, and `index` is stripped from the id — `bitcoin/index.mdx` still serves `/bitcoin`. Post-scoped data is imported from there (`@/content/bitcoin/data/bars.json`) rather than from `src/lib/`, which now holds only the logic and the site-wide modules. 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//`, 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 — `fan.ts`, `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. @@ -35,19 +35,19 @@ 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/content/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/content/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/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/content/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/content/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`. +**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/content//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 (`@/content/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 `src/assets//` folder and are pulled in with a `?url` import (`import modelUrl from "@/assets/bitcoin/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. +**Interactive diagrams:** a post's own figures live in `src/posts//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. **Path alias:** `@/` maps to `src/` (used for site-wide modules like `import { cardNo } from '@/lib/fan'`; a post's own files are imported relatively instead). ## Adding content -**Images.** Masters live in `art-src/`, which is **gitignored** — a multi-megabyte master committed once is in git history forever, and a second version doubles it. `npm run art` (`scripts/generate-art.mjs`) writes one stage-width webp per master into `src/assets/`, and _that_ is what gets committed (7 MB PNG → 696 KB webp). It mirrors subfolders, so the master for a post's stage art belongs at `art-src//.png` and lands at `src/assets//.webp`, one folder per post. From there the build behaves normally: `index.astro` derives the fan's 480px card thumbnail with `getImage()`, while the stage uses the committed file as-is — re-encoding a webp that is already the right size and format buys nothing. `--quality` (default 90) and `--sizes` (default 2560) are flags on the script. +**Images.** Masters live in `art-src/`, which is **gitignored** — a multi-megabyte master committed once is in git history forever, and a second version doubles it. `npm run art` (`scripts/generate-art.mjs`) writes one stage-width webp per master into the post that owns it, and _that_ is what gets committed (7 MB PNG → 696 KB webp). The first folder under `art-src/` is the post slug, so a master at `art-src//.png` lands at `src/posts//assets/.webp`. A master with no slug folder is an error — there is no post to put it in. From there the build behaves normally: `index.astro` derives the fan's 480px card thumbnail with `getImage()`, while the stage uses the committed file as-is — re-encoding a webp that is already the right size and format buys nothing. `--quality` (default 90) and `--sizes` (default 2560) are flags on the script. -**New blog post:** Create `src/content/my-post/index.mdx` with frontmatter, and keep that post's data files and stage art in the same-named folders (`src/content/my-post/`, `src/assets/my-post/`): +**New blog post:** Create `src/posts/my-post/index.mdx` with frontmatter, and keep that post's data, figures, logic and art beside it in `src/posts/my-post/data/`, `diagrams/`, `lib/` and `assets/`: ```mdx --- diff --git a/package.json b/package.json index a1e9ab4..267f225 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/generate-art.mjs b/scripts/generate-art.mjs index 75a741f..71d826a 100644 --- a/scripts/generate-art.mjs +++ b/scripts/generate-art.mjs @@ -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 @@ -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; @@ -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}.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 })) @@ -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)})`); @@ -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); diff --git a/scripts/generate-bitcoin-epochs.mjs b/scripts/generate-bitcoin-epochs.mjs index b230679..3610111 100644 --- a/scripts/generate-bitcoin-epochs.mjs +++ b/scripts/generate-bitcoin-epochs.mjs @@ -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. * @@ -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 diff --git a/src/components/HeroStage.astro b/src/components/HeroStage.astro index 67701ea..f9cdbd2 100644 --- a/src/components/HeroStage.astro +++ b/src/components/HeroStage.astro @@ -1,7 +1,7 @@ --- -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"; diff --git a/src/components/PostsFan.tsx b/src/components/PostsFan.tsx index 67bf05e..3aa647d 100644 --- a/src/components/PostsFan.tsx +++ b/src/components/PostsFan.tsx @@ -12,7 +12,7 @@ import { subscribeStage, } from "@/lib/stage"; import { cardNo, sortCurated } from "@/lib/fan"; -import { PrologueSheet } from "@/content/prologue/PrologueSheet"; +import { PrologueSheet } from "@/posts/prologue/PrologueSheet"; export interface PostSummary { id: string; diff --git a/src/content.config.ts b/src/content.config.ts index 13c35d6..c2e42c9 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -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(), diff --git a/src/assets/bitcoin/bg.webp b/src/posts/bitcoin/assets/bg.webp similarity index 100% rename from src/assets/bitcoin/bg.webp rename to src/posts/bitcoin/assets/bg.webp diff --git a/src/assets/bitcoin/coin.glb b/src/posts/bitcoin/assets/coin.glb similarity index 100% rename from src/assets/bitcoin/coin.glb rename to src/posts/bitcoin/assets/coin.glb diff --git a/src/content/bitcoin/data/bars.json b/src/posts/bitcoin/data/bars.json similarity index 100% rename from src/content/bitcoin/data/bars.json rename to src/posts/bitcoin/data/bars.json diff --git a/src/content/bitcoin/data/epochs.json b/src/posts/bitcoin/data/epochs.json similarity index 100% rename from src/content/bitcoin/data/epochs.json rename to src/posts/bitcoin/data/epochs.json diff --git a/src/content/bitcoin/data/mempool-space-by-month.json b/src/posts/bitcoin/data/mempool-space-by-month.json similarity index 100% rename from src/content/bitcoin/data/mempool-space-by-month.json rename to src/posts/bitcoin/data/mempool-space-by-month.json diff --git a/src/content/bitcoin/data/mempool-space-by-pool.json b/src/posts/bitcoin/data/mempool-space-by-pool.json similarity index 100% rename from src/content/bitcoin/data/mempool-space-by-pool.json rename to src/posts/bitcoin/data/mempool-space-by-pool.json diff --git a/src/content/bitcoin/data/offchain-revenue-jan2023.json b/src/posts/bitcoin/data/offchain-revenue-jan2023.json similarity index 100% rename from src/content/bitcoin/data/offchain-revenue-jan2023.json rename to src/posts/bitcoin/data/offchain-revenue-jan2023.json diff --git a/src/content/bitcoin/diagrams/BitcoinCoin3D.tsx b/src/posts/bitcoin/diagrams/BitcoinCoin3D.tsx similarity index 98% rename from src/content/bitcoin/diagrams/BitcoinCoin3D.tsx rename to src/posts/bitcoin/diagrams/BitcoinCoin3D.tsx index e76152d..552f2f3 100644 --- a/src/content/bitcoin/diagrams/BitcoinCoin3D.tsx +++ b/src/posts/bitcoin/diagrams/BitcoinCoin3D.tsx @@ -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; diff --git a/src/content/bitcoin/diagrams/BitcoinTimeline.astro b/src/posts/bitcoin/diagrams/BitcoinTimeline.astro similarity index 100% rename from src/content/bitcoin/diagrams/BitcoinTimeline.astro rename to src/posts/bitcoin/diagrams/BitcoinTimeline.astro diff --git a/src/content/bitcoin/diagrams/BitcoinTimeline.tsx b/src/posts/bitcoin/diagrams/BitcoinTimeline.tsx similarity index 99% rename from src/content/bitcoin/diagrams/BitcoinTimeline.tsx rename to src/posts/bitcoin/diagrams/BitcoinTimeline.tsx index 67ca39c..bc56572 100644 --- a/src/content/bitcoin/diagrams/BitcoinTimeline.tsx +++ b/src/posts/bitcoin/diagrams/BitcoinTimeline.tsx @@ -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 diff --git a/src/content/bitcoin/diagrams/FlagRateMempool.astro b/src/posts/bitcoin/diagrams/FlagRateMempool.astro similarity index 100% rename from src/content/bitcoin/diagrams/FlagRateMempool.astro rename to src/posts/bitcoin/diagrams/FlagRateMempool.astro diff --git a/src/content/bitcoin/diagrams/FlagRateMempool.tsx b/src/posts/bitcoin/diagrams/FlagRateMempool.tsx similarity index 99% rename from src/content/bitcoin/diagrams/FlagRateMempool.tsx rename to src/posts/bitcoin/diagrams/FlagRateMempool.tsx index b9b38d5..972abeb 100644 --- a/src/content/bitcoin/diagrams/FlagRateMempool.tsx +++ b/src/posts/bitcoin/diagrams/FlagRateMempool.tsx @@ -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. diff --git a/src/content/bitcoin/diagrams/FlaggedBlocks.astro b/src/posts/bitcoin/diagrams/FlaggedBlocks.astro similarity index 100% rename from src/content/bitcoin/diagrams/FlaggedBlocks.astro rename to src/posts/bitcoin/diagrams/FlaggedBlocks.astro diff --git a/src/content/bitcoin/diagrams/FlaggedBlocks.tsx b/src/posts/bitcoin/diagrams/FlaggedBlocks.tsx similarity index 99% rename from src/content/bitcoin/diagrams/FlaggedBlocks.tsx rename to src/posts/bitcoin/diagrams/FlaggedBlocks.tsx index e09e1bd..55658dc 100644 --- a/src/content/bitcoin/diagrams/FlaggedBlocks.tsx +++ b/src/posts/bitcoin/diagrams/FlaggedBlocks.tsx @@ -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))"; diff --git a/src/content/bitcoin/diagrams/OffchainRevenue.tsx b/src/posts/bitcoin/diagrams/OffchainRevenue.tsx similarity index 99% rename from src/content/bitcoin/diagrams/OffchainRevenue.tsx rename to src/posts/bitcoin/diagrams/OffchainRevenue.tsx index 0e03af7..94bbefd 100644 --- a/src/content/bitcoin/diagrams/OffchainRevenue.tsx +++ b/src/posts/bitcoin/diagrams/OffchainRevenue.tsx @@ -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 diff --git a/src/content/bitcoin/diagrams/PoolFlagHeatmap.astro b/src/posts/bitcoin/diagrams/PoolFlagHeatmap.astro similarity index 100% rename from src/content/bitcoin/diagrams/PoolFlagHeatmap.astro rename to src/posts/bitcoin/diagrams/PoolFlagHeatmap.astro diff --git a/src/content/bitcoin/diagrams/PoolFlagHeatmap.tsx b/src/posts/bitcoin/diagrams/PoolFlagHeatmap.tsx similarity index 98% rename from src/content/bitcoin/diagrams/PoolFlagHeatmap.tsx rename to src/posts/bitcoin/diagrams/PoolFlagHeatmap.tsx index b404ca8..9a87659 100644 --- a/src/content/bitcoin/diagrams/PoolFlagHeatmap.tsx +++ b/src/posts/bitcoin/diagrams/PoolFlagHeatmap.tsx @@ -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, @@ -8,7 +8,7 @@ import { shade, type Cell, type Series, -} from "@/lib/pool-flag-rate"; +} from "../lib/pool-flag-rate"; interface Hover { pool: string; diff --git a/src/content/bitcoin/index.mdx b/src/posts/bitcoin/index.mdx similarity index 97% rename from src/content/bitcoin/index.mdx rename to src/posts/bitcoin/index.mdx index 6ebfc55..0f72ff0 100644 --- a/src/content/bitcoin/index.mdx +++ b/src/posts/bitcoin/index.mdx @@ -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" --- @@ -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? @@ -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. diff --git a/src/lib/bitcoin-timeline.test.ts b/src/posts/bitcoin/lib/bitcoin-timeline.test.ts similarity index 99% rename from src/lib/bitcoin-timeline.test.ts rename to src/posts/bitcoin/lib/bitcoin-timeline.test.ts index 74dc352..7658b03 100644 --- a/src/lib/bitcoin-timeline.test.ts +++ b/src/posts/bitcoin/lib/bitcoin-timeline.test.ts @@ -37,7 +37,7 @@ import { subsidyWorth, usdWorth, type Bar, -} from "@/lib/bitcoin-timeline"; +} from "./bitcoin-timeline.ts"; const BAR = (over: Partial = {}): Bar => ({ startHeight: 240_000, diff --git a/src/lib/bitcoin-timeline.ts b/src/posts/bitcoin/lib/bitcoin-timeline.ts similarity index 99% rename from src/lib/bitcoin-timeline.ts rename to src/posts/bitcoin/lib/bitcoin-timeline.ts index 15e81e1..aab8e11 100644 --- a/src/lib/bitcoin-timeline.ts +++ b/src/posts/bitcoin/lib/bitcoin-timeline.ts @@ -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. */ diff --git a/src/lib/flag-rate.ts b/src/posts/bitcoin/lib/flag-rate.ts similarity index 100% rename from src/lib/flag-rate.ts rename to src/posts/bitcoin/lib/flag-rate.ts diff --git a/src/lib/flagged-blocks.test.ts b/src/posts/bitcoin/lib/flagged-blocks.test.ts similarity index 100% rename from src/lib/flagged-blocks.test.ts rename to src/posts/bitcoin/lib/flagged-blocks.test.ts diff --git a/src/lib/flagged-blocks.ts b/src/posts/bitcoin/lib/flagged-blocks.ts similarity index 100% rename from src/lib/flagged-blocks.ts rename to src/posts/bitcoin/lib/flagged-blocks.ts diff --git a/src/lib/offchain-revenue.test.ts b/src/posts/bitcoin/lib/offchain-revenue.test.ts similarity index 98% rename from src/lib/offchain-revenue.test.ts rename to src/posts/bitcoin/lib/offchain-revenue.test.ts index a25d751..276c93b 100644 --- a/src/lib/offchain-revenue.test.ts +++ b/src/posts/bitcoin/lib/offchain-revenue.test.ts @@ -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) { diff --git a/src/lib/offchain-revenue.ts b/src/posts/bitcoin/lib/offchain-revenue.ts similarity index 98% rename from src/lib/offchain-revenue.ts rename to src/posts/bitcoin/lib/offchain-revenue.ts index 7d1a175..b53f9a9 100644 --- a/src/lib/offchain-revenue.ts +++ b/src/posts/bitcoin/lib/offchain-revenue.ts @@ -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. */ diff --git a/src/lib/onchain-share.test.ts b/src/posts/bitcoin/lib/onchain-share.test.ts similarity index 98% rename from src/lib/onchain-share.test.ts rename to src/posts/bitcoin/lib/onchain-share.test.ts index ec99d64..806e52b 100644 --- a/src/lib/onchain-share.test.ts +++ b/src/posts/bitcoin/lib/onchain-share.test.ts @@ -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); diff --git a/src/lib/pool-flag-rate.ts b/src/posts/bitcoin/lib/pool-flag-rate.ts similarity index 100% rename from src/lib/pool-flag-rate.ts rename to src/posts/bitcoin/lib/pool-flag-rate.ts diff --git a/src/lib/pool-flag-sample.ts b/src/posts/bitcoin/lib/pool-flag-sample.ts similarity index 100% rename from src/lib/pool-flag-sample.ts rename to src/posts/bitcoin/lib/pool-flag-sample.ts diff --git a/src/lib/sha256.ts b/src/posts/bitcoin/lib/sha256.ts similarity index 100% rename from src/lib/sha256.ts rename to src/posts/bitcoin/lib/sha256.ts diff --git a/src/content/prologue/PrologueSheet.tsx b/src/posts/prologue/PrologueSheet.tsx similarity index 100% rename from src/content/prologue/PrologueSheet.tsx rename to src/posts/prologue/PrologueSheet.tsx diff --git a/src/content/prologue/index.mdx b/src/posts/prologue/index.mdx similarity index 78% rename from src/content/prologue/index.mdx rename to src/posts/prologue/index.mdx index 069d534..0b662c0 100644 --- a/src/content/prologue/index.mdx +++ b/src/posts/prologue/index.mdx @@ -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) \ No newline at end of file +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) From aa0cf004c65ac718c7c03daf16b3c8e6454f9972 Mon Sep 17 00:00:00 2001 From: 37ng Date: Tue, 8 Sep 2026 10:11:18 -0700 Subject: [PATCH 2/2] chore: reorg --- CLAUDE.md | 8 ++++---- src/components/{ui => }/Button.tsx | 0 src/components/HeroStage.astro | 2 +- src/components/{ui => }/Panel.tsx | 0 src/components/PostsFan.tsx | 29 ++++++++++++++++++++++++++++- src/components/{ui => }/Tag.tsx | 0 src/lib/fan.ts | 30 ------------------------------ src/lib/posts.ts | 2 +- src/pages/index.astro | 9 ++++----- 9 files changed, 38 insertions(+), 42 deletions(-) rename src/components/{ui => }/Button.tsx (100%) rename src/components/{ui => }/Panel.tsx (100%) rename src/components/{ui => }/Tag.tsx (100%) delete mode 100644 src/lib/fan.ts diff --git a/CLAUDE.md b/CLAUDE.md index 7d91c9e..f1f81c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ Prettier runs automatically on staged files via the `.githooks/pre-commit` hook. - **The stage** (`src/components/HeroStage.astro`): one full-screen subject at a time — backdrop art, live furniture (the bitcoin coin), and the bottom-left caption, all belonging to the same post and swapping together. Every post in the fan has a stage; the prologue gets `src/posts/prologue/PrologueSheet.tsx`, a sheet of manila carrying a pencil signature (the same component draws the prologue's card thumbnail in the fan, so the two can never drift — which is why it sits in the post folder even though only the stage and the fan import it, never the prose), and any other post with no `stageImage` gets a plain surface in its `stageTone` — and `index.astro` derives `light` for it rather than reading `stageTone`, since paper is always light and trusting the field there fails invisibly (white type on cream) instead of visibly. **The homepage does not scroll** — every layer is fixed to the viewport and the fan is the only way through the work; `LANDING_STAGE` in `index.astro` is what a first visit sees. Which subject is up is ``. Hovering a card is the only thing that writes it (`setStage`, `src/lib/stage.ts`) and **nothing clears it**: a stage stays up after the fan drops, and is saved to `sessionStorage` so opening a post and coming home returns you to it rather than to the landing stage. **Visibility is pure CSS, and it has to be** — the browser paints `HeroStage`'s markup long before the island hydrates, so a stage chosen in JSX shows the landing stage first and cross-fades off it on the way home. So `HeroStage.astro` renders every stage and holds no state — it is **not an island**; only `` inside it runs on the client. The per-id rules and the `` bootstrap that sets `data-stage` before the body is parsed are both **generated from the post list in `index.astro`** (adding a post needs no edit). Adding new per-stage furniture means writing another block in `HeroStage.astro` by hand: a client directive is compile-time, so it can't be looked up from a map the way the old React version did. Two traps if you touch that bootstrap: the storage key must stay in step with `stage.ts`, and it must stay one `var`-only IIFE — a view transition re-executes it in the same window, so a top-level `const` (what `define:vars` emits) throws on the second run. It also re-applies on `astro:after-swap`, since a swap replaces ``'s attributes. - **Stage → post**: clicking the stage caption opens that blog post, which has richer interactive animations per concept and an optional link to the project's GitHub repo. -- **Index**: `src/components/PostsFan.tsx` (`client:load`) — a hand of cream index cards fanned along the bottom edge of the landing page. It is furniture, not an overlay: `--fan-peek` of each card stays on screen at rest, and the hand rises when the pointer reaches it (or on focus, or on tap). Every card is a link — hovering raises that post onto the stage (`setStage`), which is also what lifts the card out of the hand; the fan keeps no selection state of its own. While the hand is up, the card's title and description are written centered above it and the stage caption steps aside — same content, only the position changes, so lowering the hand is a handover rather than a reset. Both write the same `N-00x · date` index line, so the handover carries the number too. That number is the post's **position in the fan's order**, which is why `CURATED_ORDER`/`sortCurated`/`cardNo` live in `src/lib/fan.ts` and not in the island: `index.astro` sorts the one list before handing it to both, so the static caption and the hydrated panel can never number a post differently. Card **N-000** is the prologue: not a bespoke component, just `src/posts/prologue/index.mdx` pinned first in `CURATED_ORDER`, same as every other post. Live per-stage furniture (the bitcoin coin) lives in `STAGE_FURNITURE` in `HeroStage.tsx`, mounted once into a fixed layer rather than inside a snap section: a card can raise it from any scroll position, and one WebGL context is enough. +- **Index**: `src/components/PostsFan.tsx` (`client:load`) — a hand of cream index cards fanned along the bottom edge of the landing page. It is furniture, not an overlay: `--fan-peek` of each card stays on screen at rest, and the hand rises when the pointer reaches it (or on focus, or on tap). Every card is a link — hovering raises that post onto the stage (`setStage`), which is also what lifts the card out of the hand; the fan keeps no selection state of its own. While the hand is up, the card's title and description are written centered above it and the stage caption steps aside — same content, only the position changes, so lowering the hand is a handover rather than a reset. Both write the same `N-00x · date` index line, so the handover carries the number too. That number is the post's **position in the fan's order**, which is why `CURATED_ORDER`/`sortCurated`/`cardNo` are exported from `PostsFan.tsx` and `index.astro` sorts the one list _before_ handing it to both the fan and the stage — the static caption and the hydrated panel read the same positions, so they can never number a post differently. `HeroStage.astro` imports `cardNo` from the island's module for that reason; it is the only thing the static stage takes from it. Card **N-000** is the prologue: not a bespoke component, just `src/posts/prologue/index.mdx` pinned first in `CURATED_ORDER`, same as every other post. Live per-stage furniture (the bitcoin coin) lives in `STAGE_FURNITURE` in `HeroStage.tsx`, mounted once into a fixed layer rather than inside a snap section: a card can raise it from any scroll position, and one WebGL context is enough. - **Header**: site label on the left, live clock on the right. No nav links. ## Blog @@ -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//`, 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 — `fan.ts`, `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//`, 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. **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. @@ -41,7 +41,7 @@ Each blog is an MDX file that combines article content with interactive illustra **Interactive diagrams:** a post's own figures live in `src/posts//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. -**Path alias:** `@/` maps to `src/` (used for site-wide modules like `import { cardNo } from '@/lib/fan'`; a post's own files are imported relatively instead). +**Path alias:** `@/` maps to `src/` (used for site-wide modules like `import { backdropFor } from '@/lib/stage'`; a post's own files are imported relatively instead). ## Adding content @@ -60,7 +60,7 @@ tags: ["tag1"] Import React components at the top of the MDX file and hydrate with `client:load` where needed. -**New UI primitive:** Follow the pattern in `components/ui/Button.tsx` — typed variant maps, no data fetching, Tailwind token classes only. +**New UI primitive:** Follow the pattern in `components/Button.tsx` — typed variant maps, no data fetching, Tailwind token classes only. **Fonts:** Fraunces (variable, `opsz`/`SOFT`/`WONK` axes), Inter, and JetBrains Mono are loaded via a Google Fonts `` in `BaseLayout.astro`'s `` — no `@fontsource/*` packages installed. diff --git a/src/components/ui/Button.tsx b/src/components/Button.tsx similarity index 100% rename from src/components/ui/Button.tsx rename to src/components/Button.tsx diff --git a/src/components/HeroStage.astro b/src/components/HeroStage.astro index f9cdbd2..2b15b04 100644 --- a/src/components/HeroStage.astro +++ b/src/components/HeroStage.astro @@ -3,7 +3,7 @@ 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. */ diff --git a/src/components/ui/Panel.tsx b/src/components/Panel.tsx similarity index 100% rename from src/components/ui/Panel.tsx rename to src/components/Panel.tsx diff --git a/src/components/PostsFan.tsx b/src/components/PostsFan.tsx index 3aa647d..52f4232 100644 --- a/src/components/PostsFan.tsx +++ b/src/components/PostsFan.tsx @@ -11,9 +11,36 @@ import { stageServerSnapshot, subscribeStage, } from "@/lib/stage"; -import { cardNo, sortCurated } from "@/lib/fan"; 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( + 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; title: string; diff --git a/src/components/ui/Tag.tsx b/src/components/Tag.tsx similarity index 100% rename from src/components/ui/Tag.tsx rename to src/components/Tag.tsx diff --git a/src/lib/fan.ts b/src/lib/fan.ts deleted file mode 100644 index 17a86e4..0000000 --- a/src/lib/fan.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * The fan's order, and the index number that comes out of it. - * - * Shared rather than owned by PostsFan because two things now show that - * number: the raised panel above the hand, and the stage caption underneath - * it. They are different components on different sides of the hydration line — - * one React island, one static Astro — so the order has to live somewhere both - * can reach, or the same post gets two different numbers. - */ - -/** Curated order for the fan — edit this to reorder. Anything not listed falls in after, newest first. */ -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( - 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"); diff --git a/src/lib/posts.ts b/src/lib/posts.ts index f51f980..5641214 100644 --- a/src/lib/posts.ts +++ b/src/lib/posts.ts @@ -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[]> { const posts = await getCollection("posts"); return posts.sort( diff --git a/src/pages/index.astro b/src/pages/index.astro index 3333853..8b3f7b4 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -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 @@ -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(