diff --git a/.gitignore b/.gitignore
index dfe73a5..481c192 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,10 +37,6 @@ pnpm-debug.log*
*.tar.gz
# Security-sensitive local files
-.env
-.env.*
-!.env.example
-!.env.sample
*.pem
*.key
*.p12
diff --git a/AGENTS.md b/AGENTS.md
index 279fd3a..b056328 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -393,8 +393,18 @@ Build scripts live in:
```text
scripts-build/fetch-pulse.mjs
scripts-build/generate-grain.mjs
+scripts-build/csp-hashes.mjs
```
+`csp-hashes.mjs` runs as part of `npm run build` and rewrites
+`worker/script-hashes.json`. The Worker's `script-src` names those hashes, so
+changing any inline script without rebuilding would make the deployed CSP
+block it. The audit fails on a stale list.
+
+`generate-grain.mjs` is seeded: repeated runs must stay byte-identical.
+`public/pulse.json` is read at build time only — the Network Clock renders it
+as a snapshot and does not poll it at runtime.
+
Rules:
- Do not hand-edit generated files unless the task explicitly requires it.
diff --git a/DESIGN.md b/DESIGN.md
index 4cb9516..25937b9 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -253,10 +253,19 @@ Do not reintroduce older font systems such as Playfair Display, Lora, or DM Mono
Use the existing type tokens in `design-system.css`:
-- display sizes: `--fz-hero`, `--fz-page`, `--fz-section`, `--fz-step`, `--fz-note`, `--fz-quote`
-- fixed scale: `--text-xs` through `--text-6xl`
-- line heights: `--lh-title`, `--lh-card`, `--lh-base`, `--lh-loose`
-- tracking: `--ls-tighter`, `--ls-snug`, `--ls-normal`, `--ls-3`, `--ls-5`, `--ls-7`, `--ls-9`, `--ls-11`
+- display sizes: `--fz-page`, `--fz-section`, `--fz-step`, `--fz-note`, `--fz-quote`
+- role aliases: `--fz-xs`, `--fz-sm`, `--fz-base`, `--fz-prose`, `--fz-lg`, `--fz-xl`, `--fz-3xl`
+- fixed scale: `--text-xs` through `--text-3xl`
+- line heights: `--lh-title`, `--lh-snug`, `--lh-card`, `--lh-normal`, `--lh-relaxed`, `--lh-base`, `--lh-loose`
+- tracking: `--ls-tighter`, `--ls-snug`, `--ls-normal`, `--ls-3`, `--ls-5`, `--ls-7`
+- text colour: `--tx` (headings), `--tx-2` (body), `--tx-3` (muted)
+
+Every token above resolves to a distinct value. Aliases that duplicated an
+existing size or tracking value (`--fz-hero`, `--fz-3xs`, `--fz-md`,
+`--fz-2xl`, `--ls-9`, `--ls-11`, `--text-4xl`/`5xl`/`6xl`, `--tx-2b`) were
+removed: they implied a granularity the design did not have. If a new step is
+genuinely needed, give it a real value rather than a second name for an
+existing one.
Avoid oversized hero typography on inner pages. The site should feel archival and editorial, not like a launch-page template.
@@ -271,7 +280,7 @@ Use existing spacing and layout tokens:
```css
--max-w
--pad
---sp-1 through --sp-13
+--sp-1 through --sp-12
--radius
--radius-sm
--radius-lg
diff --git a/README.md b/README.md
index 134e729..a69da24 100644
--- a/README.md
+++ b/README.md
@@ -51,6 +51,8 @@ flowchart TD
H[Preact islands] --> B
I[Browser scripts] --> B
B --> J[Static HTML output]
+ J --> S[Inline script hashes]
+ S --> K
J --> K[Cloudflare Worker routing script]
K --> L[Cloudflare Workers static assets]
M[GitHub Actions] --> N[npm ci / npm run validate]
@@ -62,7 +64,7 @@ flowchart TD
The repository currently uses:
-- Astro 7
+- Astro 7 (a small Vite plugin drops Fontsource's legacy `.woff` fallbacks)
- `@astrojs/preact` 6
- Preact 10 islands
- TypeScript 5
@@ -73,7 +75,7 @@ The repository currently uses:
The dependency source of truth is `package.json`. Deployment details are in `astro.config.mjs`, `wrangler.jsonc`, `worker/index.js`, and `public/_headers`.
-Local and CI builds require Node.js 22.12 or newer. Astro 7 uses Vite 8 and its Rust-based compiler/bundling pipeline; the project does not depend on custom Vite plugins or legacy Markdown processors.
+Local and CI builds require Node.js 22.12 or newer. Astro 7 uses Vite 8 and its Rust-based compiler/bundling pipeline. The only custom Vite plugin is the woff2-only transform in `astro.config.mjs`; there are no legacy Markdown processors.
## Project Structure
@@ -106,7 +108,8 @@ src/data/site.json Site identity and origin configuration
src/layouts/Base.astro Shared page shell and metadata
src/components/Nav.astro Main navigation
src/lib/seo.ts SEO metadata helpers
-worker/index.js Canonical host and legacy sitemap redirects before static assets
+worker/index.js Canonical host redirects, security headers, cache policy
+worker/script-hashes.json Generated CSP hashes for the build's inline scripts
```
## Content Model
@@ -188,10 +191,12 @@ Time-sensitive Toolkit entries include a review date and source label. Objection
- latest block-height fallback
- `public/pulse.json` network snapshot
-- `public/grain.png` texture asset
+- `public/grain.png` texture asset (seeded, so repeated runs are byte-identical)
The refresh path should be defensive. If a live source fails, the site should preserve a cached or fallback value rather than fail unnecessarily.
+`public/pulse.json` is a build-time artifact, and the Network Clock renders it as one. The clock does not poll: the file cannot change between deploys, so a poll could only ever re-fetch an identical body. Run `refresh-data` and redeploy to move the numbers.
+
## Development
Install dependencies:
@@ -288,9 +293,11 @@ npm ci
npm run validate
```
-The audit runs against the generated `dist` output. It checks the sitemap route set, document metadata, one-H1 structure, internal routes and fragments, duplicate IDs, selected interactive/accessibility contracts, generated-data freshness, and static asset budgets. Playwright then checks the mobile menu, resource filtering, and the Frame 2 no-JavaScript experience in Chrome. Freshness fallback states are reported as warnings so a temporary upstream outage does not make a static build unavailable.
+The audit runs against the generated `dist` output. It checks the sitemap route set, document metadata, one-H1 structure, internal routes and fragments, duplicate IDs, selected interactive/accessibility contracts, generated-data freshness, static asset budgets, CSP script-hash freshness, that no legacy `.woff` is shipped, that inline prose links keep their underline affordance, and that the resource-filter script only reaches pages that render filters. Playwright then checks the mobile menu, resource filtering, and the Frame 2 no-JavaScript experience in Chrome. Freshness fallback states are reported as warnings so a temporary upstream outage does not make a static build unavailable.
+
+The production build outputs static assets from Astro. Cloudflare deployment is configured through Wrangler using the `dist` directory as the static assets source. `worker/index.js` runs before assets to redirect `www.bitcoinmind.com` to the apex domain and route legacy sitemap asset paths to the canonical sitemap. It also normalizes security headers and cache lifetimes; error responses never inherit an immutable cache rule from their path.
-The production build outputs static assets from Astro. Cloudflare deployment is configured through Wrangler using the `dist` directory as the static assets source. `worker/index.js` runs before assets to redirect `www.bitcoinmind.com` to the apex domain and route legacy sitemap asset paths to the canonical sitemap.
+`npm run build` regenerates `worker/script-hashes.json`, the sha256 of every inline script in `dist`. The Worker names those hashes in `script-src`, so `'unsafe-inline'` survives only as a fallback for browsers that predate hash support. The audit re-derives the hashes and fails if the committed list is stale — if you change an inline script, rebuild before deploying.
## Maintenance Principles
diff --git a/astro.config.mjs b/astro.config.mjs
index 3c33a8c..f45e1ac 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -2,6 +2,26 @@ import { defineConfig } from 'astro/config';
import preact from '@astrojs/preact';
import siteData from './src/data/site.json' with { type: 'json' };
+/**
+ * Fontsource ships every face as woff2 *and* legacy woff. Vite emits both
+ * because both are referenced, so the build carried ~260KB of .woff that no
+ * browser capable of running this site would ever request — woff2 has been
+ * universal since 2016, and the site already depends on `inert`, `dvh`, and
+ * ResizeObserver. Stripping the fallback before Vite reads the CSS means the
+ * files are never emitted at all, rather than deleted afterwards.
+ */
+function woff2Only() {
+ return {
+ name: 'bitcoinmind:woff2-only',
+ enforce: 'pre',
+ transform(code, id) {
+ if (!id.includes('@fontsource') || !id.includes('.css')) return null;
+ const stripped = code.replace(/,\s*url\([^)]+\.woff\)\s*format\((['"])woff\1\)/g, '');
+ return stripped === code ? null : { code: stripped, map: null };
+ },
+ };
+}
+
export default defineConfig({
site: siteData.url,
trailingSlash: 'never',
@@ -13,6 +33,7 @@ export default defineConfig({
preact({ compat: true }),
],
vite: {
+ plugins: [woff2Only()],
css: {
transformer: 'lightningcss',
lightningcss: {
diff --git a/package.json b/package.json
index fd52245..22cec7c 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
},
"scripts": {
"dev": "astro dev",
- "build": "astro build",
+ "build": "astro build && node scripts-build/csp-hashes.mjs",
"check": "astro check",
"audit": "node scripts-build/audit-site.mjs",
"test:worker": "node --test tests/worker.test.mjs",
diff --git a/public/grain.png b/public/grain.png
index 66246df..c8d88e0 100644
Binary files a/public/grain.png and b/public/grain.png differ
diff --git a/public/pulse.json b/public/pulse.json
index e1cbef3..9ccaf32 100644
--- a/public/pulse.json
+++ b/public/pulse.json
@@ -1,7 +1,6 @@
{
"hashRate": 873326117924410800000,
"mempoolCount": 83914,
- "nodeCount": 26415,
"fetchedAt": "2026-08-25T19:27:46.418Z",
"source": "snapshot",
"height": 964047
diff --git a/scripts-build/audit-site.mjs b/scripts-build/audit-site.mjs
index 1761ddc..8b722fe 100644
--- a/scripts-build/audit-site.mjs
+++ b/scripts-build/audit-site.mjs
@@ -1,8 +1,14 @@
import { readFile, readdir, stat } from "node:fs/promises";
import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { collectScriptHashes } from "./csp-hashes.mjs";
-const ROOT = new URL("../", import.meta.url).pathname;
+// fileURLToPath, not .pathname: the latter stays percent-encoded, so any
+// checkout under a directory with a space in its name resolved to a path
+// that does not exist.
+const ROOT = fileURLToPath(new URL("../", import.meta.url));
const DIST = join(ROOT, "dist");
+const assetDirectory = join(DIST, "_astro");
const siteConfig = JSON.parse(await readFile(join(ROOT, "src/data/site.json"), "utf8"));
const failures = [];
const warnings = [];
@@ -271,7 +277,7 @@ const contracts = [
["/texts", /data-filter-scope/, "text filters"],
["/toolkit", /data-filter-scope/, "toolkit filters"],
["/frames/2", /aria-labelledby="f2-chart-title f2-chart-desc"/, "accessible chart fallback"],
- ["/", /aria-modal="true"/, "welcome dialog semantics"],
+ ["/", /id="site-menu"[\s\S]{0,240}?aria-modal="true"/, "modal mobile-menu semantics"],
];
for (const [route, pattern, label] of contracts) {
if (!pattern.test(pages.get(route) ?? "")) fail(`${route}: missing ${label}`);
@@ -281,6 +287,44 @@ if (!/name="robots"\s+content="noindex, (?:no)?follow"/.test(pages.get("/404") ?
fail("/404: missing noindex directive");
}
+const builtCss = (await Promise.all(
+ (await readdir(assetDirectory)).filter((name) => name.endsWith(".css"))
+ .map((name) => read(join(assetDirectory, name))),
+)).join("\n");
+
+// Inline prose links inherit the global anchor reset, so without an explicit
+// rule they render identically to the paragraph around them.
+if (!/\.a-body a[^{]*\{[^}]*text-decoration:\s*underline/.test(builtCss)) {
+ fail("styles: inline prose links are missing their underline affordance");
+}
+
+// Fontsource emits a legacy .woff beside every .woff2; the build strips the
+// fallback so those files are never referenced or shipped.
+const legacyFonts = (await readdir(assetDirectory)).filter((name) => name.endsWith(".woff"));
+if (legacyFonts.length) fail(`performance: ${legacyFonts.length} legacy .woff font(s) shipped`);
+if (/format\((["'])woff\1\)/.test(builtCss)) fail("performance: built CSS still references legacy .woff");
+
+// The resource filter belongs only to the pages that render a filter scope.
+// It used to be an inline block in the shared layout, so every page paid for
+// it. The marker is the code's own attribute lookup, not the card markup.
+for (const [route, html] of pages) {
+ if (/data-filter-card/.test(html)) continue;
+ const shipsFilterCode = [...html.matchAll(/
diff --git a/src/components/StudyLoop.astro b/src/components/StudyLoop.astro
index a56748d..7ef9c53 100644
--- a/src/components/StudyLoop.astro
+++ b/src/components/StudyLoop.astro
@@ -46,7 +46,7 @@ const { active, compact = false } = Astro.props;
.study-loop-eye,
.study-loop-number {
font-family: var(--font-mono);
- font-size: var(--fz-3xs);
+ font-size: var(--fz-xs);
letter-spacing: var(--ls-7);
text-transform: uppercase;
}
@@ -69,7 +69,7 @@ const { active, compact = false } = Astro.props;
.study-loop-number { color: var(--gold); }
.study-loop-copy { display: flex; flex-direction: column; gap: var(--sp-1); min-width: 0; }
.study-loop-copy strong { font-size: var(--fz-sm); color: var(--tx); }
- .study-loop-copy span { font-size: var(--fz-3xs); line-height: var(--lh-card); color: var(--tx-3); }
+ .study-loop-copy span { font-size: var(--fz-xs); line-height: var(--lh-card); color: var(--tx-3); }
.study-loop--compact .study-loop-head { margin-bottom: var(--sp-3); }
@media (--viewport-layout) {
.study-loop-list { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
diff --git a/src/components/StudyNext.astro b/src/components/StudyNext.astro
index e992911..aa776e6 100644
--- a/src/components/StudyNext.astro
+++ b/src/components/StudyNext.astro
@@ -28,8 +28,8 @@ const { previous, next } = Astro.props;
.study-next-link { display: flex; flex-direction: column; gap: var(--sp-1); padding: var(--sp-4); border: 1px solid var(--bdr); border-radius: var(--radius); }
.study-next-link:hover { border-color: var(--border-accent); background: var(--surf); }
.study-next-link--next { text-align: right; }
- .study-next-link span { font-family: var(--font-mono); font-size: var(--fz-3xs); color: var(--gold); letter-spacing: var(--ls-7); text-transform: uppercase; }
- .study-next-link strong { font-size: var(--fz-md); color: var(--tx); }
+ .study-next-link span { font-family: var(--font-mono); font-size: var(--fz-xs); color: var(--gold); letter-spacing: var(--ls-7); text-transform: uppercase; }
+ .study-next-link strong { font-size: var(--fz-base); color: var(--tx); }
.study-next-link small { font-size: var(--fz-xs); color: var(--tx-3); line-height: var(--lh-card); }
@media (--viewport-sm) { .study-next { grid-template-columns: 1fr; } .study-next-link--next { text-align: left; } }
diff --git a/src/data/rabbit-holes.ts b/src/data/rabbit-holes.ts
index c479653..cecff2e 100644
--- a/src/data/rabbit-holes.ts
+++ b/src/data/rabbit-holes.ts
@@ -18,6 +18,8 @@ export interface RabbitHole {
id: string;
emoji: string;
title: string;
+ /** The question this route answers, shown on the /paths route cards. */
+ question: string;
subtitle: string;
desc: string;
difficulty: 'Beginner' | 'Intermediate' | 'Advanced';
@@ -34,6 +36,7 @@ export const RABBIT_HOLES: RabbitHole[] = [
id: 'rh-monetary',
emoji: '🏛',
title: 'The Monetary Path',
+ question: 'What is money, and why does scarcity matter?',
subtitle: 'Why societies converge on money',
desc: 'Begin before Bitcoin. Study how monetary goods emerge, how settlement and credit differ, and why scarcity matters only when people demand the other properties of money.',
difficulty: 'Beginner',
@@ -54,6 +57,7 @@ export const RABBIT_HOLES: RabbitHole[] = [
id: 'rh-technical',
emoji: '🔧',
title: 'The Technical Path',
+ question: 'How does Bitcoin actually work?',
subtitle: 'How the rules produce a shared ledger',
desc: 'Move from the original proposal to transactions, keys, proof of work, difficulty, and validation. The aim is not programming fluency; it is knowing which mechanism supports each claim.',
difficulty: 'Intermediate',
@@ -74,6 +78,7 @@ export const RABBIT_HOLES: RabbitHole[] = [
id: 'rh-verification',
emoji: '◉',
title: 'The Verification Path',
+ question: 'Which claims can you verify for yourself?',
subtitle: 'Observe less. Verify more.',
desc: 'Learn the difference between reading a dashboard, querying someone else’s node, and applying Bitcoin’s rules yourself. Verification is a spectrum of dependence, not a badge.',
difficulty: 'Intermediate',
@@ -94,6 +99,7 @@ export const RABBIT_HOLES: RabbitHole[] = [
id: 'rh-custody',
emoji: '🛡',
title: 'The Custody Path',
+ question: 'Which risks become yours when you hold the keys?',
subtitle: 'Control, recovery, and human failure',
desc: 'Learn custody as risk transfer. Removing an intermediary reduces some failures while making key security, recovery, privacy, and inheritance your responsibility.',
difficulty: 'Intermediate',
@@ -114,6 +120,7 @@ export const RABBIT_HOLES: RabbitHole[] = [
id: 'rh-philosophy',
emoji: '📜',
title: 'The Reflection Path',
+ question: 'Which conclusions follow—and which remain arguments?',
subtitle: 'Claims, objections, and institutional meaning',
desc: 'Separate protocol facts from claims about freedom, human rights, institutions, and the future. Read sympathetic arguments beside the strongest objections and keep the unresolved parts visible.',
difficulty: 'Intermediate',
diff --git a/src/data/site.json b/src/data/site.json
index 939386e..6466c4d 100644
--- a/src/data/site.json
+++ b/src/data/site.json
@@ -4,8 +4,7 @@
"themeColor": "#0d0b08",
"tagline": "A curated, first-principles guide to Bitcoin: money, protocol, verification, self-custody, history, and the strongest objections.",
"author": "Hiei",
- "contactEmail": "hiei1988@gmail.com",
+ "contactEmail": "hi@bitcoinmind.com",
"foundedYear": 2017,
- "lastUpdated": "August 2026",
- "lastModified": "2026-08-31"
+ "firstContact": "2011-04"
}
diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro
index f0b74a6..563b351 100644
--- a/src/layouts/Base.astro
+++ b/src/layouts/Base.astro
@@ -180,83 +180,6 @@ const legacyHashRedirects = LEGACY_HASH_REDIRECTS;
import '@/scripts/mobile-menu';
import '@/scripts/explore-menu';
-