diff --git a/AGENTS.md b/AGENTS.md
index d2de097..2ee8e4a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,7 +4,9 @@ Josselin Perrus's personal site: an Astro 7 static site served by Cloudflare Wor
## Layout
-- `src/`: pages, layouts and components.
+- `src/`: pages, layouts and components. Pages are built in the `Frame` component, which holds the header and the footer; the portfolio view of `/documents/` has its own.
+- `src/styles/`: `tokens.css` holds the colours and fonts as custom properties, `global.css` the base styles, and `legacy.css` the content styles of the pages not yet rebuilt in the redesign.
+- `src/fonts/`: the self-hosted fonts, DejaVu Sans Mono and Newsreader, subset by `scripts/subset-fonts.py`, with their licences. The site loads no outside fonts.
- `src/content/writing/`: git submodule of [meaningfool-writing](https://github.com/meaningfool/meaningfool-writing), which holds every article, activity log and image. Read its `AGENTS.md` before editing content.
- `src/content.config.ts`: the content collection, built from `articles/*.md` and `daily-logs/*.md` in the submodule.
- `docs/document-sharing.md`: how the encrypted portfolio links on `/documents/` work and how to create them.
diff --git a/public/_headers b/public/_headers
index 34b42da..74f643e 100644
--- a/public/_headers
+++ b/public/_headers
@@ -1,15 +1,16 @@
# Cloudflare Workers reads this file; it isn't served there.
# Every response from the site. The Content-Security-Policy only reports for now:
-# it lists what pages load (Google Fonts, YouTube embeds, outside images and
-# Cloudflare's analytics beacon, which reports to /cdn-cgi/rum on this domain).
+# it lists what pages load (YouTube embeds, outside images and Cloudflare's
+# analytics beacon, which reports to /cdn-cgi/rum on this domain). Fonts are
+# self-hosted.
# Inline styles stay allowed because code highlighting uses style attributes.
/*
Strict-Transport-Security: max-age=31536000
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()
- Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; media-src 'self'; frame-src https://www.youtube.com https://www.youtube-nocookie.com; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
+ Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data: https:; media-src 'self'; frame-src https://www.youtube.com https://www.youtube-nocookie.com; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
# Fingerprinted build files: their names change whenever their content does.
/_astro/*
diff --git a/public/fonts/space-mono-400.woff2 b/public/fonts/space-mono-400.woff2
deleted file mode 100644
index 5037077..0000000
--- a/public/fonts/space-mono-400.woff2
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
Error 404 (Not Found)!!1
-
-
- 404. That’s an error.
-
The requested URL /s/spacemono/v13/i7dPIFZifjKcF5UAWdDRUEZ2RFq7AwU.woff2 was not found on this server. That’s all we know.
diff --git a/public/fonts/space-mono-500.woff2 b/public/fonts/space-mono-500.woff2
deleted file mode 100644
index f3cc684..0000000
--- a/public/fonts/space-mono-500.woff2
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
Error 404 (Not Found)!!1
-
-
- 404. That’s an error.
-
The requested URL /s/spacemono/v13/i7dMIFZifjKcF5UAWdDRaPpZUFqaAwXGVl0.woff2 was not found on this server. That’s all we know.
diff --git a/scripts/subset-fonts.py b/scripts/subset-fonts.py
new file mode 100644
index 0000000..1ef1ca0
--- /dev/null
+++ b/scripts/subset-fonts.py
@@ -0,0 +1,105 @@
+"""Make the site's self-hosted fonts in src/fonts/ from the original font files.
+
+- DejaVu Sans Mono 2.37, Book: the text of everything but articles and About.
+- Newsreader 1.003, regular and italic: the text of articles and About. Its weight is fixed at 400 and its optical size
+ axis (6 to 72) is kept, as Google Fonts serves it to the prototype.
+
+Each font keeps only the characters in its RANGES, which the @font-face rules in src/styles/tokens.css repeat as their
+unicode-range, so anything else falls back to the next font in the stack. DejaVu Sans Mono leaves out the symbols that
+are also emoji, such as the star or the check mark, so they show as emoji. The fonts keep their whole name table, with
+the copyright and licence. The licences are next to the fonts: LICENSE-DejaVu.txt and OFL-Newsreader.txt.
+
+Run from the repository root, with fontTools and brotli installed (pip install fonttools brotli):
+
+ python3 scripts/subset-fonts.py
+
+It downloads the originals, checks their SHA-256, and overwrites the .woff2 files in src/fonts/. The same originals
+give the same files.
+"""
+import hashlib
+import io
+import os
+import tarfile
+import urllib.request
+
+from fontTools import subset
+from fontTools.ttLib import TTFont
+from fontTools.varLib import instancer
+
+OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src", "fonts")
+
+# Latin, as Google Fonts subsets it: ASCII, Latin-1, the French and other Western European extras, punctuation, euro.
+LATIN = "U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD"
+# The mono font also draws arrows (← → ↗) and box drawing (├── └──) for code and labels.
+RANGES = {
+ "mono": LATIN + ", U+2190-21FF, U+2500-257F",
+ "serif": LATIN,
+}
+
+FONTS = [
+ {
+ "out": "dejavu-sans-mono.woff2",
+ "url": "https://github.com/dejavu-fonts/dejavu-fonts/releases/download/version_2_37/dejavu-fonts-ttf-2.37.tar.bz2",
+ "member": "dejavu-fonts-ttf-2.37/ttf/DejaVuSansMono.ttf",
+ "sha256": "b4a6c3e4faab8773f4ff761d56451646409f29abedd68f05d38c2df667d3c582",
+ "ranges": RANGES["mono"],
+ },
+ {
+ "out": "newsreader.woff2",
+ "url": "https://raw.githubusercontent.com/google/fonts/main/ofl/newsreader/Newsreader%5Bopsz,wght%5D.ttf",
+ "sha256": "8a08d13f8a6c0d51be379a60af84f945f65369a67e509ee3c3bdcc421254d7c1",
+ "ranges": RANGES["serif"],
+ "pin": {"wght": 400},
+ },
+ {
+ "out": "newsreader-italic.woff2",
+ "url": "https://raw.githubusercontent.com/google/fonts/main/ofl/newsreader/Newsreader-Italic%5Bopsz,wght%5D.ttf",
+ "sha256": "796668611f80b64d5adf182fde3b6f29ed83b4e7cbec7b96937e84ac01364792",
+ "ranges": RANGES["serif"],
+ "pin": {"wght": 400},
+ },
+]
+
+
+def unicodes(ranges):
+ """The code points of a CSS unicode-range list."""
+ points = []
+ for part in ranges.split(","):
+ lo, _, hi = part.strip()[2:].partition("-")
+ points.extend(range(int(lo, 16), int(hi or lo, 16) + 1))
+ return points
+
+
+def original(spec):
+ data = urllib.request.urlopen(spec["url"]).read()
+ if "member" in spec:
+ data = tarfile.open(fileobj=io.BytesIO(data)).extractfile(spec["member"]).read()
+ digest = hashlib.sha256(data).hexdigest()
+ if digest != spec["sha256"]:
+ raise SystemExit(f"{spec['out']}: the original's SHA-256 is {digest}, expected {spec['sha256']}")
+ return TTFont(io.BytesIO(data))
+
+
+def main():
+ for spec in FONTS:
+ font = original(spec)
+ if "pin" in spec:
+ font = instancer.instantiateVariableFont(font, spec["pin"])
+ options = subset.Options()
+ options.flavor = "woff2"
+ options.name_IDs = ["*"]
+ options.name_languages = ["*"]
+ options.layout_features = ["*"]
+ options.notdef_outline = True
+ subsetter = subset.Subsetter(options)
+ subsetter.populate(unicodes=unicodes(spec["ranges"]))
+ subsetter.subset(font)
+ path = os.path.join(OUT, spec["out"])
+ font.flavor = "woff2"
+ font.recalcTimestamp = False # keep the original's date, so the same originals give the same files
+ font.save(path)
+ print(spec["out"], os.path.getsize(path) // 1024, "KB")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/components/ArticlesList.astro b/src/components/ArticlesList.astro
index 3d12bdc..dae2449 100644
--- a/src/components/ArticlesList.astro
+++ b/src/components/ArticlesList.astro
@@ -40,7 +40,6 @@ const { articles } = Astro.props;
}
.articles-list .post-title:hover {
- color: var(--accent-hover);
text-decoration: underline;
}
diff --git a/src/components/Footer.astro b/src/components/Footer.astro
new file mode 100644
index 0000000..d98f260
--- /dev/null
+++ b/src/components/Footer.astro
@@ -0,0 +1,53 @@
+---
+// The site footer: the wordmark, then About and the profiles elsewhere. On phones the links go under the wordmark.
+import Wordmark from './Wordmark.astro';
+
+const links = [
+ { href: '/about/', label: 'About' },
+ { href: 'https://www.linkedin.com/in/josselinperrus/', label: 'LinkedIn' },
+ { href: 'https://github.com/meaningfool', label: 'GitHub' },
+ { href: 'https://x.com/nonils', label: 'X' },
+];
+---
+
+
+
+
diff --git a/src/components/Frame.astro b/src/components/Frame.astro
new file mode 100644
index 0000000..eb0fc9e
--- /dev/null
+++ b/src/components/Frame.astro
@@ -0,0 +1,22 @@
+---
+// The frame the site's pages are built in: up to 1240px wide with 24px side padding (more where a phone's notch needs
+// it), the header at the top and the footer at the bottom. It sits above the page background.
+import Header from './Header.astro';
+import Footer from './Footer.astro';
+---
+
+
+
+
+
+
+
+
diff --git a/src/components/Header.astro b/src/components/Header.astro
index cef6695..030b46b 100644
--- a/src/components/Header.astro
+++ b/src/components/Header.astro
@@ -1,57 +1,53 @@
-