diff --git a/AGENTS.md b/AGENTS.md
index d2de097..001a092 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,9 +4,15 @@ 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, and `global.css` the base styles.
+- `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.
+- The page background (`src/components/Background.astro`: the paper texture and the corner pattern) and the topic map (`TopicMap.astro`, inside the `Intro` of Home and About) are drawn on canvases from small images in `src/assets/background/`. `scripts/make-background.py` makes those images, from the map's land in `src/data/topic-map.json`; rerun it after changing either, and commit its output. The site loads no shader library.
- `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.
+- `src/pages/index.astro`: the home. Its Writing list holds the articles, not the activity logs, and picking a topic on the map shows only the articles about it. An article's topics are its `topics` frontmatter field (`customer-success`, `product`, `ai-agents`: each one that makes up 30% or more of it), or else its entry in `src/data/topics.ts`.
+- `src/pages/articles/[slug].astro`: the page of every article and activity log. The Markdown goes through the plugins in `src/lib/` set in `astro.config.mjs`: the `# Title` line is dropped, the headings are renumbered from h2, and `rehype-article.mjs` shapes figures, YouTube embeds (moved to youtube-nocookie.com) and tables for `src/styles/prose.css`. In a table, a row whose cells are all bold is highlighted, and a paragraph starting with `Table:` right after it, past a blank line, is its caption. An image alone in its paragraph is captioned with its title, or with its alt text when that has a space.
+- `src/data/projects.ts`: the projects, with their screenshots in `src/assets/projects/`, and which articles belong to each, by article id.
- `docs/document-sharing.md`: how the encrypted portfolio links on `/documents/` work and how to create them.
- `wrangler.jsonc`: the Cloudflare Worker `meaningfool`, which serves `dist/` as static assets.
- `worker/index.js`: the only Worker code. It answers byte-range requests for the videos in `/images/`, which Safari needs to play them.
diff --git a/astro.config.mjs b/astro.config.mjs
index d5e09bf..c521bfa 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -1,21 +1,37 @@
// @ts-check
+import { createHash } from 'node:crypto';
+import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
import { unified } from '@astrojs/markdown-remark';
import remarkBreaks from 'remark-breaks';
+import rehypeRaw from 'rehype-raw';
import remarkDropTitle from './src/lib/remark-drop-title.mjs';
+import remarkHeadingLevels from './src/lib/remark-heading-levels.mjs';
+import rehypeArticle from './src/lib/rehype-article.mjs';
import trailingSlashRedirects from './src/lib/trailing-slash-redirects.mjs';
import { postLastModified } from './src/lib/posts.mjs';
const lastModified = postLastModified(fileURLToPath(new URL('./src/content/writing', import.meta.url)));
+// Astro keeps each post's rendered HTML between builds, in node_modules/.astro, which the deploy workflow restores from
+// its cache, and renders a post again only when the post or this config changes. The Markdown plugins' code and the
+// installed packages aren't part of the config it compares, so a hash of them is, as rehypeArticle's options.
+const markdownSources = ['src/lib/remark-drop-title.mjs', 'src/lib/remark-heading-levels.mjs', 'src/lib/rehype-article.mjs', 'bun.lock'];
+const markdownDigest = createHash('sha256')
+ .update(markdownSources.map((file) => readFileSync(new URL(`./${file}`, import.meta.url), 'utf8')).join('\0'))
+ .digest('hex');
+
// https://astro.build/config
export default defineConfig({
markdown: {
processor: unified({
- remarkPlugins: [remarkBreaks, remarkDropTitle],
+ remarkPlugins: [remarkBreaks, remarkDropTitle, remarkHeadingLevels],
+ // rehype-raw first, so that rehypeArticle also sees the HTML written in the Markdown, such as embeds
+ rehypePlugins: [rehypeRaw, [rehypeArticle, { markdownDigest }]],
}),
+ shikiConfig: { theme: 'github-light' }, // code blocks sit on the white page, in a light frame (prose.css)
},
integrations: [sitemap({
filter: (page) => !/^\/documents\/?$/.test(new URL(page).pathname),
@@ -26,9 +42,15 @@ export default defineConfig({
output: 'static',
compressHTML: true, // Astro 7 defaults to 'jsx', which drops whitespace between inline elements
trailingSlash: 'always', // Pages live at /page/; dist/_redirects sends /page there with a 301
+ prefetch: false, // Home and About's ClientRouter (IntroRouter.astro) would otherwise prefetch every link hovered
vite: {
resolve: {
preserveSymlinks: true
+ },
+ build: {
+ // Scripts stay in their own files, never inline: the Content-Security-Policy (public/_headers) only allows
+ // scripts from the site. Other assets keep Vite's default, inline under 4 KB.
+ assetsInlineLimit: (file) => (file.endsWith('.js') ? false : undefined)
}
}
});
diff --git a/bun.lock b/bun.lock
index 37833d0..7710314 100644
--- a/bun.lock
+++ b/bun.lock
@@ -8,7 +8,10 @@
"@astrojs/markdown-remark": "^7.3.0",
"@astrojs/rss": "^4.0.19",
"astro": "^7.4.3",
+ "fontkitten": "^1.0.3",
+ "rehype-raw": "^7.0.0",
"remark-breaks": "^4.0.0",
+ "sharp": "^0.35.4",
},
"devDependencies": {
"@astrojs/sitemap": "^3.7.4",
diff --git a/docs/redesign/README.md b/docs/redesign/README.md
new file mode 100644
index 0000000..54810d0
--- /dev/null
+++ b/docs/redesign/README.md
@@ -0,0 +1,30 @@
+# Redesign reference
+
+The prototype approved in #8, kept as the reference for building the redesign (#35 and its sub-issues). Nothing here is part of the site build.
+
+## View it
+
+Open `prototype/index.html` in a browser, or serve the folder:
+
+```
+python3 -m http.server -d docs/redesign/prototype
+```
+
+The prototype has three pages: the home, `#article` and `#about`. After editing `template.html` or `maps.js`, rebuild `index.html`:
+
+```
+python3 docs/redesign/prototype/build.py
+```
+
+## What's where
+
+- `prototype/template.html`: the layout, styles and copy of the three pages. Its script draws the paper background (Paper Shaders), the corner pattern (a canvas) and the mask that clears the pattern around the map. The settled values are at the top of the script.
+- `prototype/maps.js`: the 18 articles with their topic weights (customer success, product, AI agents), the map shapes, labels and orange dots, and the print settings. The chosen map is `best`, placed by `fit()`.
+- `prototype/images/`: the project screenshots and the article figure.
+- `paper/`: exports from the Paper file, with their exact values: the share images and the favicon (#34), and the chosen article design (#32).
+- `map-tools/`: how the map was made. `gen.py` builds island maps from specs; `specs-2.json` holds the recipe for `best`. `lib.py` computes the ink field and a halftone preview calibrated against the shader: `calib.json` (ink to dot coverage) was measured with `calib.html`. Useful for #30, which has to draw the dots without the shader. Needs numpy and Pillow.
+
+## Where the site differs from the prototype
+
+- The prototype loads `@paper-design/shaders` from jsDelivr and computes the background and the map in the browser. The site prepares them ahead of time with `scripts/make-background.py`, which writes small images to `src/assets/background/`, and only lays out and draws the dots in the browser (#30).
+- The prototype uses Menlo and loads Newsreader from Google Fonts. The site self-hosts DejaVu Sans Mono and Newsreader (#29).
diff --git a/docs/redesign/map-tools/calib.html b/docs/redesign/map-tools/calib.html
new file mode 100644
index 0000000..fe9cfa2
--- /dev/null
+++ b/docs/redesign/map-tools/calib.html
@@ -0,0 +1,41 @@
+
+
Comparing Models for Time-Sensitive Structured Extraction
+
+
A key aspect of my voice-todos app was to make it snappy: visual feedback is useful only if it's near instant. So how do you get close to this <500ms that provides this human-level interaction feeling? You need to optimize latency at all levels.
+
Optimizing LLM extraction time
+
I built my own, very simple, eval suite, made of 26 cases, to test if LLMs were able to capture some behaviours expected from my voice-todos-app:
+
Multi-todo extraction.
Over- and under-splitting.
Merge instructions following.
Correction of previous todos.
Cancellation.
+
The winner was Mistral Small 4: with a perfect score and 0.674s average latency. Latency is measured over the full case span, including failed runs.
There are 2 possible strategies to enforce the structure of an LLM response:
+
parse-after-generation
+
The provider shows the model a schema or a tool definition.
The model generates a text answer.
The provider parses the response to extract a schema-compliant json object.
The app validates the schema, retries or repairs the result if not ok.
+
constrained decoding
+
The model is added a constrained decoding library such as Outlines.
The provider shows the model a schema.
The library turns the schema into rules that run during token generation.
The model generates tokens as it normally would but the library masks invalid tokens so that only schema-compliant tokens may be chosen as an output.
+
+
+
PART OF THE PROJECTVoice Todos
Talk, and your todo list builds itself as you speak.
Try Voice Todos ↗
+
← PreviousThoughts on Human-AI Collaboration
Next →Editing and Reconciling Collections without IDs
+
+
meaningfool.
AboutLinkedInGitHubX
+
+
+
+
+
+
+
About
+
Customer success, Product, AI agents.
+
I'm Josselin Perrus, product manager turned builder. I started in customer-facing roles, spent seven years building the product at Linkurious (2026 exit), and the last three years building with AI.
+
+
+
+
+
+
+
Customer SuccessIdentify the value
+
+
I started in client-facing positions. Talking to customers and making them successful is still the highlight of my day. It's also where I learnt to listen and be pragmatic.
+
Listen, because when you have little leverage on the product, that's your only chance to narrow the problem enough that you may be able to find a solution.
+
Pragmatic, because a now-solution beats everyday a beautiful solution.
+
+
+
+
ProductDecide what to build
+
+
Linkurious grew from €1K / year / customer to over €100K / year / customer. Self-financed. As the Founding PM, I had to identify the highest leverage bets, and build alignment with the cofounders. And push back on the rest.
+
I build focus and clarity in fluid environments on a deep understanding of customers (120 interviews in the first 6 months at Linkurious), their problems, and how they create opportunities to meet the company strategy.
+
+
+
+
AI AgentsSoftware factory
+
+
My very first AI project was Dec. 2023: building a knowledge graph from a sum of articles using structured extraction. Later came a data lineage tool, a report on agent frameworks and 2 voice agent projects.
+
I build them end to end with coding agents, from the infrastructure to the prompts, from backend/frontend to benchmarks and evals.
+
I compound building my own set of tools such as my boundary model Skills (GitHub).
+
+
+
+
+
+
+
+
+
diff --git a/docs/redesign/prototype/maps.js b/docs/redesign/prototype/maps.js
new file mode 100644
index 0000000..ca6a693
--- /dev/null
+++ b/docs/redesign/prototype/maps.js
@@ -0,0 +1,327 @@
+/* Topic map in the round 7 look: the articles are patches of ink printed by Paper's Halftone Dots (same grid,
+ grey and grain as round 7), areas are named in grey capitals on a pale grey ground, and the three topics of
+ the site (Customer success, Product, AI agents) are the three orange dots.
+ mount(el, { kind, onPick }) draws one variation into el and returns { setPick, destroy }; clicking a dot picks its topic. */
+(function () {
+const PILLARS = [
+ { id: 'cs', name: 'Customer success' },
+ { id: 'product', name: 'Product' },
+ { id: 'agents', name: 'AI agents' },
+];
+// w = [customer success, product, AI agents]: how much each article is about each, read from the text.
+const ARTICLES = [
+ { id: 'collections', date: '2026-05-27', title: 'Editing and Reconciling Collections without IDs', min: 2, project: 'Voice Todos', w: [0.1, 0.15, 0.75] },
+ { id: 'extraction', date: '2026-05-27', title: 'Comparing Models for Time-Sensitive Structured Extraction', min: 3, project: 'Voice Todos', w: [0.2, 0.1, 0.7] },
+ { id: 'human-ai', date: '2026-05-18', title: 'Thoughts on Human-AI Collaboration', min: 2, project: 'Voice Todos', w: [0.3, 0.3, 0.4] },
+ { id: 'pointing', date: '2026-03-03', title: 'Giving Agents the Ability to Point at Things', min: 5, project: '', w: [0.05, 0.1, 0.85] },
+ { id: 'frameworks', date: '2026-02-13', title: 'Agent Frameworks for the Rest of Us', min: 39, project: 'Agent SDKs', w: [0.05, 0.15, 0.8] },
+ { id: 'how-long', date: '2026-02-02', title: 'How long for that change?', min: 4, project: '', w: [0.15, 0.7, 0.15] },
+ { id: 'january', date: '2026-02-01', title: 'January in review', min: 3, project: '', w: [0.05, 0.25, 0.7] },
+ { id: 'prolifics', date: '2026-01-26', title: 'Prolifics and frugals', min: 1, project: '', w: [0.2, 0.7, 0.1] },
+ { id: 'slicing', date: '2026-01-19', title: 'The Slicing Fallacy', min: 1, project: '', w: [0.25, 0.7, 0.05] },
+ { id: 'planning', date: '2026-01-12', title: "We Can't Stop Planning (Even When We Should)", min: 2, project: '', w: [0.2, 0.75, 0.05] },
+ { id: 'cost', date: '2026-01-05', title: "Cost of Change is Agile's ceiling", min: 1, project: '', w: [0.15, 0.75, 0.1] },
+ { id: 'compounding', date: '2025-12-24', title: 'Compounding Development Process', min: 4, project: '', w: [0.05, 0.5, 0.45] },
+ { id: 'antigravity', date: '2025-12-22', title: '3 weeks with Antigravity and Gemini', min: 5, project: 'Data Lineage', w: [0.05, 0.25, 0.7] },
+ { id: 'semi-async', date: '2025-10-22', title: 'The semi-async valley of death', min: 1, project: '', w: [0.1, 0.3, 0.6] },
+ { id: 'spec', date: '2025-10-15', title: 'Spec-driven development has it backwards', min: 4, project: '', w: [0.1, 0.5, 0.4] },
+ { id: 'paris', date: '2025-09-26', title: 'AI Engineer Paris Conference Notes', min: 2, project: '', w: [0.05, 0.15, 0.8] },
+ { id: 'voice-ai', date: '2025-09-01', title: 'Voice AI Maven Course Notes', min: 3, project: '', w: [0.05, 0.1, 0.85] },
+ { id: 'faster', date: '2025-01-22', title: 'Faster answers, better questions', min: 1, project: '', w: [0.3, 0.5, 0.2] },
+];
+const K = { cs: 0, product: 1, agents: 2 };
+// An article belongs to a topic when at least 30% of it is about that topic.
+const inPillar = (a, id) => a.w[K[id]] >= 0.3;
+const MIN = Object.fromEntries(ARTICLES.map((a) => [a.id, a.min]));
+
+// Round 7's map: a 420 x 300 box, positions as fractions of it.
+const W = 420, H = 300;
+
+function rng(seed) {
+ let a = seed >>> 0;
+ return () => { a = (a + 0x6d2b79f5) >>> 0; let t = Math.imul(a ^ (a >>> 15), a | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
+}
+/* An area: its articles scattered around a centre (Gaussian spread sx, sy), one patch each, sized by reading time
+ as in round 7. A spine adds small patches along a path so separate areas read as one piece of land. */
+function scatter(seed, areas, spine = []) {
+ const r = rng(seed), gauss = () => Math.sqrt(-2 * Math.log(1 - r())) * Math.cos(2 * Math.PI * r());
+ const out = [];
+ for (const a of areas) for (const id of a.ids) {
+ const u = Math.min(0.95, Math.max(0.05, a.c[0] + gauss() * a.s[0])), v = Math.min(0.94, Math.max(0.06, a.c[1] + gauss() * a.s[1]));
+ out.push([u, v, MIN[id]]);
+ }
+ for (const [u, v, m] of spine) out.push([u, v, m]);
+ return out;
+}
+
+/* Three-island variations, made by docs/redesign/map-tools/gen.py from a few parameters each (or, for "best", from
+ parts of three others) and tuned so that,
+ as in round 7, little of the land sits at the very top: patches are [x, y, radius, weight]. */
+const ISLANDS = {
+ best: [[0.2, 0.25, 0.12, 0.72], [0.3, 0.207, 0.087, 0.3], [0.131, 0.213, 0.076, 0.307], [0.23, 0.315, 0.067, 0.353], [0.191, 0.242, 0.04, 0.45], [0.306, 0.258, 0.035, 0.749], [0.226, 0.337, 0.033, 0.462], [0.085, 0.304, 0.029, 0.384], [0.059, 0.289, 0.054, -0.7], [0.226, 0.383, 0.037, 0.782], [0.239, 0.462, 0.043, 0.487], [0.255, 0.527, 0.042, 0.829], [0.269, 0.582, 0.044, 0.915], [0.303, 0.655, 0.034, 0.624], [0.329, 0.711, 0.045, 0.647], [0.45, 0.81, 0.11, 0.82], [0.44, 0.857, 0.074, 0.396], [0.389, 0.83, 0.076, 0.453], [0.359, 0.836, 0.061, 0.385], [0.459, 0.805, 0.04, 0.6], [0.519, 0.776, 0.032, 0.767], [0.557, 0.749, 0.024, 0.522], [0.462, 0.666, 0.022, 0.676], [0.561, 0.671, 0.021, 0.471], [0.379, 0.831, 0.034, 0.451], [0.52, 0.63, 0.042, 0.95], [0.545, 0.54, 0.036, 0.8], [0.565, 0.46, 0.028, 0.62], [0.58, 0.4, 0.016, 0.9], [0.566, 0.726, 0.037, 0.615], [0.592, 0.684, 0.034, 0.665], [0.62, 0.663, 0.043, 0.352], [0.673, 0.63, 0.038, 0.63], [0.716, 0.594, 0.034, 0.529], [0.85, 0.44, 0.13, 0.8], [0.967, 0.431, 0.092, 0.343], [0.761, 0.462, 0.075, 0.397], [0.817, 0.518, 0.088, 0.429], [0.856, 0.446, 0.04, 0.62], [0.786, 0.325, 0.038, 0.63], [0.925, 0.229, 0.031, 0.717], [0.858, 0.275, 0.025, 0.476], [0.863, 0.515, 0.036, 0.597], [0.939, 0.474, 0.027, 0.332], [0.333, 0.253, 0.039, 0.39], [0.385, 0.264, 0.031, 0.368], [0.464, 0.272, 0.028, 0.33], [0.506, 0.259, 0.03, 0.361], [0.563, 0.262, 0.036, 0.383], [0.594, 0.282, 0.036, 0.335], [0.651, 0.287, 0.027, 0.341], [0.691, 0.317, 0.035, 0.339], [0.374, 0.351, 0.039, 0.324], [0.406, 0.383, 0.035, 0.305], [0.468, 0.387, 0.037, 0.286], [0.528, 0.401, 0.034, 0.277], [0.559, 0.417, 0.033, 0.328], [0.624, 0.411, 0.027, 0.273], [0.684, 0.421, 0.039, 0.343], [0.565, 0.352, 0.012, 0.725], [0.602, 0.364, 0.009, 0.764], [0.463, 0.32, 0.016, 1.286], [0.634, 0.37, 0.01, 0.677], [0.518, 0.342, 0.01, 0.881], [0.454, 0.32, 0.01, 0.699], [0.681, 0.364, 0.012, 0.768], [0.419, 0.286, 0.013, 0.643], [0.435, 0.314, 0.021, 1.158], [0.372, 0.274, 0.01, 0.81], [0.05, 0.55, 0.018, 0.895], [0.94, 0.78, 0.021, 0.978], [0.3, 0.94, 0.018, 0.966]],
+ roll: [[0.2, 0.25, 0.12, 0.72], [0.14, 0.222, 0.089, 0.421], [0.106, 0.266, 0.066, 0.32], [0.177, 0.329, 0.066, 0.428], [0.191, 0.244, 0.04, 0.45], [0.288, 0.268, 0.034, 0.694], [0.225, 0.354, 0.029, 0.411], [0.078, 0.294, 0.022, 0.439], [0.059, 0.289, 0.054, -0.7], [0.45, 0.81, 0.11, 0.82], [0.413, 0.744, 0.079, 0.393], [0.396, 0.853, 0.066, 0.438], [0.527, 0.813, 0.07, 0.385], [0.44, 0.814, 0.04, 0.6], [0.515, 0.792, 0.027, 0.602], [0.56, 0.737, 0.022, 0.581], [0.477, 0.666, 0.026, 0.596], [0.57, 0.681, 0.019, 0.5], [0.386, 0.836, 0.026, 0.438], [0.85, 0.44, 0.13, 0.8], [0.932, 0.454, 0.096, 0.46], [0.887, 0.391, 0.088, 0.444], [0.891, 0.353, 0.07, 0.349], [0.856, 0.445, 0.04, 0.62], [0.802, 0.318, 0.039, 0.644], [0.934, 0.241, 0.033, 0.666], [0.852, 0.276, 0.024, 0.369], [0.853, 0.54, 0.031, 0.673], [0.922, 0.461, 0.022, 0.438], [0.225, 0.379, 0.034, 0.732], [0.236, 0.438, 0.042, 0.553], [0.241, 0.516, 0.045, 0.829], [0.256, 0.581, 0.041, 0.909], [0.282, 0.65, 0.037, 0.632], [0.325, 0.698, 0.033, 0.641], [0.556, 0.726, 0.03, 0.619], [0.598, 0.684, 0.038, 0.684], [0.645, 0.638, 0.038, 0.355], [0.677, 0.603, 0.033, 0.632], [0.74, 0.56, 0.035, 0.592], [0.34, 0.245, 0.028, 0.436], [0.405, 0.255, 0.036, 0.354], [0.456, 0.271, 0.038, 0.376], [0.515, 0.274, 0.028, 0.37], [0.549, 0.264, 0.036, 0.339], [0.607, 0.294, 0.028, 0.402], [0.641, 0.305, 0.037, 0.434], [0.705, 0.315, 0.037, 0.34], [0.371, 0.348, 0.031, 0.295], [0.406, 0.378, 0.029, 0.294], [0.455, 0.376, 0.037, 0.328], [0.521, 0.411, 0.033, 0.276], [0.558, 0.409, 0.037, 0.36], [0.621, 0.407, 0.035, 0.362], [0.668, 0.432, 0.04, 0.327], [0.499, 0.313, 0.021, 1.285], [0.693, 0.394, 0.01, 0.618], [0.449, 0.33, 0.01, 0.817], [0.484, 0.306, 0.014, 0.827], [0.662, 0.35, 0.013, 0.741], [0.404, 0.29, 0.014, 0.865], [0.618, 0.341, 0.01, 0.758], [0.376, 0.273, 0.012, 0.793], [0.577, 0.358, 0.012, 0.894], [0.622, 0.368, 0.021, 1.149], [0.52, 0.63, 0.042, 0.95], [0.545, 0.54, 0.036, 0.8], [0.565, 0.46, 0.028, 0.62], [0.58, 0.4, 0.016, 0.9], [0.05, 0.55, 0.021, 0.713], [0.94, 0.78, 0.022, 0.713], [0.3, 0.94, 0.016, 0.701]],
+ flatsouth: [[0.2, 0.25, 0.12, 0.72], [0.159, 0.328, 0.063, 0.329], [0.116, 0.308, 0.07, 0.373], [0.255, 0.283, 0.081, 0.368], [0.204, 0.249, 0.04, 0.45], [0.3, 0.251, 0.032, 0.606], [0.22, 0.348, 0.031, 0.482], [0.084, 0.309, 0.027, 0.341], [0.059, 0.289, 0.054, -0.7], [0.45, 0.81, 0.11, 0.82], [0.51, 0.767, 0.067, 0.342], [0.482, 0.849, 0.058, 0.457], [0.384, 0.866, 0.082, 0.43], [0.444, 0.803, 0.04, 0.6], [0.544, 0.79, 0.029, 0.796], [0.56, 0.761, 0.021, 0.54], [0.465, 0.684, 0.025, 0.612], [0.579, 0.674, 0.022, 0.493], [0.367, 0.819, 0.029, 0.484], [0.85, 0.44, 0.13, 0.8], [0.879, 0.386, 0.097, 0.45], [0.804, 0.387, 0.09, 0.433], [0.903, 0.389, 0.091, 0.352], [0.849, 0.433, 0.04, 0.62], [0.783, 0.333, 0.04, 0.647], [0.924, 0.215, 0.034, 0.751], [0.842, 0.256, 0.026, 0.476], [0.851, 0.526, 0.038, 0.633], [0.936, 0.465, 0.028, 0.417], [0.222, 0.386, 0.035, 0.794], [0.229, 0.449, 0.043, 0.541], [0.233, 0.506, 0.038, 0.801], [0.266, 0.569, 0.042, 0.932], [0.292, 0.646, 0.04, 0.543], [0.325, 0.703, 0.04, 0.777], [0.574, 0.737, 0.034, 0.593], [0.591, 0.675, 0.029, 0.685], [0.632, 0.645, 0.033, 0.318], [0.697, 0.598, 0.03, 0.627], [0.738, 0.577, 0.04, 0.58], [0.346, 0.275, 0.032, 0.376], [0.382, 0.322, 0.038, 0.367], [0.446, 0.333, 0.03, 0.352], [0.474, 0.359, 0.031, 0.33], [0.522, 0.362, 0.039, 0.35], [0.596, 0.375, 0.028, 0.356], [0.652, 0.37, 0.036, 0.325], [0.685, 0.361, 0.029, 0.347], [0.35, 0.371, 0.03, 0.35], [0.395, 0.416, 0.04, 0.296], [0.452, 0.449, 0.03, 0.321], [0.495, 0.455, 0.034, 0.276], [0.569, 0.461, 0.028, 0.281], [0.606, 0.442, 0.037, 0.292], [0.671, 0.439, 0.031, 0.333], [0.469, 0.374, 0.014, 0.827], [0.631, 0.41, 0.011, 0.9], [0.443, 0.357, 0.02, 1.114], [0.416, 0.331, 0.014, 0.886], [0.663, 0.39, 0.013, 0.727], [0.612, 0.405, 0.011, 0.606], [0.432, 0.372, 0.011, 0.71], [0.612, 0.38, 0.017, 1.26], [0.413, 0.366, 0.013, 0.735], [0.487, 0.372, 0.013, 0.608], [0.52, 0.63, 0.042, 0.95], [0.545, 0.54, 0.036, 0.8], [0.565, 0.46, 0.028, 0.62], [0.58, 0.4, 0.016, 0.9], [0.05, 0.55, 0.017, 0.885], [0.94, 0.78, 0.018, 0.799], [0.3, 0.94, 0.02, 0.908]],
+ south: [[0.2, 0.25, 0.12, 0.72], [0.3, 0.207, 0.087, 0.3], [0.131, 0.213, 0.076, 0.307], [0.23, 0.315, 0.067, 0.353], [0.191, 0.242, 0.04, 0.45], [0.306, 0.258, 0.035, 0.749], [0.226, 0.337, 0.033, 0.462], [0.085, 0.304, 0.029, 0.384], [0.059, 0.289, 0.054, -0.7], [0.47, 0.82, 0.11, 0.82], [0.474, 0.76, 0.071, 0.436], [0.447, 0.864, 0.068, 0.446], [0.406, 0.786, 0.073, 0.358], [0.464, 0.817, 0.04, 0.6], [0.559, 0.791, 0.026, 0.616], [0.576, 0.753, 0.025, 0.603], [0.485, 0.676, 0.023, 0.565], [0.598, 0.681, 0.019, 0.397], [0.408, 0.846, 0.033, 0.461], [0.83, 0.43, 0.13, 0.8], [0.715, 0.402, 0.084, 0.471], [0.891, 0.519, 0.067, 0.387], [0.942, 0.444, 0.087, 0.476], [0.836, 0.439, 0.04, 0.62], [0.771, 0.325, 0.038, 0.689], [0.914, 0.216, 0.032, 0.668], [0.829, 0.251, 0.028, 0.482], [0.832, 0.508, 0.035, 0.536], [0.911, 0.469, 0.023, 0.374], [0.226, 0.383, 0.037, 0.782], [0.239, 0.462, 0.043, 0.487], [0.255, 0.527, 0.042, 0.829], [0.269, 0.582, 0.044, 0.915], [0.303, 0.655, 0.034, 0.624], [0.329, 0.711, 0.045, 0.647], [0.593, 0.733, 0.037, 0.658], [0.602, 0.693, 0.036, 0.64], [0.644, 0.631, 0.034, 0.38], [0.667, 0.608, 0.032, 0.633], [0.71, 0.558, 0.035, 0.525], [0.344, 0.312, 0.037, 0.506], [0.375, 0.357, 0.031, 0.56], [0.398, 0.406, 0.039, 0.5], [0.459, 0.429, 0.035, 0.469], [0.488, 0.437, 0.031, 0.569], [0.535, 0.445, 0.033, 0.547], [0.588, 0.445, 0.031, 0.467], [0.628, 0.421, 0.031, 0.553], [0.693, 0.399, 0.034, 0.443], [0.515, 0.461, 0.013, 0.674], [0.694, 0.394, 0.01, 0.709], [0.392, 0.346, 0.011, 0.804], [0.406, 0.421, 0.01, 0.893], [0.645, 0.405, 0.01, 0.82], [0.491, 0.441, 0.013, 0.781], [0.52, 0.433, 0.023, 1.082], [0.46, 0.437, 0.013, 0.731], [0.05, 0.55, 0.018, 0.895], [0.94, 0.78, 0.021, 0.978], [0.3, 0.94, 0.018, 0.966]],
+ spit: [[0.22, 0.25, 0.12, 0.72], [0.139, 0.189, 0.074, 0.39], [0.301, 0.222, 0.082, 0.393], [0.258, 0.211, 0.065, 0.432], [0.225, 0.245, 0.04, 0.45], [0.327, 0.25, 0.034, 0.688], [0.242, 0.339, 0.03, 0.47], [0.095, 0.305, 0.029, 0.347], [0.079, 0.211, 0.054, -0.8], [0.45, 0.81, 0.11, 0.82], [0.44, 0.857, 0.074, 0.396], [0.389, 0.83, 0.076, 0.453], [0.359, 0.836, 0.061, 0.385], [0.459, 0.805, 0.04, 0.6], [0.519, 0.776, 0.032, 0.767], [0.557, 0.749, 0.024, 0.522], [0.462, 0.666, 0.022, 0.676], [0.561, 0.671, 0.021, 0.471], [0.379, 0.831, 0.034, 0.451], [0.83, 0.47, 0.13, 0.8], [0.855, 0.534, 0.089, 0.388], [0.778, 0.506, 0.069, 0.424], [0.842, 0.545, 0.073, 0.37], [0.824, 0.478, 0.04, 0.62], [0.774, 0.35, 0.032, 0.795], [0.902, 0.249, 0.032, 0.868], [0.825, 0.297, 0.022, 0.473], [0.832, 0.562, 0.037, 0.602], [0.906, 0.493, 0.026, 0.355], [0.236, 0.394, 0.047, 0.736], [0.244, 0.44, 0.045, 0.561], [0.249, 0.503, 0.041, 0.881], [0.278, 0.583, 0.039, 0.909], [0.291, 0.642, 0.042, 0.629], [0.31, 0.707, 0.033, 0.65], [0.566, 0.726, 0.037, 0.615], [0.592, 0.684, 0.034, 0.665], [0.62, 0.663, 0.043, 0.352], [0.673, 0.63, 0.038, 0.63], [0.716, 0.594, 0.034, 0.529], [0.371, 0.276, 0.027, 0.325], [0.422, 0.271, 0.04, 0.275], [0.457, 0.285, 0.033, 0.281], [0.487, 0.28, 0.031, 0.298], [0.547, 0.278, 0.033, 0.517], [0.595, 0.306, 0.031, 0.548], [0.638, 0.316, 0.038, 0.645], [0.673, 0.341, 0.035, 0.618], [0.711, 0.375, 0.032, 0.635], [0.413, 0.28, 0.011, 0.775], [0.486, 0.274, 0.013, 0.84], [0.492, 0.281, 0.011, 0.693], [0.455, 0.282, 0.02, 1.331], [0.403, 0.268, 0.011, 0.652], [0.336, 0.278, 0.019, 1.11], [0.52, 0.63, 0.042, 0.95], [0.545, 0.54, 0.036, 0.8], [0.565, 0.46, 0.028, 0.62], [0.58, 0.4, 0.016, 0.9], [0.04, 0.5, 0.021, 0.949], [0.95, 0.76, 0.016, 0.741], [0.32, 0.93, 0.018, 0.746]],
+ flats: [[0.2, 0.29, 0.12, 0.72], [0.291, 0.328, 0.089, 0.348], [0.277, 0.267, 0.079, 0.42], [0.217, 0.338, 0.065, 0.293], [0.199, 0.282, 0.04, 0.45], [0.288, 0.294, 0.034, 0.78], [0.215, 0.395, 0.034, 0.481], [0.07, 0.33, 0.024, 0.341], [0.104, 0.203, 0.054, -0.6], [0.46, 0.78, 0.11, 0.82], [0.551, 0.765, 0.077, 0.435], [0.533, 0.775, 0.056, 0.387], [0.372, 0.811, 0.055, 0.468], [0.457, 0.77, 0.04, 0.6], [0.543, 0.771, 0.031, 0.749], [0.578, 0.717, 0.021, 0.611], [0.482, 0.629, 0.023, 0.607], [0.586, 0.653, 0.02, 0.456], [0.396, 0.787, 0.034, 0.482], [0.85, 0.44, 0.13, 0.8], [0.967, 0.431, 0.092, 0.343], [0.761, 0.462, 0.075, 0.397], [0.817, 0.518, 0.088, 0.429], [0.856, 0.446, 0.04, 0.62], [0.786, 0.325, 0.038, 0.63], [0.925, 0.229, 0.031, 0.717], [0.858, 0.275, 0.025, 0.476], [0.863, 0.515, 0.036, 0.597], [0.939, 0.474, 0.027, 0.332], [0.221, 0.411, 0.034, 0.754], [0.237, 0.484, 0.037, 0.568], [0.25, 0.531, 0.045, 0.991], [0.258, 0.581, 0.042, 0.942], [0.291, 0.639, 0.035, 0.659], [0.317, 0.667, 0.034, 0.783], [0.565, 0.702, 0.04, 0.613], [0.62, 0.672, 0.035, 0.688], [0.655, 0.63, 0.042, 0.33], [0.691, 0.586, 0.029, 0.643], [0.732, 0.552, 0.037, 0.639], [0.333, 0.253, 0.039, 0.39], [0.385, 0.264, 0.031, 0.368], [0.464, 0.272, 0.028, 0.33], [0.506, 0.259, 0.03, 0.361], [0.563, 0.262, 0.036, 0.383], [0.594, 0.282, 0.036, 0.335], [0.651, 0.287, 0.027, 0.341], [0.691, 0.317, 0.035, 0.339], [0.374, 0.351, 0.039, 0.324], [0.406, 0.383, 0.035, 0.305], [0.468, 0.387, 0.037, 0.286], [0.528, 0.401, 0.034, 0.277], [0.559, 0.417, 0.033, 0.328], [0.624, 0.411, 0.027, 0.273], [0.684, 0.421, 0.039, 0.343], [0.565, 0.352, 0.012, 0.725], [0.602, 0.364, 0.009, 0.764], [0.463, 0.32, 0.016, 1.286], [0.634, 0.37, 0.01, 0.677], [0.518, 0.342, 0.01, 0.881], [0.454, 0.32, 0.01, 0.699], [0.681, 0.364, 0.012, 0.768], [0.419, 0.286, 0.013, 0.643], [0.435, 0.314, 0.021, 1.158], [0.372, 0.274, 0.01, 0.81], [0.05, 0.5, 0.018, 0.985], [0.95, 0.74, 0.016, 0.837], [0.64, 0.94, 0.02, 0.725]],
+};
+
+/* Best of three runs past the right edge of the box (its dots reach x = 1.06), so it is drawn 7% smaller about the
+ centre of its land and moved to sit inside the box with a 2.5% margin. Labels and dots move with it. */
+const fit = (k, cx, cy) => {
+ const at = (x, y) => [0.5 + (x - cx) * k, 0.5 + (y - cy) * k];
+ return {
+ patches: (ps) => ps.map(([x, y, r, w]) => [...at(x, y), r * k, w]),
+ label: ([t, x, y]) => [t, ...at(x, y)],
+ pin: ([x, y, ...side]) => [...at(x, y), ...side],
+ };
+};
+const BEST = fit(0.927, 0.546, 0.533);
+
+/* Six maps. Each one: the areas (name, where the name sits, where the articles gather, which articles),
+ and where the three orange dots go ('l' puts the dot's label on its left). */
+const V = [
+ {
+ id: 'best', name: 'Best of three',
+ note: 'Parts taken as they were from the three you liked: Customer success and its link to Product from Shelf bent south, Product and the land it pushes toward AI agents from Spit from AI agents, AI agents and the twin low strips from Tidal flats.',
+ patches: () => BEST.patches(ISLANDS['best']),
+ labels: [["IDENTIFY THE VALUE", 0.2, 0.15], ["DECIDE WHAT TO BUILD", 0.45, 0.91], ["SOFTWARE FACTORY", 0.85, 0.33]].map(BEST.label),
+ pins: { cs: BEST.pin([0.2, 0.25]), product: BEST.pin([0.45, 0.81]), agents: BEST.pin([0.85, 0.44, 'l']) },
+ },
+ {
+ id: 'roll', name: 'Best of three, new roll',
+ note: 'The same recipe drawn again: same positions, heights and kinds of links, with fresh hills, lobes and coastline.',
+ patches: () => ISLANDS['roll'],
+ labels: [["IDENTIFY THE VALUE", 0.2, 0.15], ["DECIDE WHAT TO BUILD", 0.45, 0.91], ["SOFTWARE FACTORY", 0.85, 0.33]],
+ pins: { cs: [0.2, 0.25], product: [0.45, 0.81], agents: [0.85, 0.44, 'l'] },
+ },
+ {
+ id: 'flatsouth', name: 'Flats bent south',
+ note: 'The same recipe with the twin low strips sagging south, so the sea between them and Product narrows to a channel.',
+ patches: () => ISLANDS['flatsouth'],
+ labels: [["IDENTIFY THE VALUE", 0.2, 0.15], ["DECIDE WHAT TO BUILD", 0.45, 0.91], ["SOFTWARE FACTORY", 0.85, 0.33]],
+ pins: { cs: [0.2, 0.25], product: [0.45, 0.81], agents: [0.85, 0.44, 'l'] },
+ },
+ {
+ id: 'south', name: 'Shelf bent south',
+ note: 'The shallow link sags south toward Product and leaves only a narrow channel where the lagoon was.',
+ patches: () => ISLANDS['south'],
+ labels: [["IDENTIFY THE VALUE", 0.2, 0.15], ["DECIDE WHAT TO BUILD", 0.47, 0.92], ["SOFTWARE FACTORY", 0.83, 0.32]],
+ pins: { cs: [0.2, 0.25], product: [0.47, 0.82], agents: [0.83, 0.43, 'l'] },
+ },
+ {
+ id: 'spit', name: 'Spit from AI agents',
+ note: 'The link is solid land on the AI agents side and breaks up into rocks as it nears Customer success.',
+ patches: () => ISLANDS['spit'],
+ labels: [["IDENTIFY THE VALUE", 0.22, 0.15], ["DECIDE WHAT TO BUILD", 0.45, 0.91], ["SOFTWARE FACTORY", 0.83, 0.36]],
+ pins: { cs: [0.22, 0.25], product: [0.45, 0.81], agents: [0.83, 0.47, 'l'] },
+ },
+ {
+ id: 'flats', name: 'Tidal flats',
+ note: 'A wide band of low flats instead of a single strip; the sea shrinks to an inlet below it.',
+ patches: () => ISLANDS['flats'],
+ labels: [["IDENTIFY THE VALUE", 0.2, 0.19], ["DECIDE WHAT TO BUILD", 0.46, 0.88], ["SOFTWARE FACTORY", 0.85, 0.33]],
+ pins: { cs: [0.2, 0.29], product: [0.46, 0.78], agents: [0.85, 0.44, 'l'] },
+ },
+ {
+ // Hand-placed patches: [x, y, radius, weight], drawn from Josselin's sketch. Hills are clusters of overlapping
+ // patches; lower ground is single, lighter patches; a negative weight carves a bay.
+ id: 'vshape', name: 'Three islands, from your sketch',
+ note: 'Three islands around a central sea, each centred on its topic, heights as in round 7: AI agents highest, Product in the middle, Customer success lowest, with a bay in its north-west coast. Land joins Customer success to Product (a waist, a dip, its own hill) and Product to AI agents (a saddle between two rises); a shallow, rocky strip arcs over the top from Customer success to AI agents.',
+ patches: () => [
+ // Customer success: lowest; a round island around its dot, a bay cut into the north-west coast (negative patch), an elongated hill with two summits, a southern lobe, a western lobe
+ [0.22, 0.27, 0.13, 1.05], [0.19, 0.26, 0.045, 1.26], [0.3, 0.27, 0.04, 1.05], [0.25, 0.27, 0.03, 0.56], [0.24, 0.38, 0.045, 0.7], [0.08, 0.33, 0.035, 0.56],
+ [0.08, 0.15, 0.04, -1.1],
+ // Left link (Customer success to Product): a waist, a dip, its own hill, then lower ground into Product
+ [0.21, 0.46, 0.035, 0.75], [0.24, 0.53, 0.033, 0.675], [0.27, 0.59, 0.042, 1.65], [0.3, 0.66, 0.04, 1.05],
+ // Product: medium; a curved ridge peaking at its dot with a hook to the east, two small hills to the north
+ [0.47, 0.78, 0.1, 1], [0.46, 0.8, 0.05, 1.75], [0.4, 0.8, 0.04, 0.875], [0.55, 0.77, 0.04, 1.125], [0.6, 0.71, 0.03, 0.875], [0.48, 0.64, 0.03, 1],
+ [0.58, 0.64, 0.025, 0.75],
+ // Right link (Product to AI agents): merged, a saddle between two rises
+ [0.64, 0.68, 0.04, 1.17], [0.68, 0.63, 0.035, 0.78], [0.72, 0.58, 0.04, 1.3],
+ // AI agents: highest; a broad high island, the main hill at the dot, an upper ridge with two summits
+ [0.84, 0.44, 0.13, 1.5], [0.85, 0.46, 0.05, 1.5], [0.85, 0.54, 0.045, 1.1], [0.78, 0.33, 0.045, 1.1], [0.92, 0.23, 0.045, 1.2], [0.85, 0.28, 0.035, 0.7],
+ // Top link (Customer success to AI agents): shallow, a low-tide shelf arcing over the top, with rocks
+ [0.4, 0.2, 0.03, 0.54], [0.46, 0.16, 0.03, 0.504], [0.53, 0.15, 0.03, 0.54], [0.6, 0.18, 0.03, 0.54], [0.65, 0.23, 0.03, 0.576], [0.69, 0.3, 0.03, 0.612],
+ [0.72, 0.36, 0.03, 0.684], [0.43, 0.18, 0.014, 1.08], [0.5, 0.16, 0.016, 1.17], [0.56, 0.13, 0.012, 0.9], [0.62, 0.21, 0.015, 1.26], [0.67, 0.27, 0.013, 0.99],
+ [0.71, 0.33, 0.012, 0.9],
+ // Islets
+ [0.03, 0.55, 0.02, 0.78], [0.96, 0.74, 0.02, 0.715], [0.7, 0.92, 0.018, 0.65], [0.34, 0.92, 0.015, 0.585],
+ ],
+ labels: [['IDENTIFY THE VALUE', 0.22, 0.05], ['DECIDE WHAT TO BUILD', 0.47, 0.96], ['SOFTWARE FACTORY', 0.86, 0.08]],
+ pins: { cs: [0.21, 0.26], product: [0.46, 0.8], agents: [0.85, 0.46, 'l'] },
+ },
+ {
+ id: 'r7', name: 'Round 7, re-pinned',
+ note: 'Round 7 as it was: same patches, same three areas. The orange dots now mark your three topics instead of the projects.',
+ patches: () => [
+ [0.66, 0.30, 39], [0.78, 0.22, 5], [0.60, 0.44, 3], [0.73, 0.40, 2], [0.84, 0.36, 5], [0.70, 0.16, 2], [0.57, 0.24, 3],
+ [0.20, 0.52, 4], [0.30, 0.64, 1], [0.24, 0.74, 2], [0.36, 0.52, 1], [0.14, 0.66, 4], [0.33, 0.80, 1], [0.42, 0.66, 4],
+ [0.58, 0.72, 2], [0.70, 0.82, 1], [0.78, 0.68, 3], [0.64, 0.90, 1],
+ ],
+ labels: [['AGENTS & TOOLS', 0.72, 0.06], ['HOW TEAMS BUILD', 0.26, 0.42], ['NOTES TO SELF', 0.71, 0.97]],
+ pins: { agents: [0.66, 0.30], product: [0.24, 0.64], cs: [0.48, 0.86] },
+ },
+ {
+ id: 'regions', name: 'Three regions',
+ note: 'One region per topic, named by what you do there. Customer success gets its own region at the top, small for now.',
+ patches: () => scatter(3, [
+ { c: [0.5, 0.27], s: [0.05, 0.06], ids: ['human-ai', 'faster', 'extraction'] },
+ { c: [0.24, 0.68], s: [0.07, 0.08], ids: ['cost', 'planning', 'slicing', 'prolifics', 'how-long', 'spec', 'compounding'] },
+ { c: [0.76, 0.64], s: [0.07, 0.09], ids: ['frameworks', 'pointing', 'voice-ai', 'paris', 'antigravity', 'collections', 'january', 'semi-async'] },
+ ]),
+ labels: [['WHAT USERS NEED', 0.5, 0.06], ['HOW TEAMS SHIP', 0.22, 0.95], ['BUILDING WITH AGENTS', 0.76, 0.95]],
+ pins: { cs: [0.5, 0.27], product: [0.24, 0.68], agents: [0.76, 0.62, 'l'] },
+ },
+ {
+ id: 'bridge', name: 'Bridge',
+ note: 'Four areas in a line, from planning to tools. Product and AI agents hold the two ends; customer success sits in the middle, where they meet.',
+ patches: () => scatter(8, [
+ { c: [0.15, 0.76], s: [0.05, 0.06], ids: ['cost', 'planning', 'slicing'] },
+ { c: [0.36, 0.6], s: [0.05, 0.06], ids: ['how-long', 'prolifics', 'faster', 'compounding'] },
+ { c: [0.6, 0.44], s: [0.06, 0.06], ids: ['spec', 'semi-async', 'human-ai', 'collections', 'extraction'] },
+ { c: [0.82, 0.26], s: [0.06, 0.07], ids: ['frameworks', 'pointing', 'voice-ai', 'paris', 'antigravity', 'january'] },
+ ], Array.from({ length: 12 }, (_, i) => [0.14 + i * 0.062, 0.77 - i * 0.046, 0.3])),
+ labels: [['HOW TEAMS PLAN', 0.15, 0.95], ['SPEED & FLOW', 0.3, 0.4], ['AGENTS IN THE LOOP', 0.68, 0.66], ['TOOLS & MODELS', 0.78, 0.06]],
+ pins: { product: [0.15, 0.76], cs: [0.48, 0.52], agents: [0.82, 0.27, 'l'] },
+ },
+ {
+ id: 'islands', name: 'Archipelago',
+ note: 'Six small islands, one per subject you write about. The three dots sit between the islands they connect.',
+ patches: () => scatter(21, [
+ { c: [0.14, 0.3], s: [0.035, 0.04], ids: ['cost', 'planning', 'slicing'] },
+ { c: [0.2, 0.72], s: [0.035, 0.04], ids: ['how-long', 'prolifics', 'faster'] },
+ { c: [0.47, 0.5], s: [0.04, 0.045], ids: ['spec', 'semi-async', 'compounding', 'human-ai'] },
+ { c: [0.74, 0.24], s: [0.03, 0.035], ids: ['frameworks', 'pointing'] },
+ { c: [0.85, 0.62], s: [0.035, 0.04], ids: ['voice-ai', 'collections', 'extraction'] },
+ { c: [0.6, 0.83], s: [0.04, 0.035], ids: ['antigravity', 'january', 'paris'] },
+ ]),
+ labels: [['PLANNING & SLICING', 0.16, 0.09], ['SPEED & FLOW', 0.2, 0.93], ['BUILDING WITH AGENTS', 0.47, 0.29], ['AGENT FRAMEWORKS', 0.74, 0.05], ['VOICE & EXTRACTION', 0.83, 0.83], ['TOOLS & MODELS', 0.47, 0.95]],
+ pins: { product: [0.2, 0.5], cs: [0.42, 0.68], agents: [0.7, 0.46] },
+ },
+ {
+ id: 'formats', name: 'Formats',
+ note: 'Areas by kind of writing: essays, field notes, experiments. The three topics run across them, so the dots sit on the borders.',
+ patches: () => scatter(5, [
+ { c: [0.3, 0.5], s: [0.11, 0.13], ids: ['cost', 'planning', 'slicing', 'prolifics', 'how-long', 'faster', 'semi-async', 'spec', 'human-ai'] },
+ { c: [0.76, 0.27], s: [0.07, 0.07], ids: ['voice-ai', 'paris', 'antigravity', 'january', 'compounding'] },
+ { c: [0.74, 0.74], s: [0.06, 0.07], ids: ['frameworks', 'pointing', 'collections', 'extraction'] },
+ ]),
+ labels: [['ESSAYS', 0.18, 0.1], ['FIELD NOTES', 0.8, 0.06], ['EXPERIMENTS', 0.86, 0.95]],
+ pins: { product: [0.26, 0.5], agents: [0.74, 0.5, 'l'], cs: [0.48, 0.8] },
+ },
+ {
+ id: 'landmass', name: 'One landmass',
+ note: 'A single piece of land with three lobes, each named by the question it asks. Each dot sits on its own lobe.',
+ patches: () => scatter(14, [
+ { c: [0.17, 0.68], s: [0.06, 0.07], ids: ['cost', 'planning', 'slicing', 'prolifics', 'how-long', 'spec', 'compounding'] },
+ { c: [0.48, 0.3], s: [0.05, 0.06], ids: ['human-ai', 'faster', 'extraction'] },
+ { c: [0.8, 0.4], s: [0.07, 0.08], ids: ['frameworks', 'pointing', 'voice-ai', 'paris', 'antigravity', 'collections', 'january', 'semi-async'] },
+ ], [
+ ...Array.from({ length: 14 }, (_, i) => { const t = i / 13; return [0.16 + t * 0.66, 0.66 - 0.28 * t + 0.1 * Math.sin(t * Math.PI), 0.6]; }),
+ ...Array.from({ length: 5 }, (_, i) => [0.46 + i * 0.008, 0.52 - i * 0.045, 0.6]),
+ ]),
+ labels: [['WHY TEAMS SLOW DOWN', 0.18, 0.93], ['WHAT PEOPLE NEED', 0.48, 0.07], ['WHAT AGENTS CAN DO', 0.8, 0.13]],
+ pins: { product: [0.17, 0.68], cs: [0.48, 0.3], agents: [0.8, 0.42, 'l'] },
+ },
+];
+
+/* Round 7's ink: dark where patches gather, each patch r = 0.035 + 0.018 * sqrt(minutes) of the box (capped for the long report).
+ Also returns a mask of where there is ink at all: the shader leaves a faint dot even on blank paper, which round 7's
+ paper texture hid but a white page shows, so the print is masked to the inked areas. */
+function source(patches) {
+ const c = document.createElement('canvas'); c.width = W; c.height = H;
+ const g = c.getContext('2d'), d = g.createImageData(W, H), md = g.createImageData(W, H);
+ // Patches are [x, y, minutes] (round 7: radius from reading time) or [x, y, radius, weight] when placed by hand.
+ const ps = patches.map(([x, y, m, w]) => (w === undefined ? [x, y, Math.min(0.12, 0.035 + 0.018 * Math.sqrt(m)), 1] : [x, y, m, w]));
+ for (let py = 0; py < H; py++) for (let px = 0; px < W; px++) {
+ const u = px / W, v = py / H;
+ let s = 0;
+ for (const [x, y, r, w] of ps) s += w * Math.exp(-((u - x) ** 2 + (v - y) ** 2) / (2 * r * r));
+ s = Math.max(0, s);
+ const ink = 0.62 * (1 - Math.exp(-1.3 * s)), i = (py * W + px) * 4;
+ d.data[i] = d.data[i + 1] = d.data[i + 2] = 255 * (1 - ink); d.data[i + 3] = 255;
+ // The mask also fades over the outer 4% of the box, so land that reaches an edge thins out instead of stopping.
+ const e = Math.min(1, u / 0.04, (1 - u) / 0.04, v / 0.04, (1 - v) / 0.04), fade = e * e * (3 - 2 * e);
+ md.data[i + 3] = 255 * fade * Math.min(1, Math.max(0, (ink - 0.02) / 0.06));
+ }
+ g.putImageData(md, 0, 0);
+ const mask = c.toDataURL('image/png');
+ g.putImageData(d, 0, 0);
+ const img = new Image(); img.src = c.toDataURL('image/png');
+ return new Promise((r) => { img.onload = () => r({ img, mask }); });
+}
+// Round 7 printed with u_size 0.78 on a map about 420 px tall; keep that dot spacing whatever the size here.
+const PITCH = 7.8;
+const sizeFor = (h) => Math.pow((300 - Math.min(300, Math.max(7, h / PITCH))) / 293, 1 / 0.7);
+async function print(holder, spec) {
+ const P = await import('https://cdn.jsdelivr.net/npm/@paper-design/shaders@0.0.81/+esm');
+ const { img, mask } = await source(spec.patches());
+ for (const k of ['maskImage', 'webkitMaskImage']) holder.style[k] = `url(${mask})`;
+ for (const k of ['maskSize', 'webkitMaskSize']) holder.style[k] = '100% 100%';
+ return new P.ShaderMount(holder, P.halftoneDotsFragmentShader, {
+ u_image: img, u_colorFront: P.getShaderColorFromString('#9a9991'), u_colorBack: [0, 0, 0, 0],
+ u_size: sizeFor(holder.clientHeight || 300), u_radius: 1.0, u_contrast: 0.72, u_originalColors: false, u_inverted: false,
+ u_grainMixer: 0.15, u_grainOverlay: 0, u_grainSize: 0.5,
+ u_grid: P.HalftoneDotsGrids.hex, u_type: P.HalftoneDotsTypes.classic,
+ u_fit: P.ShaderFitOptions.cover, u_scale: 1, u_rotation: 0, u_offsetX: 0, u_offsetY: 0,
+ u_originX: 0.5, u_originY: 0.5, u_worldWidth: 0, u_worldHeight: 0,
+ }, { premultipliedAlpha: true, alpha: true }, 0);
+}
+
+// Round 7's labels and pins, unchanged: grey capitals on a pale ground; orange dots with a pale ring and an ink label.
+const CSS = `
+.tm { position: relative; aspect-ratio: ${W} / ${H}; font-family: var(--mono, Menlo, monospace); line-height: 1.6; }
+.tm-print, .tm-labels { position: absolute; inset: 0; }
+.tm-area { position: absolute; transform: translate(-50%, -50%); font-size: 10px; letter-spacing: 0.08em; color: var(--muted, #686862); background: rgba(244, 243, 238, 0.82); padding: 2px 6px; white-space: nowrap; pointer-events: none; }
+.tm-pin { position: absolute; width: 9px; height: 9px; margin: -4.5px 0 0 -4.5px; padding: 0; border: 0; border-radius: 50%; background: var(--accent, #b64b00); box-shadow: 0 0 0 3px rgba(244, 243, 238, 0.9); cursor: pointer; font: inherit; transition: opacity 0.2s; }
+.tm-pin span { position: absolute; left: 14px; top: -6px; font-size: 11px; color: var(--ink, #151515); white-space: nowrap; background: rgba(244, 243, 238, 0.82); padding: 0 4px; }
+.tm-pin.l span { left: auto; right: 14px; }
+.tm-pin:hover span, .tm-pin:focus-visible span, .tm-pin.on span { text-decoration: underline; text-underline-offset: 3px; }
+.tm-pin:focus { outline: none; }
+.tm-pin:focus-visible { outline: 2px solid var(--accent, #b64b00); outline-offset: 4px; }
+.tm.picking .tm-pin:not(.on) { opacity: 0.4; }
+`;
+let styled = false;
+function style() {
+ if (styled) return; styled = true;
+ const s = document.createElement('style'); s.textContent = CSS; document.head.append(s);
+}
+
+function mount(root, { kind, onPick } = {}) {
+ style();
+ root.innerHTML = '';
+ const spec = V.find((v) => v.id === kind) || V[0];
+ const tm = document.createElement('div'); tm.className = 'tm';
+ tm.setAttribute('role', 'group'); tm.setAttribute('aria-label', 'Map of the writing: Customer success, Product and AI agents');
+ tm.innerHTML = '';
+ root.append(tm);
+ const labels = tm.querySelector('.tm-labels'), pct = (x) => (x * 100).toFixed(2) + '%';
+ for (const [t, x, y] of spec.labels) {
+ const e = document.createElement('span'); e.className = 'tm-area'; e.textContent = t;
+ e.style.left = pct(x); e.style.top = pct(y); labels.append(e);
+ }
+ for (const p of PILLARS) {
+ const [x, y, side] = spec.pins[p.id];
+ const b = document.createElement('button'); b.type = 'button'; b.className = 'tm-pin' + (side === 'l' ? ' l' : ''); b.dataset.pick = p.id;
+ b.style.left = pct(x); b.style.top = pct(y);
+ b.innerHTML = `${p.name}`;
+ labels.append(b);
+ }
+ let shader = null, gone = false, pick = null;
+ print(tm.querySelector('.tm-print'), spec).then((m) => { if (gone) m.dispose(); else { shader = m; tm.__shader = m; } });
+ tm.addEventListener('click', (e) => { const b = e.target.closest('[data-pick]'); if (b) onPick?.(b.dataset.pick === pick ? null : b.dataset.pick); });
+ return {
+ setPick(id) {
+ pick = id;
+ tm.classList.toggle('picking', !!id);
+ tm.querySelectorAll('[data-pick]').forEach((b) => b.classList.toggle('on', b.dataset.pick === id));
+ },
+ destroy() { gone = true; shader?.dispose(); },
+ };
+}
+
+// On show for now: the three kept from the first round of variations, then three new ones built from their best parts.
+const SHOWN_IDS = ['south', 'spit', 'flats', 'best', 'roll', 'flatsouth'];
+window.TopicMap = { PILLARS, ARTICLES, inPillar, mount, SHOWN: SHOWN_IDS.map((id) => V.find((v) => v.id === id)).map(({ id, name, note, labels }) => ({ id, name, note, areas: labels.map((l) => l[0]) })) };
+})();
diff --git a/docs/redesign/prototype/template.html b/docs/redesign/prototype/template.html
new file mode 100644
index 0000000..3b0e1bf
--- /dev/null
+++ b/docs/redesign/prototype/template.html
@@ -0,0 +1,556 @@
+
+
+meaningfool Site Prototype
+
+
+
+
+
+
+
+
+
+
+
meaningfool.
+
+
+
+
+
Josselin Perrus · Product manager · Paris
+
I build products with AI agents.
+
I'm a product manager building AI products with AI. My projects are an opportunity to test ideas and learn in public.
+
About me →
LinkedInGitHubX
+
+
+
+
+
+
Projects
Things I built to learn
+
+
App · May 2026Voice Todos ↗
Talk, and your todo list builds itself as you speak.
+
Report · Feb 2026Agent SDKs for the Rest of Us ↗
A visual guide to agent frameworks for people who build products.
Comparing Models for Time-Sensitive Structured Extraction
+
+
A key aspect of my voice-todos app was to make it snappy: visual feedback is useful only if it's near instant. So how do you get close to this <500ms that provides this human-level interaction feeling? You need to optimize latency at all levels.
+
Optimizing LLM extraction time
+
I built my own, very simple, eval suite, made of 26 cases, to test if LLMs were able to capture some behaviours expected from my voice-todos-app:
+
Multi-todo extraction.
Over- and under-splitting.
Merge instructions following.
Correction of previous todos.
Cancellation.
+
The winner was Mistral Small 4: with a perfect score and 0.674s average latency. Latency is measured over the full case span, including failed runs.
There are 2 possible strategies to enforce the structure of an LLM response:
+
parse-after-generation
+
The provider shows the model a schema or a tool definition.
The model generates a text answer.
The provider parses the response to extract a schema-compliant json object.
The app validates the schema, retries or repairs the result if not ok.
+
constrained decoding
+
The model is added a constrained decoding library such as Outlines.
The provider shows the model a schema.
The library turns the schema into rules that run during token generation.
The model generates tokens as it normally would but the library masks invalid tokens so that only schema-compliant tokens may be chosen as an output.
+
+
+
PART OF THE PROJECTVoice Todos
Talk, and your todo list builds itself as you speak.
Try Voice Todos ↗
+
← PreviousThoughts on Human-AI Collaboration
Next →Editing and Reconciling Collections without IDs
+
+
meaningfool.
AboutLinkedInGitHubX
+
+
+
+
+
+
+
About
+
Customer success, Product, AI agents.
+
I'm Josselin Perrus, product manager turned builder. I started in customer-facing roles, spent seven years building the product at Linkurious (2026 exit), and the last three years building with AI.
+
+
+
+
+
+
+
Customer SuccessIdentify the value
+
+
I started in client-facing positions. Talking to customers and making them successful is still the highlight of my day. It's also where I learnt to listen and be pragmatic.
+
Listen, because when you have little leverage on the product, that's your only chance to narrow the problem enough that you may be able to find a solution.
+
Pragmatic, because a now-solution beats everyday a beautiful solution.
+
+
+
+
ProductDecide what to build
+
+
Linkurious grew from €1K / year / customer to over €100K / year / customer. Self-financed. As the Founding PM, I had to identify the highest leverage bets, and build alignment with the cofounders. And push back on the rest.
+
I build focus and clarity in fluid environments on a deep understanding of customers (120 interviews in the first 6 months at Linkurious), their problems, and how they create opportunities to meet the company strategy.
+
+
+
+
AI AgentsSoftware factory
+
+
My very first AI project was Dec. 2023: building a knowledge graph from a sum of articles using structured extraction. Later came a data lineage tool, a report on agent frameworks and 2 voice agent projects.
+
I build them end to end with coding agents, from the infrastructure to the prompts, from backend/frontend to benchmarks and evals.
+
I compound building my own set of tools such as my boundary model Skills (GitHub).
+
+
+
+
+
+
+
+
+
diff --git a/package.json b/package.json
index e2d2201..5f99bc5 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,10 @@
"@astrojs/markdown-remark": "^7.3.0",
"@astrojs/rss": "^4.0.19",
"astro": "^7.4.3",
- "remark-breaks": "^4.0.0"
+ "fontkitten": "^1.0.3",
+ "rehype-raw": "^7.0.0",
+ "remark-breaks": "^4.0.0",
+ "sharp": "^0.35.4"
},
"scripts": {
"test:documents": "node --test tests/*.test.mjs",
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/favicon.svg b/public/favicon.svg
deleted file mode 100644
index f157bd1..0000000
--- a/public/favicon.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-
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/public/og-default.jpg b/public/og-default.jpg
deleted file mode 100644
index d86e036..0000000
Binary files a/public/og-default.jpg and /dev/null differ
diff --git a/scripts/make-background.py b/scripts/make-background.py
new file mode 100644
index 0000000..4c9f35e
--- /dev/null
+++ b/scripts/make-background.py
@@ -0,0 +1,269 @@
+"""Make the data files the page background and the topic map are drawn from, in src/assets/background/.
+
+The prototype (docs/redesign/prototype/) computed all three in the browser and printed two of them with Paper Shaders'
+Halftone Dots shader. The site does that work here instead, once, and ships the results as small images. The browser
+only places the dots and draws them on a canvas (src/components/Background.astro and TopicMap.astro), with no shader
+library and no WebGL.
+
+- paper.png: a 140px tile of the paper texture, at 2x. It is the shader's output for a plain white image (hex grid,
+ "gooey" dots in a colour 87% of the way from #f2f1e8 to #2b2b2b, grain 61%, 10px dot spacing), computed pixel by
+ pixel as the shader does, then laid at 6% on the white page. The dots repeat every 14px; the grain is the shader's
+ value noise made to wrap around the tile, so the tile repeats without a seam.
+- corner.png: the ink of the corner pattern, the "Ripple" rings around the top-right corner, made with the prototype's
+ recipe (three rings, each 60% as dark as the one inside it, twist 165%). It is drawn on a polar grid around the corner:
+ one column per 1/4 degree, from along the top edge (left) to straight down the right edge (right), and 768 rows from
+ the corner out to 1.5 times the reach (142% of the window's short side). Each pixel is how big a dot is there, as a
+ share of the largest, after the prototype's blur and tone curve. The browser lays the dots on their own grid (7px
+ apart, at 14 degrees) and reads each dot's size from this image.
+- map.png: the topic map ("Best of three"), in the 420 x 300 box of the prototype: the image the prototype gave the
+ shader, 255 x (1 - ink), with the ink made from the land's patches in src/data/topic-map.json. The browser lays grey
+ dots 7.8px apart over the map's box and reads each dot's size from it, as the shader does.
+
+Run from the repository root, with numpy and Pillow installed (pip install numpy pillow):
+
+ python3 scripts/make-background.py
+
+The settings below are the prototype's. The same settings give the same files.
+"""
+import json
+import math
+import os
+
+import numpy as np
+from PIL import Image
+
+ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
+OUT = os.path.join(ROOT, "src", "assets", "background")
+MAP_SPEC = os.path.join(ROOT, "src", "data", "topic-map.json")
+
+
+def smoothstep(e0, e1, x):
+ t = np.clip((x - e0) / (e1 - e0), 0.0, 1.0)
+ return t * t * (3 - 2 * t)
+
+
+def hex_rgb(h):
+ return np.array([int(h[i:i + 2], 16) for i in (1, 3, 5)], dtype=float)
+
+
+def save_png(path, pixels):
+ """Grey levels, or RGB colours, which are saved with a palette of exactly the colours used."""
+ pixels = np.ascontiguousarray(pixels, dtype=np.uint8)
+ if pixels.ndim == 2:
+ image = Image.fromarray(pixels, "L")
+ else:
+ colours, index = np.unique(pixels.reshape(-1, 3), axis=0, return_inverse=True)
+ assert len(colours) <= 256
+ image = Image.fromarray(index.reshape(pixels.shape[:2]).astype(np.uint8), "P")
+ image.putpalette(colours.flatten().tolist())
+ image.save(path, optimize=True)
+ print(os.path.relpath(path, ROOT), os.path.getsize(path), "bytes")
+
+
+# Paper ---------------------------------------------------------------------------------------------------------------
+
+PAPER = {
+ "back": "#f2f1e8", # the paper
+ "ink": "#2b2b2b",
+ "dots": 87, # dot colour, % of the way from the paper to the ink
+ "grain": 61, # grain overlay, %
+ "spacing": 10, # px, the setting the prototype turns into the shader's size
+ "opacity": 6, # % the layer is shown at, over the white page
+ "radius": 1.25,
+ "contrast": 0.4,
+ "grain_mixer": 0.2,
+ "grain_size": 0.5,
+}
+# For gooey dots on the hex grid, the shader's cells are 6 x 0.7 of the spacing (42px), with a dot every 1/6 of a cell
+# (7px) where its row and column add up to an even number: dots 9.9px apart, a pattern that repeats every 14px.
+STEP = PAPER["spacing"] * 0.7
+PERIOD = 2 * STEP
+TILE = 140 # CSS px: ten repeats of the pattern
+SCALE = 2 # device pixels per CSS px, as the shader renders at least at 2x
+# The shader's grain has mix(2000, 200, grain size) = 1100 cells across the window: 1.31px on a 1440px wide one.
+GRAIN_PX = 1440 / (2000 + (200 - 2000) * PAPER["grain_size"])
+GRAIN_CELLS = round(TILE / GRAIN_PX)
+
+
+def wrapping_noise(cells, size, seed):
+ """Value noise as the shader's valueNoise(), smooth between random values on a grid of cells x cells that wraps."""
+ values = np.random.default_rng(seed).random((cells, cells))
+ p = (np.arange(size) + 0.5) / size * cells
+ i = np.floor(p).astype(int)
+ f = p - i
+ f = f * f * (3 - 2 * f)
+ i0, i1 = i % cells, (i + 1) % cells
+ a = values[np.ix_(i0, i0)]
+ b = values[np.ix_(i0, i1)]
+ c = values[np.ix_(i1, i0)]
+ d = values[np.ix_(i1, i1)]
+ fx, fy = f[None, :], f[:, None]
+ return (a * (1 - fx) + b * fx) * (1 - fy) + (c * (1 - fx) + d * fx) * fy
+
+
+def paper_tile():
+ """The Halftone Dots shader (gooey dots, hex grid) on a plain white image, as in the shader's source."""
+ size = TILE * SCALE
+ xy = (np.arange(size) + 0.5) / SCALE # CSS px
+ x, y = np.meshgrid(xy, xy)
+ # Distance to the nearest dot: dots at (STEP i, STEP j) with i + j even.
+ dist = np.full(x.shape, np.inf)
+ for ox, oy in ((0, 0), (PERIOD, 0), (0, PERIOD), (PERIOD, PERIOD), (STEP, STEP)):
+ dist = np.minimum(dist, np.hypot(np.mod(x, PERIOD) - ox, np.mod(y, PERIOD) - oy))
+ contrast = 15 * PAPER["contrast"] ** 1.5
+ lum = 1 / (1 + math.exp(-contrast * (1 - 0.5))) # white, through the shader's sigmoid
+ size_radius = 0.42 * PAPER["radius"] * (1 - lum) # in cells
+ d = dist / (6 * STEP) # in cells
+ shape = (1 - smoothstep(0, size_radius, d)) ** (2 + PAPER["radius"])
+ gy, gx = np.gradient(shape)
+ aa = np.maximum(np.abs(gx) + np.abs(gy), 1e-9) # the shader's fwidth()
+ shape = smoothstep(0.5 - aa, 0.5 + aa, shape)
+
+ grain = smoothstep(0.55, 0.7 + 0.2 * PAPER["grain_mixer"], wrapping_noise(GRAIN_CELLS, size, 1))
+ shape = shape * (1 - grain * PAPER["grain_mixer"])
+
+ back = hex_rgb(PAPER["back"]) / 255
+ ink = hex_rgb(PAPER["ink"])
+ front = np.round(hex_rgb(PAPER["back"]) + (ink - hex_rgb(PAPER["back"])) * PAPER["dots"] / 100) / 255
+ color = front * shape[..., None] + back * (1 - shape[..., None])
+
+ overlay = 0.5 * (wrapping_noise(GRAIN_CELLS, size, 2) + wrapping_noise(GRAIN_CELLS, size, 3))
+ v = overlay ** 1.3 * 2 - 1
+ strength = 0.5 * (PAPER["grain"] / 100 * np.abs(v)) ** 0.8
+ target = (v >= 0).astype(float)[..., None]
+ color = color + (target - color) * strength[..., None]
+
+ alpha = PAPER["opacity"] / 100
+ page = 1 - alpha + alpha * color # over white
+ return np.round(np.clip(page, 0, 1) * 255)
+
+
+# Corner pattern ------------------------------------------------------------------------------------------------------
+
+# The dot spacing and the reach are also in Background.astro, which lays the dots out (with the grid at 14 degrees).
+CORNER = {"spacing": 7, "reach": 142, "rings": 3, "fade": 60, "twist": 165}
+# The prototype's darkness response (line density, blur in dot spacings, tone curve) and its pixel grid, 1/4 of the dot
+# spacing.
+FIT = {
+ "dens": 0.1407, "gain": 0.784, "gamma": 0.7615, "blur": 0.22,
+ "tone": [[0, 0], [0.203, 0.179], [0.225, 0.195], [0.257, 0.223], [0.295, 0.285], [0.331, 0.33], [0.381, 0.413],
+ [0.427, 0.44], [0.451, 0.46], [0.468, 0.483], [0.49, 0.507], [0.519, 0.542], [0.554, 0.59], [0.6, 0.646],
+ [0.649, 0.712], [0.717, 0.841], [0.763, 0.956], [1, 1]],
+}
+CELL = CORNER["spacing"] / 4
+# Corner rings, inner first: each ring's five curves give the distance from the corner (in reach units) at nine angles,
+# from along the top edge (first) to straight down the right edge (last).
+RIPPLE = [
+ [[0.204, 0.197, 0.189, 0.185, 0.186, 0.195, 0.208, 0.224, 0.238], [0.156, 0.201, 0.285, 0.368, 0.411, 0.394, 0.325, 0.236, 0.17],
+ [0.495, 0.582, 0.617, 0.583, 0.496, 0.394, 0.321, 0.311, 0.37], [0.463, 0.413, 0.369, 0.34, 0.33, 0.342, 0.373, 0.419, 0.471],
+ [0.483, 0.465, 0.448, 0.439, 0.441, 0.453, 0.471, 0.486, 0.494]],
+ [[0.442, 0.458, 0.485, 0.516, 0.541, 0.555, 0.555, 0.542, 0.522], [0.713, 0.716, 0.714, 0.699, 0.668, 0.618, 0.552, 0.476, 0.401],
+ [0.664, 0.68, 0.689, 0.685, 0.663, 0.624, 0.57, 0.51, 0.457], [0.475, 0.416, 0.365, 0.328, 0.31, 0.313, 0.336, 0.374, 0.422],
+ [0.77, 0.775, 0.787, 0.802, 0.814, 0.818, 0.813, 0.801, 0.787]],
+ [[0.8, 0.804, 0.82, 0.843, 0.863, 0.874, 0.869, 0.849, 0.822], [0.622, 0.751, 0.898, 1.033, 1.129, 1.168, 1.147, 1.072, 0.96],
+ [0.968, 1.061, 1.188, 1.306, 1.376, 1.377, 1.308, 1.192, 1.067], [1.407, 1.413, 1.397, 1.354, 1.286, 1.202, 1.113, 1.034, 0.979],
+ [1.068, 1.097, 1.121, 1.131, 1.124, 1.101, 1.071, 1.043, 1.027]],
+]
+ANGLES = 360 # columns, half the prototype's polar grid: finer gives the same dots
+ROWS = 768 # rows, from the corner out to RMAX: 2.5px each on a 1440 x 900 window
+RMAX = 1.5 # reach units: the outer ring ends at 1.43 (Background.astro's ROWS_REACH)
+REACH_PX = 900 * CORNER["reach"] / 100 # the reach on a 1440 x 900 window, where the blur is measured
+SAMPLES = 1200 # points along each line of a bundle
+
+
+def twisted(curves, pct):
+ """Twist scales how far the inner curves swing from the edges' midline."""
+ c = np.array(curves, dtype=float)
+ mid = (c[0] + c[-1]) / 2
+ out = mid + (c - mid) * pct / 100
+ out[0], out[-1] = c[0], c[-1]
+ return out
+
+
+def catmull_rom(vs, u):
+ """Smooth curve through values at evenly spaced points (Catmull-Rom, clamped ends), as the prototype's curve()."""
+ n = len(vs) - 1
+ i = np.clip(np.floor(u * n).astype(int), 0, n - 1)
+ t = np.clip(u * n - i, 0, 1)
+ p0, p1, p2, p3 = vs[np.maximum(i - 1, 0)], vs[i], vs[i + 1], vs[np.minimum(i + 2, n)]
+ return 0.5 * (2 * p1 + (-p0 + p2) * t + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t * t + (-p0 + 3 * p1 - 3 * p2 + p3) * t ** 3)
+
+
+def tone(d):
+ t = np.array(FIT["tone"])
+ return np.interp(d, t[:, 0], t[:, 1])
+
+
+def corner_field():
+ """The prototype's cornerInk() and blur, on a polar grid in reach units instead of the window's pixels."""
+ row = RMAX / ROWS # reach units per row
+ u = (np.arange(ANGLES) + 0.5) / ANGLES
+ s = (np.arange(SAMPLES) + 0.5) / SAMPLES
+ bern = np.stack([math.comb(4, k) * s ** k * (1 - s) ** (4 - k) for k in range(5)], axis=1) # SAMPLES x 5
+ polar = np.zeros((ROWS + 1, ANGLES))
+ cols = np.broadcast_to(np.arange(ANGLES), (SAMPLES, ANGLES))
+ for j, ring in enumerate(RIPPLE[:CORNER["rings"]]):
+ cv = np.stack([catmull_rom(c, u) for c in twisted(ring, CORNER["twist"])]) # 5 x ANGLES
+ y = bern @ cv # SAMPLES x ANGLES: each line of the bundle, in reach units
+ share = FIT["dens"] * (CORNER["fade"] / 100) ** j / SAMPLES / row
+ fy = y / row - 0.5
+ i0 = np.floor(fy).astype(int)
+ t = fy - i0
+ for rows, weight in ((i0, 1 - t), (i0 + 1, t)):
+ ok = (rows >= 0) & (rows <= ROWS)
+ np.add.at(polar, (rows[ok], cols[ok]), weight[ok] * share)
+ ink = 1 - np.exp(-polar[:ROWS])
+
+ # The prototype blurs the ink on the window's grid, by 0.88 of a 1.75px cell. Do the same here around each point of
+ # the polar grid, for the reach of a 1440 x 900 window: along the radius, then along each arc.
+ sigma_px = FIT["blur"] * 4 * CELL
+ ink = blur_rows(ink.T, sigma_px / (row * REACH_PX)).T
+ radius = (np.arange(ROWS) + 0.5) * row * REACH_PX # px from the corner
+ per_col = (math.pi / 2) / ANGLES * np.maximum(radius, 1) # px along the arc per column
+ for r in range(ROWS):
+ ink[r] = blur_rows(ink[r][None, :], min(40, sigma_px / per_col[r]))[0]
+
+ dark = tone(np.minimum(1, FIT["gain"] * ink) ** FIT["gamma"])
+ assert dark[-8:].max() == 0, "the outer ring reaches past RMAX"
+ return np.round(dark * 255)
+
+
+def blur_rows(a, sigma):
+ """Gaussian blur along the last axis, with nothing beyond the ends (as the prototype's blur1d)."""
+ if sigma < 0.05:
+ return a
+ r = int(math.ceil(3 * sigma))
+ k = np.exp(-np.arange(-r, r + 1) ** 2 / (2 * sigma * sigma))
+ k /= k.sum()
+ return np.stack([np.convolve(line, k, mode="same") for line in a])
+
+
+# Topic map -----------------------------------------------------------------------------------------------------------
+
+
+def map_image():
+ """The prototype's source(): ink where the patches gather. (Its mask of where there is ink at all, which also fades
+ the land over the outer 4% of the box, is computed from this image in the browser.)"""
+ spec = json.load(open(MAP_SPEC))
+ w, h = spec["box"]
+ k = spec["fit"]["scale"]
+ cx, cy = spec["fit"]["centre"]
+ # "Best of three" runs past the right edge of the box, so it is drawn 7% smaller about the centre of its land.
+ patches = [(0.5 + (x - cx) * k, 0.5 + (y - cy) * k, r * k, wt) for x, y, r, wt in spec["patches"]]
+ u, v = np.meshgrid(np.arange(w) / w, np.arange(h) / h)
+ s = np.zeros_like(u)
+ for x, y, r, wt in patches:
+ s += wt * np.exp(-((u - x) ** 2 + (v - y) ** 2) / (2 * r * r))
+ ink = 0.62 * (1 - np.exp(-1.3 * np.maximum(0, s)))
+ return np.round(255 * (1 - ink))
+
+
+def main():
+ os.makedirs(OUT, exist_ok=True)
+ save_png(os.path.join(OUT, "paper.png"), paper_tile())
+ save_png(os.path.join(OUT, "corner.png"), corner_field())
+ save_png(os.path.join(OUT, "map.png"), map_image())
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/subset-fonts.py b/scripts/subset-fonts.py
new file mode 100644
index 0000000..bc145fd
--- /dev/null
+++ b/scripts/subset-fonts.py
@@ -0,0 +1,106 @@
+"""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. The build also draws the share images
+ and the favicon from it (src/lib/mono.ts), so a character missing from it shows as a box in a share image.
+- 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/assets/background/corner.png b/src/assets/background/corner.png
new file mode 100644
index 0000000..a70d01c
Binary files /dev/null and b/src/assets/background/corner.png differ
diff --git a/src/assets/background/map.png b/src/assets/background/map.png
new file mode 100644
index 0000000..d5ced29
Binary files /dev/null and b/src/assets/background/map.png differ
diff --git a/src/assets/background/paper.png b/src/assets/background/paper.png
new file mode 100644
index 0000000..f3b18ef
Binary files /dev/null and b/src/assets/background/paper.png differ
diff --git a/src/assets/projects/agent-sdks.jpg b/src/assets/projects/agent-sdks.jpg
new file mode 100644
index 0000000..bca5c5e
Binary files /dev/null and b/src/assets/projects/agent-sdks.jpg differ
diff --git a/src/assets/projects/data-lineage.jpg b/src/assets/projects/data-lineage.jpg
new file mode 100644
index 0000000..87cd77e
Binary files /dev/null and b/src/assets/projects/data-lineage.jpg differ
diff --git a/src/assets/projects/voice-todos.jpg b/src/assets/projects/voice-todos.jpg
new file mode 100644
index 0000000..27050ee
Binary files /dev/null and b/src/assets/projects/voice-todos.jpg differ
diff --git a/src/components/ArticlesList.astro b/src/components/ArticlesList.astro
deleted file mode 100644
index 3d12bdc..0000000
--- a/src/components/ArticlesList.astro
+++ /dev/null
@@ -1,73 +0,0 @@
----
-import type { CollectionEntry } from 'astro:content';
-
-export interface Props {
- articles: CollectionEntry<'writing'>[];
-}
-
-const { articles } = Astro.props;
----
-
-
-
-
\ No newline at end of file
diff --git a/src/components/Background.astro b/src/components/Background.astro
new file mode 100644
index 0000000..57fc802
--- /dev/null
+++ b/src/components/Background.astro
@@ -0,0 +1,269 @@
+---
+// The page background, fixed behind every page's frame: the paper texture, and the corner pattern (rings of dots
+// around the top-right corner) at 6%. scripts/make-background.py prepares both: the paper as a tile, the pattern's
+// ink as an image the script below reads each dot's size from. No shader library, no WebGL.
+//
+// The pattern alone makes way, so the paper keeps going: around the topic map (TopicMap.astro), clear inside the
+// ellipse that fits the map's box and back to full strength at 1.55 times that size; on a page with the centred 720px
+// text column (textColumn), behind a band as wide as the column, fading out between 16px and 112px on each side.
+//
+// With `persist`, going between Home and About without a full page load (IntroRouter.astro), the router keeps this
+// element as it is drawn, and its hole moves to the next page's map. A page with the text column has its own mask, so
+// it never shares the element.
+interface Props {
+ /** The page has the centred 720px text column of articles: the pattern is cleared behind it. */
+ textColumn?: boolean;
+ /** Keep the element, as drawn, when the router (IntroRouter.astro) goes to another page that keeps it. */
+ persist?: boolean;
+}
+
+const { textColumn = false, persist = false } = Astro.props;
+// The attribute that transition:persist sets. The directive itself would also add Astro's view transition styles to
+// every page with the background, the 404 page and /documents/ included, which never use the router.
+const keep = persist && !textColumn ? 'page-background' : undefined;
+---
+
+
+
+
+
+
+
+
+
diff --git a/src/components/Favicons.astro b/src/components/Favicons.astro
new file mode 100644
index 0000000..823cd14
--- /dev/null
+++ b/src/components/Favicons.astro
@@ -0,0 +1,8 @@
+---
+// The favicon links, drawn at build time by src/lib/favicon.ts: the SVG for the browsers that take one, favicon.ico
+// for the others (its sizes keep Chrome on the SVG), and the icon iPhones and iPads put on the home screen.
+---
+
+
+
+
diff --git a/src/components/Footer.astro b/src/components/Footer.astro
new file mode 100644
index 0000000..a57720d
--- /dev/null
+++ b/src/components/Footer.astro
@@ -0,0 +1,49 @@
+---
+// The site footer: the wordmark, then About and the profiles elsewhere. On phones the links go under the wordmark.
+import Wordmark from './Wordmark.astro';
+import { profiles } from '../data/profiles';
+
+const links = [{ href: '/about/', label: 'About' }, ...profiles];
+---
+
+
+
+
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';
+---
+
+
+---
+// The site header: the wordmark on the left, the navigation on the right. The current page's link is in ink,
+// underlined in the accent colour. The navigation stays visible on phones.
+import Wordmark from './Wordmark.astro';
+
+const links = [
+ { href: '/', label: 'Home' },
+ { href: '/about/', label: 'About' },
+];
+const here = Astro.url.pathname.replace(/\/?$/, '/');
+---
+
+
+
+
-
\ No newline at end of file
+
diff --git a/src/components/Intro.astro b/src/components/Intro.astro
new file mode 100644
index 0000000..39b04b8
--- /dev/null
+++ b/src/components/Intro.astro
@@ -0,0 +1,104 @@
+---
+// The intro of Home and About: the text on the left at 500px, the topic map on the right, from 480px to 636px wide.
+// The map is taller than the text and spills 84px above and below it without making the intro taller. Below 1120px
+// the map goes under the text, up to 560px wide. The columns are top-aligned, so the label, the title and the map sit
+// at the same place on both pages whatever the length of the text.
+//
+//
+// Customer success, Product, AI agents.
+// I'm Josselin Perrus, product manager turned builder…
+//
…
+//
+//
+// - label: the small line above the title.
+// - slot "title": the title, inside the
. A in it is in the accent colour, for the full stop.
+// - default slot: the text under the title, in muted grey, up to 52 characters wide.
+// - slot "after": anything under the text, such as buttons and links. It comes 18px under the text.
+// - links, mapLabel: passed to TopicMap, to make the orange dots links and to name the map for screen readers.
+import TopicMap, { type Topic } from './TopicMap.astro';
+
+interface Props {
+ label: string;
+ links?: Partial>;
+ mapLabel?: string;
+}
+
+const { label, links, mapLabel } = Astro.props;
+---
+
+
+
+
{label}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/IntroRouter.astro b/src/components/IntroRouter.astro
new file mode 100644
index 0000000..ae76800
--- /dev/null
+++ b/src/components/IntroRouter.astro
@@ -0,0 +1,32 @@
+---
+// Going between Home and About without a full page load, so that the topic map, at the same place on both pages, is
+// moved rather than drawn again, and the background with it. Layout renders this in the of those two pages
+// (src/lib/intro-pages.ts). It is Astro's ClientRouter, held to them:
+// - A link between them swaps the page in place. TopicMap carries its canvas over to the next page's map, and
+// Background keeps its element (data-astro-transition-persist); everything else is the next page's own.
+// - A link from them to any other page loads it in full, as before, without the router fetching it first. Other pages
+// don't have the router, so they load every page in full.
+// - The page changes at once, as in the prototype: no cross-fade, and a plain swap where view transitions are missing.
+// - Nothing is prefetched (prefetch: false in astro.config.mjs), as before.
+//
+// A page's own script runs once, the first time the page is shown, and stays: a listener it adds to document or
+// window also hears the other page's events, and anything it found in the page is gone once the router swaps it.
+import { ClientRouter } from 'astro:transitions';
+---
+
+
+
+
+
+{/* Astro's transition:animate="none", for the whole page. Inline, so that only these pages have it. */}
+
diff --git a/src/components/NotFound.astro b/src/components/NotFound.astro
index 7c226b9..786ce7f 100644
--- a/src/components/NotFound.astro
+++ b/src/components/NotFound.astro
@@ -1,5 +1,55 @@
-
-
+---
+// The 404 message, inside the page frame, on /404.html and in the not-found view of /documents/, which must look the
+// same. Its styles don't rely on the base styles, which /documents/ doesn't load.
+interface Props {
+ id?: string;
+}
+
+const { id } = Astro.props;
+---
+
+
+
+
+
diff --git a/src/components/PrevNext.astro b/src/components/PrevNext.astro
new file mode 100644
index 0000000..d50e866
--- /dev/null
+++ b/src/components/PrevNext.astro
@@ -0,0 +1,74 @@
+---
+// Previous and Next at the end of a post: the older post on the left, the newer one on the right.
+import type { CollectionEntry } from 'astro:content';
+
+type Post = CollectionEntry<'writing'>;
+
+interface Props {
+ previous?: Post;
+ next?: Post;
+}
+
+const { previous, next } = Astro.props;
+const links = [
+ { post: previous, label: '← Previous', rel: 'prev' },
+ { post: next, label: 'Next →', rel: 'next' },
+];
+---
+
+{(previous || next) && (
+
+)}
+
+
diff --git a/src/components/ProjectPanel.astro b/src/components/ProjectPanel.astro
new file mode 100644
index 0000000..52f3020
--- /dev/null
+++ b/src/components/ProjectPanel.astro
@@ -0,0 +1,90 @@
+---
+// "Part of the project", after an article that belongs to one: its screenshot, name, one line and link. On phones the
+// screenshot goes above the text, full width.
+import { Image } from 'astro:assets';
+import type { Project } from '../data/projects';
+
+interface Props {
+ project: Project;
+}
+
+const { project } = Astro.props;
+---
+
+
+
+
diff --git a/src/components/TopicMap.astro b/src/components/TopicMap.astro
new file mode 100644
index 0000000..330428d
--- /dev/null
+++ b/src/components/TopicMap.astro
@@ -0,0 +1,340 @@
+---
+// The topic map, "Best of three": three islands in grey halftone dots, named in grey capitals, with an orange dot
+// for each of the site's three topics. scripts/make-background.py turns the land (src/data/topic-map.json) into
+// map.png; the script below lays the dots 7.8px apart over the map's box, whatever its size, and reads each dot's size
+// from that image, as the prototype's shader did. No shader library, no WebGL.
+//
+// The orange dots are the map's interface (see the PR for #30, and Intro.astro):
+// - Each dot has its topic in data-topic: customer-success, product or ai-agents.
+// - Clicking one sends a "topicmap:pick" event from the element. It bubbles, and event.detail.topic is the
+// dot's topic. It can be cancelled: calling preventDefault() stops a link dot from following its link.
+// - With `links`, the dots are links, for example to the matching part of a page; otherwise they are buttons.
+// - Setting data-picked="" on the element marks that dot as picked and dims the others; removing it
+// clears the pick. Button dots get aria-pressed to match.
+//
+// Going between Home and About without a full page load (IntroRouter.astro), the router keeps the drawing: the canvas
+// is carried over (data-astro-transition-persist) into the next page's , which is otherwise that page's
+// own, with its own dots (buttons or links), no data-picked and no listeners. Nothing is drawn again unless the map's
+// size changed.
+import spec from '../data/topic-map.json';
+import mapUrl from '../assets/background/map.png?url';
+
+export type Topic = 'customer-success' | 'product' | 'ai-agents';
+
+interface Props {
+ /** Turns the dots into links, for example { 'customer-success': '#customer-success' }. */
+ links?: Partial>;
+ /** What screen readers announce for the map. */
+ label?: string;
+}
+
+const { links = {}, label = 'Map of the writing: Customer success, Product and AI agents' } = Astro.props;
+const [boxW, boxH] = spec.box;
+
+// "Best of three" runs past the right edge of its box, so the prototype draws it 7% smaller about the centre of its
+// land. Labels and dots move with it (make-background.py does the same to the land).
+const { scale, centre } = spec.fit;
+const place = ([x, y]: number[]) => {
+ const at = (v: number, c: number) => ((0.5 + (v - c) * scale) * 100).toFixed(2) + '%';
+ return `left: ${at(x, centre[0])}; top: ${at(y, centre[1])}`;
+};
+const topics = spec.topics.map((t) => ({ ...t, id: t.id as Topic, href: links[t.id as Topic] }));
+---
+
+
+
+ {/* data-astro-transition-persist is what transition:persist sets, without Astro's view transition styles. */}
+
+
+
+
+
+
+
diff --git a/src/components/Wordmark.astro b/src/components/Wordmark.astro
new file mode 100644
index 0000000..161b39f
--- /dev/null
+++ b/src/components/Wordmark.astro
@@ -0,0 +1,29 @@
+---
+// The wordmark, "meaningfool." with the full stop in the accent colour, linking to the home: 22px in the header,
+// 16px (small) in the footer.
+interface Props {
+ small?: boolean;
+}
+
+const { small = false } = Astro.props;
+---
+
+meaningfool.
+
+
diff --git a/src/content.config.ts b/src/content.config.ts
index 48e6074..1c7cebd 100644
--- a/src/content.config.ts
+++ b/src/content.config.ts
@@ -2,6 +2,7 @@ import { defineCollection } from 'astro:content';
import { z } from 'astro/zod';
import { glob } from 'astro/loaders';
import { postId } from './lib/posts.mjs';
+import { TOPICS } from './data/topics';
const writing = defineCollection({
loader: glob({
@@ -23,6 +24,9 @@ const writing = defineCollection({
updated: z.date().optional(),
description: z.string().optional(),
tags: z.array(z.string()).optional(),
+ // The topics an article is about, each one 30% or more of it: customer-success, product, ai-agents. The home's
+ // topic map filters Writing with them. Articles without the field take theirs from src/data/topics.ts.
+ topics: z.array(z.enum(TOPICS)).optional(),
}),
});
diff --git a/src/data/profiles.ts b/src/data/profiles.ts
new file mode 100644
index 0000000..bcc83f9
--- /dev/null
+++ b/src/data/profiles.ts
@@ -0,0 +1,6 @@
+// Josselin's profiles elsewhere, linked from the footer and the home's intro.
+export const profiles = [
+ { 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/data/projects.ts b/src/data/projects.ts
new file mode 100644
index 0000000..cb2b177
--- /dev/null
+++ b/src/data/projects.ts
@@ -0,0 +1,72 @@
+// The projects shown on the site, newest first: the "Part of the project" panel and the project in the meta line of
+// an article (#32), the Projects tiles of the home (#31) and the project count of the share images (#34).
+//
+// Which articles belong to which project is kept here, by article id (the file name without its date), because the
+// articles' frontmatter in meaningfool-writing has no field for it.
+import type { ImageMetadata } from 'astro';
+import voiceTodos from '../assets/projects/voice-todos.jpg';
+import agentSdks from '../assets/projects/agent-sdks.jpg';
+import dataLineage from '../assets/projects/data-lineage.jpg';
+
+export interface Project {
+ slug: string;
+ name: string;
+ /** A shorter name, for narrow columns such as the home's Writing list. */
+ shortName: string;
+ type: 'App' | 'Report';
+ /** The month it came out, as YYYY-MM. */
+ date: string;
+ /** One line that says what it is. */
+ description: string;
+ link: string;
+ /** The text of the link, without the ↗. */
+ linkText: string;
+ /** A 16:10 screenshot. */
+ screenshot: ImageMetadata;
+ /** The ids of the articles written about it. */
+ articles: string[];
+}
+
+export const projects: Project[] = [
+ {
+ slug: 'voice-todos',
+ name: 'Voice Todos',
+ shortName: 'Voice Todos',
+ type: 'App',
+ date: '2026-05',
+ description: 'Talk, and your todo list builds itself as you speak.',
+ link: 'https://voice-todos.meaningfool.net/',
+ linkText: 'Try Voice Todos',
+ screenshot: voiceTodos,
+ articles: ['edit-collections-generative-ai', 'structured-extraction', 'thoughts-on-human-ai-collaboration'],
+ },
+ {
+ slug: 'agent-sdks',
+ name: 'Agent SDKs for the Rest of Us',
+ shortName: 'Agent SDKs',
+ type: 'Report',
+ date: '2026-02',
+ description: 'A visual guide to agent frameworks for people who build products.',
+ link: 'https://agent-frameworks-report.meaningfool.net/',
+ linkText: 'Read the report',
+ screenshot: agentSdks,
+ articles: ['agent-frameworks-for-the-rest-of-us'],
+ },
+ {
+ slug: 'data-lineage',
+ name: 'Data Lineage',
+ shortName: 'Data Lineage',
+ type: 'App',
+ date: '2025-12',
+ description: 'CSV files in, an interactive lineage graph out.',
+ link: 'https://lineage.meaningfool.net/',
+ linkText: 'Try Data Lineage',
+ screenshot: dataLineage,
+ articles: ['antigravity_is_onto_something'],
+ },
+];
+
+/** The project an article belongs to, if any. */
+export function projectOf(articleId: string): Project | undefined {
+ return projects.find((project) => project.articles.includes(articleId));
+}
diff --git a/src/data/topic-map.json b/src/data/topic-map.json
new file mode 100644
index 0000000..74418e8
--- /dev/null
+++ b/src/data/topic-map.json
@@ -0,0 +1,83 @@
+{
+ "box": [420, 300],
+ "fit": { "scale": 0.927, "centre": [0.546, 0.533] },
+ "topics": [
+ {"id": "customer-success", "name": "Customer success", "area": "IDENTIFY THE VALUE", "areaAt": [0.2, 0.15], "dotAt": [0.2, 0.25], "nameSide": "right"},
+ {"id": "product", "name": "Product", "area": "DECIDE WHAT TO BUILD", "areaAt": [0.45, 0.91], "dotAt": [0.45, 0.81], "nameSide": "right"},
+ {"id": "ai-agents", "name": "AI agents", "area": "SOFTWARE FACTORY", "areaAt": [0.85, 0.33], "dotAt": [0.85, 0.44], "nameSide": "left"}
+ ],
+ "patches": [
+ [0.2, 0.25, 0.12, 0.72],
+ [0.3, 0.207, 0.087, 0.3],
+ [0.131, 0.213, 0.076, 0.307],
+ [0.23, 0.315, 0.067, 0.353],
+ [0.191, 0.242, 0.04, 0.45],
+ [0.306, 0.258, 0.035, 0.749],
+ [0.226, 0.337, 0.033, 0.462],
+ [0.085, 0.304, 0.029, 0.384],
+ [0.059, 0.289, 0.054, -0.7],
+ [0.226, 0.383, 0.037, 0.782],
+ [0.239, 0.462, 0.043, 0.487],
+ [0.255, 0.527, 0.042, 0.829],
+ [0.269, 0.582, 0.044, 0.915],
+ [0.303, 0.655, 0.034, 0.624],
+ [0.329, 0.711, 0.045, 0.647],
+ [0.45, 0.81, 0.11, 0.82],
+ [0.44, 0.857, 0.074, 0.396],
+ [0.389, 0.83, 0.076, 0.453],
+ [0.359, 0.836, 0.061, 0.385],
+ [0.459, 0.805, 0.04, 0.6],
+ [0.519, 0.776, 0.032, 0.767],
+ [0.557, 0.749, 0.024, 0.522],
+ [0.462, 0.666, 0.022, 0.676],
+ [0.561, 0.671, 0.021, 0.471],
+ [0.379, 0.831, 0.034, 0.451],
+ [0.52, 0.63, 0.042, 0.95],
+ [0.545, 0.54, 0.036, 0.8],
+ [0.565, 0.46, 0.028, 0.62],
+ [0.58, 0.4, 0.016, 0.9],
+ [0.566, 0.726, 0.037, 0.615],
+ [0.592, 0.684, 0.034, 0.665],
+ [0.62, 0.663, 0.043, 0.352],
+ [0.673, 0.63, 0.038, 0.63],
+ [0.716, 0.594, 0.034, 0.529],
+ [0.85, 0.44, 0.13, 0.8],
+ [0.967, 0.431, 0.092, 0.343],
+ [0.761, 0.462, 0.075, 0.397],
+ [0.817, 0.518, 0.088, 0.429],
+ [0.856, 0.446, 0.04, 0.62],
+ [0.786, 0.325, 0.038, 0.63],
+ [0.925, 0.229, 0.031, 0.717],
+ [0.858, 0.275, 0.025, 0.476],
+ [0.863, 0.515, 0.036, 0.597],
+ [0.939, 0.474, 0.027, 0.332],
+ [0.333, 0.253, 0.039, 0.39],
+ [0.385, 0.264, 0.031, 0.368],
+ [0.464, 0.272, 0.028, 0.33],
+ [0.506, 0.259, 0.03, 0.361],
+ [0.563, 0.262, 0.036, 0.383],
+ [0.594, 0.282, 0.036, 0.335],
+ [0.651, 0.287, 0.027, 0.341],
+ [0.691, 0.317, 0.035, 0.339],
+ [0.374, 0.351, 0.039, 0.324],
+ [0.406, 0.383, 0.035, 0.305],
+ [0.468, 0.387, 0.037, 0.286],
+ [0.528, 0.401, 0.034, 0.277],
+ [0.559, 0.417, 0.033, 0.328],
+ [0.624, 0.411, 0.027, 0.273],
+ [0.684, 0.421, 0.039, 0.343],
+ [0.565, 0.352, 0.012, 0.725],
+ [0.602, 0.364, 0.009, 0.764],
+ [0.463, 0.32, 0.016, 1.286],
+ [0.634, 0.37, 0.01, 0.677],
+ [0.518, 0.342, 0.01, 0.881],
+ [0.454, 0.32, 0.01, 0.699],
+ [0.681, 0.364, 0.012, 0.768],
+ [0.419, 0.286, 0.013, 0.643],
+ [0.435, 0.314, 0.021, 1.158],
+ [0.372, 0.274, 0.01, 0.81],
+ [0.05, 0.55, 0.018, 0.895],
+ [0.94, 0.78, 0.021, 0.978],
+ [0.3, 0.94, 0.018, 0.966]
+ ]
+}
diff --git a/src/data/topics.ts b/src/data/topics.ts
new file mode 100644
index 0000000..cfb81a9
--- /dev/null
+++ b/src/data/topics.ts
@@ -0,0 +1,54 @@
+// The site's three topics, the three orange dots of the topic map, and which of them each article is about. Picking a
+// topic on the home's map shows only the articles about it.
+//
+// An article belongs to a topic when that topic makes up 30% or more of it, so it can belong to more than one. An
+// article's topics come from its `topics` frontmatter field in meaningfool-writing when it has one, for example
+// `topics: ["product", "ai-agents"]`. Until every article has that field, the list below gives the topics of the 18
+// articles the prototype read (docs/redesign/prototype/maps.js, the weights in each article's `w`), by article id.
+import type { CollectionEntry } from 'astro:content';
+import map from './topic-map.json';
+
+/** The topics' ids, which the map's dots carry and the frontmatter's `topics` field takes. */
+export const TOPICS = ['customer-success', 'product', 'ai-agents'] as const;
+export type Topic = (typeof TOPICS)[number];
+
+// The map's dots (topic-map.json) are these three topics, in this order.
+if (map.topics.map(({ id }) => id).join() !== TOPICS.join()) {
+ throw new Error(`src/data/topic-map.json's topics should be ${TOPICS.join(', ')}, as in src/data/topics.ts`);
+}
+
+/** The topics' names, as the map shows them: "Customer success", "Product", "AI agents". */
+export const TOPIC_NAMES = Object.fromEntries(map.topics.map(({ id, name }) => [id, name])) as Record;
+
+// The prototype's weights are [customer success, product, AI agents]; each topic at 0.3 or more is listed.
+const fromPrototype: Record = {
+ 'edit-collections-generative-ai': ['ai-agents'], // 0.1, 0.15, 0.75
+ 'structured-extraction': ['ai-agents'], // 0.2, 0.1, 0.7
+ 'thoughts-on-human-ai-collaboration': ['customer-success', 'product', 'ai-agents'], // 0.3, 0.3, 0.4
+ 'detecting-bounding-boxes': ['ai-agents'], // 0.05, 0.1, 0.85
+ 'agent-frameworks-for-the-rest-of-us': ['ai-agents'], // 0.05, 0.15, 0.8
+ 'how-long-for-that-change': ['product'], // 0.15, 0.7, 0.15
+ 'january-in-review': ['ai-agents'], // 0.05, 0.25, 0.7
+ 'prolifics-and-frugals': ['product'], // 0.2, 0.7, 0.1
+ 'the-slicing-fallacy': ['product'], // 0.25, 0.7, 0.05
+ 'we-cant-stop-planning': ['product'], // 0.2, 0.75, 0.05
+ cost_of_change_is_agile_ceiling: ['product'], // 0.15, 0.75, 0.1
+ compounding_dev_process: ['product', 'ai-agents'], // 0.05, 0.5, 0.45
+ antigravity_is_onto_something: ['ai-agents'], // 0.05, 0.25, 0.7
+ 'semi-async_valley_of_death': ['product', 'ai-agents'], // 0.1, 0.3, 0.6
+ 'spec-driven-development-has-it-backwards': ['product', 'ai-agents'], // 0.1, 0.5, 0.4
+ 'ai-engineer-paris-conference-notes': ['ai-agents'], // 0.05, 0.15, 0.8
+ 'voice-ai-maven-course-notes': ['ai-agents'], // 0.05, 0.1, 0.85
+ 'faster-answers-better-questions': ['customer-success', 'product'], // 0.3, 0.5, 0.2
+};
+
+/** An article's topics: its `topics` frontmatter field, or else the list above; undefined when it has neither. */
+export function topicsOf(article: CollectionEntry<'writing'>): Topic[] | undefined {
+ return article.data.topics ?? fromPrototype[article.id];
+}
+
+/** The ids in the list above that match no article, for example after an article's file was renamed. */
+export function unknownTopicIds(articleIds: Iterable): string[] {
+ const ids = new Set(articleIds);
+ return Object.keys(fromPrototype).filter((id) => !ids.has(id));
+}
diff --git a/src/fonts/LICENSE-DejaVu.txt b/src/fonts/LICENSE-DejaVu.txt
new file mode 100644
index 0000000..df52c17
--- /dev/null
+++ b/src/fonts/LICENSE-DejaVu.txt
@@ -0,0 +1,187 @@
+Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
+Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below)
+
+
+Bitstream Vera Fonts Copyright
+------------------------------
+
+Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is
+a trademark of Bitstream, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of the fonts accompanying this license ("Fonts") and associated
+documentation files (the "Font Software"), to reproduce and distribute the
+Font Software, including without limitation the rights to use, copy, merge,
+publish, distribute, and/or sell copies of the Font Software, and to permit
+persons to whom the Font Software is furnished to do so, subject to the
+following conditions:
+
+The above copyright and trademark notices and this permission notice shall
+be included in all copies of one or more of the Font Software typefaces.
+
+The Font Software may be modified, altered, or added to, and in particular
+the designs of glyphs or characters in the Fonts may be modified and
+additional glyphs or characters may be added to the Fonts, only if the fonts
+are renamed to names not containing either the words "Bitstream" or the word
+"Vera".
+
+This License becomes null and void to the extent applicable to Fonts or Font
+Software that has been modified and is distributed under the "Bitstream
+Vera" names.
+
+The Font Software may be sold as part of a larger software package but no
+copy of one or more of the Font Software typefaces may be sold by itself.
+
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
+TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
+FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
+ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
+THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
+FONT SOFTWARE.
+
+Except as contained in this notice, the names of Gnome, the Gnome
+Foundation, and Bitstream Inc., shall not be used in advertising or
+otherwise to promote the sale, use or other dealings in this Font Software
+without prior written authorization from the Gnome Foundation or Bitstream
+Inc., respectively. For further information, contact: fonts at gnome dot
+org.
+
+Arev Fonts Copyright
+------------------------------
+
+Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved.
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the fonts accompanying this license ("Fonts") and
+associated documentation files (the "Font Software"), to reproduce
+and distribute the modifications to the Bitstream Vera Font Software,
+including without limitation the rights to use, copy, merge, publish,
+distribute, and/or sell copies of the Font Software, and to permit
+persons to whom the Font Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright and trademark notices and this permission notice
+shall be included in all copies of one or more of the Font Software
+typefaces.
+
+The Font Software may be modified, altered, or added to, and in
+particular the designs of glyphs or characters in the Fonts may be
+modified and additional glyphs or characters may be added to the
+Fonts, only if the fonts are renamed to names not containing either
+the words "Tavmjong Bah" or the word "Arev".
+
+This License becomes null and void to the extent applicable to Fonts
+or Font Software that has been modified and is distributed under the
+"Tavmjong Bah Arev" names.
+
+The Font Software may be sold as part of a larger software package but
+no copy of one or more of the Font Software typefaces may be sold by
+itself.
+
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL
+TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
+
+Except as contained in this notice, the name of Tavmjong Bah shall not
+be used in advertising or otherwise to promote the sale, use or other
+dealings in this Font Software without prior written authorization
+from Tavmjong Bah. For further information, contact: tavmjong @ free
+. fr.
+
+TeX Gyre DJV Math
+-----------------
+Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
+
+Math extensions done by B. Jackowski, P. Strzelczyk and P. Pianowski
+(on behalf of TeX users groups) are in public domain.
+
+Letters imported from Euler Fraktur from AMSfonts are (c) American
+Mathematical Society (see below).
+Bitstream Vera Fonts Copyright
+Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera
+is a trademark of Bitstream, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of the fonts accompanying this license (“Fonts”) and associated
+documentation
+files (the “Font Software”), to reproduce and distribute the Font Software,
+including without limitation the rights to use, copy, merge, publish,
+distribute,
+and/or sell copies of the Font Software, and to permit persons to whom
+the Font Software is furnished to do so, subject to the following
+conditions:
+
+The above copyright and trademark notices and this permission notice
+shall be
+included in all copies of one or more of the Font Software typefaces.
+
+The Font Software may be modified, altered, or added to, and in particular
+the designs of glyphs or characters in the Fonts may be modified and
+additional
+glyphs or characters may be added to the Fonts, only if the fonts are
+renamed
+to names not containing either the words “Bitstream” or the word “Vera”.
+
+This License becomes null and void to the extent applicable to Fonts or
+Font Software
+that has been modified and is distributed under the “Bitstream Vera”
+names.
+
+The Font Software may be sold as part of a larger software package but
+no copy
+of one or more of the Font Software typefaces may be sold by itself.
+
+THE FONT SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS
+OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
+TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
+FOUNDATION
+BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL,
+SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN
+ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR
+INABILITY TO USE
+THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
+Except as contained in this notice, the names of GNOME, the GNOME
+Foundation,
+and Bitstream Inc., shall not be used in advertising or otherwise to promote
+the sale, use or other dealings in this Font Software without prior written
+authorization from the GNOME Foundation or Bitstream Inc., respectively.
+For further information, contact: fonts at gnome dot org.
+
+AMSFonts (v. 2.2) copyright
+
+The PostScript Type 1 implementation of the AMSFonts produced by and
+previously distributed by Blue Sky Research and Y&Y, Inc. are now freely
+available for general use. This has been accomplished through the
+cooperation
+of a consortium of scientific publishers with Blue Sky Research and Y&Y.
+Members of this consortium include:
+
+Elsevier Science IBM Corporation Society for Industrial and Applied
+Mathematics (SIAM) Springer-Verlag American Mathematical Society (AMS)
+
+In order to assure the authenticity of these fonts, copyright will be
+held by
+the American Mathematical Society. This is not meant to restrict in any way
+the legitimate use of the fonts, such as (but not limited to) electronic
+distribution of documents containing these fonts, inclusion of these fonts
+into other public domain or commercial font collections or computer
+applications, use of the outline data to create derivative fonts and/or
+faces, etc. However, the AMS does require that the AMS copyright notice be
+removed from any derivative versions of the fonts which have been altered in
+any way. In addition, to ensure the fidelity of TeX documents using Computer
+Modern fonts, Professor Donald Knuth, creator of the Computer Modern faces,
+has requested that any alterations which yield different font metrics be
+given a different name.
+
+$Id$
diff --git a/src/fonts/OFL-Newsreader.txt b/src/fonts/OFL-Newsreader.txt
new file mode 100644
index 0000000..d2d4f40
--- /dev/null
+++ b/src/fonts/OFL-Newsreader.txt
@@ -0,0 +1,93 @@
+Copyright 2020 The Newsreader Project Authors (http://github.com/productiontype/Newsreader)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
\ No newline at end of file
diff --git a/src/fonts/dejavu-sans-mono.woff2 b/src/fonts/dejavu-sans-mono.woff2
new file mode 100644
index 0000000..512e14d
Binary files /dev/null and b/src/fonts/dejavu-sans-mono.woff2 differ
diff --git a/src/fonts/newsreader-italic.woff2 b/src/fonts/newsreader-italic.woff2
new file mode 100644
index 0000000..5133d32
Binary files /dev/null and b/src/fonts/newsreader-italic.woff2 differ
diff --git a/src/fonts/newsreader.woff2 b/src/fonts/newsreader.woff2
new file mode 100644
index 0000000..f7b0d0a
Binary files /dev/null and b/src/fonts/newsreader.woff2 differ
diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro
index def2b06..bd465dc 100644
--- a/src/layouts/Layout.astro
+++ b/src/layouts/Layout.astro
@@ -1,11 +1,39 @@
---
-// Accept props for meta tags
+import '../styles/global.css';
+import Background from '../components/Background.astro';
+import Frame from '../components/Frame.astro';
+import Favicons from '../components/Favicons.astro';
+import IntroRouter from '../components/IntroRouter.astro';
+import monoFont from '../fonts/dejavu-sans-mono.woff2?url';
+import { SITE_SHARE, type Share } from '../lib/share';
+import { isIntroPage } from '../lib/intro-pages';
+
+interface Props {
+ title?: string;
+ description?: string;
+ share?: Share;
+ type?: 'website' | 'article';
+ /** The page has the articles' centred 720px text column, which the background's corner pattern makes way for.
+ Articles have it by default. */
+ textColumn?: boolean;
+ /** Ask search engines not to index the page. */
+ noindex?: boolean;
+}
+
+// Accept props for meta tags. `share` is the page's share image, the site's unless it has its own.
const {
title = 'meaningfool',
description = 'Personal site of Josselin Perrus, product manager in Paris',
- ogImage = new URL('/og-default.jpg', Astro.site).toString(),
- type = 'website'
+ share = SITE_SHARE as Share,
+ type = 'website',
+ textColumn = type === 'article',
+ noindex = false,
} = Astro.props;
+const ogImage = new URL(share.path, Astro.site).toString();
+
+// Home and About go from one to the other without a full page load, keeping the map and the background as they are
+// drawn (IntroRouter.astro). The list is src/lib/intro-pages.ts.
+const introPage = isIntroPage(Astro.url.pathname);
// Use Astro's built-in URL handling for canonical URLs
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
@@ -16,11 +44,12 @@ const canonical = canonicalURL.toString();
-
-
+
+ {title}
+ {noindex && }
@@ -30,6 +59,9 @@ const canonical = canonicalURL.toString();
+
+
+
@@ -38,329 +70,21 @@ const canonical = canonicalURL.toString();
+
-
-
-
+
+
+
+ {introPage && }
Skip to main content
-
-
+
+
+
+
-
-
diff --git a/src/lib/excerpt.ts b/src/lib/excerpt.ts
new file mode 100644
index 0000000..d9d8fa8
--- /dev/null
+++ b/src/lib/excerpt.ts
@@ -0,0 +1,63 @@
+const ENTITIES: Record = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
+
+function decode(html: string): string {
+ return html.replace(/&(#x[\da-f]+|#\d+|[a-z]+);/gi, (match, code: string) => {
+ if (code[0] !== '#') return ENTITIES[code.toLowerCase()] ?? match;
+ const point = code[1].toLowerCase() === 'x' ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10);
+ return point <= 0x10ffff ? String.fromCodePoint(point) : match;
+ });
+}
+
+// A tag, whose quoted attribute values may hold a ">".
+const TAG = /<(?:[^>"']|"[^"]*"|'[^']*')*>/g;
+const textOf = (html: string) => decode(html.replace(TAG, '')).replace(/\s+/g, ' ').trim();
+
+/** The HTML without its lists, quotes, tables and figures, innermost first, so that only the text's own paragraphs stay. */
+function withoutBlocks(html: string): string {
+ const innermost = /<(ul|ol|blockquote|table|figure)\b[^>]*>(?:(?!<\/?(?:ul|ol|blockquote|table|figure)\b)[\s\S])*<\/\1>/g;
+ for (let previous = ''; previous !== html; ) [previous, html] = [html, html.replace(innermost, ' ')];
+ return html;
+}
+
+/** A paragraph's text. Its line breaks become spaces, or full stops between a line and one that starts a sentence. */
+function paragraphText(html: string): string {
+ return html.split(/ /).map(textOf).filter(Boolean).reduce((text, line) => {
+ if (!text) return line;
+ return /[\p{L}\p{N}]$/u.test(text) && /^\p{Lu}/u.test(line) ? `${text}. ${line}` : `${text} ${line}`;
+ }, '');
+}
+
+const SENTENCE_END = /[.!?…]["”’)]?$/;
+
+/**
+ * A post's meta description when its frontmatter has none, from its rendered HTML: the text of its first paragraph,
+ * and of the next when the first is very short. Paragraphs in lists, quotes, tables and figures don't count. A
+ * paragraph that leads into a list ("…the likes:") is cut back to its last full sentence, or ends with an ellipsis,
+ * unless it is a short label, which is skipped. The result is cut at a word to stay within `max` characters, and is
+ * empty when the post has no paragraph to take.
+ */
+export function excerpt(html: string, max = 160): string {
+ let text = '';
+ for (const [, inner] of withoutBlocks(html).matchAll(/
([\s\S]*?)<\/p>/g)) {
+ let paragraph = paragraphText(inner);
+ if (!SENTENCE_END.test(paragraph)) {
+ // Its whole sentences, unless that leaves little more than an abbreviation such as "e.g."
+ const whole = paragraph.match(/^.*[.!?…]["”’)]?(?=\s)/)?.[0];
+ const sentences = whole && whole.length >= 40 ? whole : undefined;
+ // A short label such as "Readings:" over a list says nothing on its own: skip it, or stop at it.
+ if (!sentences && paragraph.length < 50) {
+ if (text) break;
+ continue;
+ }
+ paragraph = sentences ?? `${paragraph.replace(/[\s,;:–—-]+$/, '')}…`;
+ text = text ? `${text} ${paragraph}` : paragraph;
+ break;
+ }
+ text = text ? `${text} ${paragraph}` : paragraph;
+ if (text.length >= 50) break;
+ }
+ if (text.length <= max) return text;
+ const cut = text.slice(0, max);
+ const end = cut.lastIndexOf(' ');
+ return `${(end > 0 ? cut.slice(0, end) : cut.slice(0, max - 1)).replace(/[\s,;:.–—-]+$/, '')}…`;
+}
diff --git a/src/lib/favicon.ts b/src/lib/favicon.ts
new file mode 100644
index 0000000..6913f57
--- /dev/null
+++ b/src/lib/favicon.ts
@@ -0,0 +1,73 @@
+// The favicon, the paper tile: "m." in the mono font on the panel colour, inside a 1px border, the "m" in ink and the
+// full stop in the accent colour. The design and its values are in docs/redesign/paper/README.md.
+//
+// The glyphs have a line height equal to their size and -0.12em of letter-spacing, and are centred in the tile with
+// some space kept under them. Each size has its own values.
+//
+// The small sizes are kept sharp the way a font's hinting would: the glyphs move by less than half a pixel so the "m"
+// starts on a pixel and stands on one, and the full stop, a plain rectangle in DejaVu Sans Mono, is fitted to whole
+// pixels, at least 2px a side so it still shows as a square at 16px.
+import sharp from 'sharp';
+import { baseline, charWidth, inkBox, textPath, type TextStyle } from './mono';
+
+const PANEL = '#f3f3f0';
+const BORDER = '#dcdcd6';
+const INK = '#151515';
+const ACCENT = '#b64b00';
+
+// Tile size: the glyphs' size and the space under them, in px. The 180px tile, the apple-touch-icon, is the design's
+// 144px tile (108px glyphs, 14px under them) scaled by 1.25.
+const TILES: Record = {
+ 16: { glyphs: 12, under: 1 },
+ 32: { glyphs: 24, under: 3 },
+ 180: { glyphs: 135, under: 17.5 },
+};
+
+/** The tile as an SVG, `size` px wide, with the glyphs converted to paths. */
+export function tileSvg(size: number): string {
+ const { glyphs, under } = TILES[size];
+ const style: TextStyle = { size: glyphs, tracking: -0.12 };
+ const m = inkBox('m');
+ const ink = (size - 2 * charWidth(style)) / 2 + m.minX * glyphs; // where the "m" starts, centred
+ const x = Math.round(ink) - m.minX * glyphs;
+ const y = Math.round(baseline((size - under - glyphs) / 2, glyphs, glyphs));
+
+ const stop = inkBox('.');
+ const width = Math.max(2, Math.round((stop.maxX - stop.minX) * glyphs));
+ const height = Math.max(2, Math.round((stop.maxY - stop.minY) * glyphs));
+ const centre = x + charWidth(style) + ((stop.minX + stop.maxX) / 2) * glyphs;
+ const left = Math.round(centre - width / 2);
+ return (
+ `'
+ );
+}
+
+/** The tile as a PNG. */
+export function tilePng(size: number): Promise {
+ return sharp(Buffer.from(tileSvg(size))).ensureAlpha().png({ compressionLevel: 9 }).toBuffer();
+}
+
+/** An .ico file holding the tile at each size, each as a PNG, which every browser reads. */
+export async function tileIco(sizes: number[]): Promise {
+ const images = await Promise.all(sizes.map(tilePng));
+ const header = Buffer.alloc(6 + 16 * sizes.length);
+ header.writeUInt16LE(1, 2); // an icon
+ header.writeUInt16LE(sizes.length, 4);
+ let offset = header.length;
+ sizes.forEach((size, i) => {
+ const entry = 6 + 16 * i;
+ header.writeUInt8(size % 256, entry); // width and height, 0 meaning 256
+ header.writeUInt8(size % 256, entry + 1);
+ header.writeUInt16LE(1, entry + 4); // colour planes
+ header.writeUInt16LE(32, entry + 6); // bits per pixel
+ header.writeUInt32LE(images[i].length, entry + 8);
+ header.writeUInt32LE(offset, entry + 12);
+ offset += images[i].length;
+ });
+ return Buffer.concat([header, ...images]);
+}
diff --git a/src/lib/intro-pages.ts b/src/lib/intro-pages.ts
new file mode 100644
index 0000000..13b5f24
--- /dev/null
+++ b/src/lib/intro-pages.ts
@@ -0,0 +1,6 @@
+// Home and About, the pages that open with the intro and its topic map at the same place. Going from one to the other
+// happens without a full page load, so the map is moved rather than drawn again (src/components/IntroRouter.astro).
+// Layout gives these pages the router, and the router loads any other page in full.
+export const INTRO_PAGES: readonly string[] = ['/', '/about/'];
+
+export const isIntroPage = (pathname: string) => INTRO_PAGES.includes(pathname);
diff --git a/src/lib/mono.ts b/src/lib/mono.ts
new file mode 100644
index 0000000..82c8860
--- /dev/null
+++ b/src/lib/mono.ts
@@ -0,0 +1,89 @@
+// DejaVu Sans Mono as SVG paths, for the images the build draws: the share images and the favicon. They use the
+// site's own subset (src/fonts/), so they show the same letters as the pages, and as paths they need no font where
+// they are shown. Text is laid out as a browser lays it out: every character takes the same width plus the
+// letter-spacing, lines break at spaces and after hyphens, and a line box puts its baseline where CSS puts it.
+import { create, type Font } from 'fontkitten';
+import fontData from '../fonts/dejavu-sans-mono.woff2?inline';
+
+const font = create(Buffer.from(fontData.slice(fontData.indexOf(',') + 1), 'base64')) as Font;
+const EM = font.unitsPerEm;
+const ASCENT = font.ascent / EM;
+const DESCENT = -font.descent / EM;
+const ADVANCE = font.glyphForCodePoint(0x6d).advanceWidth / EM; // 0.602em: in a mono font, every character's width
+
+const COMMANDS = { moveTo: 'M', lineTo: 'L', quadraticCurveTo: 'Q', bezierCurveTo: 'C', closePath: 'Z' };
+
+export interface TextStyle {
+ size: number; // font size, in px
+ tracking?: number; // letter-spacing, in em
+}
+
+const chars = (text: string) => Array.from(text);
+const num = (value: number) => String(Math.round(value * 100) / 100);
+
+/** The width one character takes, letter-spacing included. */
+export function charWidth({ size, tracking = 0 }: TextStyle): number {
+ return size * (ADVANCE + tracking);
+}
+
+/** The width of a line of text. As in Chrome, the last character keeps its letter-spacing. */
+export function textWidth(text: string, style: TextStyle): number {
+ return chars(text).length * charWidth(style);
+}
+
+/** Where CSS puts the baseline in a line box `lineHeight` tall whose top is at `top`. */
+export function baseline(top: number, lineHeight: number, size: number): number {
+ return top + (lineHeight - (ASCENT + DESCENT) * size) / 2 + ASCENT * size;
+}
+
+/** A character's ink, in em from its origin on the baseline, y going up. */
+export function inkBox(char: string): { minX: number; minY: number; maxX: number; maxY: number } {
+ const { minX, minY, maxX, maxY } = font.glyphForCodePoint(char.codePointAt(0)!).bbox;
+ return { minX: minX / EM, minY: minY / EM, maxX: maxX / EM, maxY: maxY / EM };
+}
+
+/** SVG path data for one line of text, starting at `x` on the baseline `y`. */
+export function textPath(text: string, x: number, y: number, style: TextStyle): string {
+ const scale = style.size / EM;
+ const step = charWidth(style);
+ const missing = new Set();
+ let d = '';
+ chars(text).forEach((char, i) => {
+ const glyph = font.glyphForCodePoint(char.codePointAt(0)!);
+ if (glyph.id === 0 && char.trim()) missing.add(char);
+ const left = x + i * step;
+ for (const { command, args } of glyph.path.commands) {
+ d += COMMANDS[command] + args.map((v, j) => num(j % 2 ? y - v * scale : left + v * scale)).join(' ');
+ }
+ });
+ if (missing.size) {
+ // The subset only holds Latin, punctuation, arrows and box drawing (scripts/subset-fonts.py).
+ console.warn(`[mono] DejaVu Sans Mono's subset has no ${[...missing].join(' ')}, drawn as a box in "${text}"`);
+ }
+ return d;
+}
+
+/**
+ * Break text into lines at most `maxWidth` wide, as a browser does: at spaces and after a hyphen between letters,
+ * the space at the end of a line not counting. A word longer than a line breaks where it reaches the end.
+ */
+export function wrap(text: string, style: TextStyle, maxWidth: number): string[] {
+ const max = Math.max(1, Math.floor(maxWidth / charWidth(style) + 1e-9));
+ const fits = (line: string) => chars(line.trimEnd()).length <= max;
+ const pieces = text.replace(/\s+/g, ' ').trim().split(/(?<= )|(?<=[\p{L}\p{N}]-)(?=[\p{L}\p{N}])/u);
+ const lines: string[] = [];
+ let line = '';
+ for (let piece of pieces) {
+ if (line && !fits(line + piece)) {
+ lines.push(line.trimEnd());
+ line = '';
+ }
+ while (!fits(piece)) {
+ lines.push(chars(piece).slice(0, max).join(''));
+ piece = chars(piece).slice(max).join('');
+ }
+ line += piece;
+ }
+ if (line.trim()) lines.push(line.trimEnd());
+ return lines;
+}
diff --git a/src/lib/reading-time.mjs b/src/lib/reading-time.mjs
new file mode 100644
index 0000000..0249c6e
--- /dev/null
+++ b/src/lib/reading-time.mjs
@@ -0,0 +1,11 @@
+// Reading time, as the redesign shows it next to a post's date.
+
+/**
+ * Minutes to read a post: the words of its Markdown body at 235 a minute, rounded up, and at least 1. This gives the
+ * prototype's reading times (docs/redesign/prototype/maps.js) for all 18 of its articles, as any rate from 231 to 236
+ * does.
+ */
+export function readingMinutes(markdown = '') {
+ const words = markdown.split(/\s+/).filter(Boolean).length;
+ return Math.max(1, Math.ceil(words / 235));
+}
diff --git a/src/lib/rehype-article.mjs b/src/lib/rehype-article.mjs
new file mode 100644
index 0000000..9046c27
--- /dev/null
+++ b/src/lib/rehype-article.mjs
@@ -0,0 +1,178 @@
+/**
+ * Shape a post's HTML for the article page's styles (src/styles/prose.css). It runs after rehype-raw, so the HTML
+ * written in the Markdown is made of elements too.
+ *
+ * - An image or a video alone in its paragraph becomes a figure, on a panel. The caption is the image's title, or its
+ * alt text when that is a phrase: an alt text without a space, such as `cost-of-rework`, is a file name.
+ * - YouTube embeds load from youtube-nocookie.com, lazily, in a 16:9 figure.
+ * - Tables get a wrapper that scrolls sideways when they don't fit. Columns holding only numbers are marked `num`, a row
+ * whose cells are all bold is marked `highlight`, and a paragraph right after a table that starts with "Table:"
+ * becomes its caption.
+ * - A numbered list that doesn't start at 1 keeps its numbers, which the page draws itself.
+ */
+const NUMBER = /^[-+−]?[$€£]?\d[\d.,:/]*\s?(?:%|[a-zµ]{1,3})?$/i;
+const PLACEHOLDER = /^(?:[-–—]|n\/a)$/i;
+const CAPTION = /^Table:\s*/;
+
+const element = (tagName, properties, children) => ({ type: 'element', tagName, properties, children });
+const isElement = (node, ...names) => node?.type === 'element' && (!names.length || names.includes(node.tagName));
+const isBlank = (node) => node.type === 'comment' || (node.type === 'text' && !node.value.trim());
+const content = (node) => (node.children ?? []).filter((child) => !isBlank(child));
+const textOf = (node) => (node.type === 'text' ? node.value : (node.children ?? []).map(textOf).join(''));
+const only = (node) => {
+ const children = content(node);
+ return children.length === 1 ? children[0] : undefined;
+};
+const addClass = (node, name) => {
+ node.properties.className = [...(node.properties.className ?? []), name];
+};
+
+function figure(media, caption) {
+ const children = [element('div', { className: ['media'] }, [media])];
+ if (caption) children.push(element('figcaption', {}, [{ type: 'text', value: caption }]));
+ return element('figure', {}, children);
+}
+
+/** The figure for a paragraph that holds only an image (linked or not) or a video. */
+function mediaFigure(paragraph) {
+ const media = only(paragraph);
+ if (isElement(media, 'video')) return figure(media);
+ const image = isElement(media, 'a') ? only(media) : media;
+ if (!isElement(image, 'img')) return undefined;
+ const { title, alt } = image.properties;
+ if (typeof title === 'string' && title.trim()) {
+ delete image.properties.title;
+ return figure(media, title.trim());
+ }
+ return figure(media, typeof alt === 'string' && /\s/.test(alt.trim()) ? alt.trim() : undefined);
+}
+
+/** The youtube-nocookie.com address of a YouTube embed, keeping its parameters, such as the start time. */
+function noCookie(src) {
+ if (typeof src !== 'string') return undefined;
+ let url;
+ try {
+ url = new URL(src.startsWith('//') ? `https:${src}` : src);
+ } catch {
+ return undefined;
+ }
+ if (!/^https?:$/.test(url.protocol) || !/^(?:www\.|m\.)?youtube(?:-nocookie)?\.com$/.test(url.hostname)) return undefined;
+ const id = url.pathname.match(/^\/embed\/([\w-]+)\/?$/)?.[1];
+ return id && `https://www.youtube-nocookie.com/embed/${id}${url.search}${url.hash}`;
+}
+
+/** The youtube-nocookie.com address for an iframe that embeds a YouTube video; undefined for anything else. */
+const youTubeSrc = (node) => (isElement(node, 'iframe') ? noCookie(node.properties.src) : undefined);
+
+/** Load a YouTube embed from `src`, lazily. In a figure, the iframe's own sizes give way to the figure's 16:9. */
+function lazyNoCookie(iframe, src, inFigure) {
+ const { width, height, style, frameBorder, ...rest } = iframe.properties;
+ iframe.properties = {
+ ...(inFigure ? rest : iframe.properties),
+ src,
+ loading: 'lazy',
+ referrerPolicy: 'strict-origin-when-cross-origin',
+ };
+ return iframe;
+}
+
+function embedFigure(iframe) {
+ const result = figure(iframe);
+ addClass(result.children[0], 'embed');
+ return result;
+}
+
+function shapeTable(table, captionParagraph) {
+ const rows = [];
+ (function collect(node) {
+ for (const child of node.children ?? []) {
+ if (isElement(child, 'tr')) rows.push({ row: child, head: node.tagName === 'thead' });
+ else if (isElement(child, 'thead', 'tbody', 'tfoot')) collect(child);
+ }
+ })(table);
+ const cells = (row) => row.children.filter((child) => isElement(child, 'th', 'td'));
+ const body = rows.filter(({ head }) => !head).map(({ row }) => row);
+ const columns = Math.max(0, ...rows.map(({ row }) => cells(row).length));
+ for (let column = 0; column < columns; column++) {
+ const values = body.map((row) => (cells(row)[column] ? textOf(cells(row)[column]).trim() : ''));
+ const numeric = values.some((value) => NUMBER.test(value))
+ && values.every((value) => !value || NUMBER.test(value) || PLACEHOLDER.test(value));
+ if (!numeric) continue;
+ for (const { row } of rows) {
+ const cell = cells(row)[column];
+ if (cell) addClass(cell, 'num');
+ }
+ }
+ for (const row of body) {
+ const filled = cells(row).filter((cell) => content(cell).length);
+ if (filled.length && filled.every((cell) => isElement(only(cell), 'strong'))) addClass(row, 'highlight');
+ }
+ if (captionParagraph) {
+ const [first, ...rest] = captionParagraph.children;
+ const children = first.value.replace(CAPTION, '') ? [{ ...first, value: first.value.replace(CAPTION, '') }, ...rest] : rest;
+ table.children.unshift(element('caption', {}, children));
+ }
+ return element('div', { className: ['table'] }, [table]);
+}
+
+function isCaption(node) {
+ const first = node?.children?.[0];
+ return isElement(node, 'p') && first?.type === 'text' && CAPTION.test(first.value)
+ && Boolean(textOf(node).replace(CAPTION, '').trim());
+}
+
+function shape(parent) {
+ const children = parent.children ?? [];
+ // Whether a figure can go here: not in a paragraph, a link or another figure.
+ const holdsBlocks = parent.type === 'root'
+ || isElement(parent, 'div', 'li', 'blockquote', 'section', 'article', 'aside', 'details', 'td', 'th');
+ for (let index = 0; index < children.length; index++) {
+ const node = children[index];
+ if (!isElement(node)) continue;
+
+ const media = holdsBlocks && isElement(node, 'p') && mediaFigure(node);
+ if (media) {
+ children[index] = media;
+ continue;
+ }
+ // A video written as an HTML block of its own, outside any paragraph.
+ if (isElement(node, 'video') && holdsBlocks) {
+ children[index] = figure(node);
+ continue;
+ }
+ // An embed alone in its own block, often a
that gave it a 16:9 box with inline styles.
+ const embed = holdsBlocks && isElement(node, 'div', 'p') ? only(node) : undefined;
+ const embedSrc = youTubeSrc(embed);
+ if (embedSrc) {
+ children[index] = embedFigure(lazyNoCookie(embed, embedSrc, true));
+ continue;
+ }
+ // An embed among other content: a figure where a block can go, or else left in place with its own sizes.
+ const src = youTubeSrc(node);
+ if (src) {
+ lazyNoCookie(node, src, holdsBlocks);
+ if (holdsBlocks) children[index] = embedFigure(node);
+ continue;
+ }
+ if (isElement(node, 'table')) {
+ let next = index + 1;
+ while (next < children.length && isBlank(children[next])) next++;
+ const caption = isCaption(children[next]) ? children[next] : undefined;
+ if (caption) children.splice(index + 1, next - index);
+ shape(node); // what the cells hold
+ children[index] = shapeTable(node, caption);
+ continue;
+ }
+ const { start, style } = node.properties;
+ if (isElement(node, 'ol') && typeof start === 'number' && start !== 1) {
+ node.properties.style = [style, `counter-reset: n ${start - 1}`].filter(Boolean).join('; ');
+ }
+ shape(node);
+ }
+}
+
+// Its options go unused: astro.config.mjs passes a hash of the Markdown plugins there, so that Astro renders the posts
+// again when they change.
+export default function rehypeArticle() {
+ return (tree) => shape(tree);
+}
diff --git a/src/lib/remark-heading-levels.mjs b/src/lib/remark-heading-levels.mjs
new file mode 100644
index 0000000..f04b1f1
--- /dev/null
+++ b/src/lib/remark-heading-levels.mjs
@@ -0,0 +1,23 @@
+/**
+ * Give a post's headings a hierarchy under the page title, the page's only h1: the highest level the text uses becomes
+ * h2 and the others keep their distance to it. A post with `#` sections gets h2 sections and h3 subsections; one that
+ * starts at `###` gets them moved up. No heading goes more than one level below the one before it, the title included,
+ * so a `##` before the first `#` section becomes h2 rather than an h3 straight under the title. Runs after
+ * remark-drop-title, which removes the `# Title` line.
+ */
+export default function remarkHeadingLevels() {
+ return (tree) => {
+ const headings = [];
+ (function collect(node) {
+ if (node.type === 'heading') headings.push(node);
+ node.children?.forEach(collect);
+ })(tree);
+ if (!headings.length) return;
+ const shift = 2 - Math.min(...headings.map((heading) => heading.depth));
+ let previous = 1; // the page title
+ for (const heading of headings) {
+ heading.depth = Math.min(6, heading.depth + shift, previous + 1);
+ previous = heading.depth;
+ }
+ };
+}
diff --git a/src/lib/share-images.ts b/src/lib/share-images.ts
new file mode 100644
index 0000000..ab34b22
--- /dev/null
+++ b/src/lib/share-images.ts
@@ -0,0 +1,118 @@
+// The share images, 1200×630 PNGs drawn at build time: one per article and one for Home and About. The design and its
+// values are in docs/redesign/paper/README.md. All the text is DejaVu Sans Mono, drawn as paths (src/lib/mono.ts).
+//
+// The frame is white, padded 64px at the top, 72px at the sides and 56px at the bottom, with three rows spread from top
+// to bottom: the wordmark and a label tab, the middle, and a line over two pieces of text.
+import sharp from 'sharp';
+import { baseline, charWidth, textPath, textWidth, wrap, type TextStyle } from './mono';
+
+const WIDTH = 1200;
+const HEIGHT = 630;
+const TOP = 64;
+const SIDE = 72;
+const BOTTOM = 56;
+const RIGHT = WIDTH - SIDE;
+
+// The colours of src/styles/tokens.css.
+const PAPER = '#ffffff';
+const INK = '#151515';
+const MUTED = '#686862';
+const LINE = '#e5e5e2';
+const TINT = '#fff9f4';
+const ACCENT = '#b64b00';
+
+interface Block {
+ height: number;
+ draw: (top: number) => string;
+}
+
+const rect = (x: number, y: number, width: number, height: number, fill: string) =>
+ ``;
+const path = (d: string, fill: string) => (d ? `` : '');
+
+/** One line of text in a line box `lineHeight` tall whose top is at `top`, from `x`. */
+const line = (text: string, x: number, top: number, lineHeight: number, style: TextStyle, fill: string) =>
+ path(textPath(text, x, baseline(top, lineHeight, style.size), style), fill);
+
+/** Lines of text, one under the other. */
+function lines(texts: string[], top: number, lineHeight: number, style: TextStyle, fill: string): string {
+ return texts.map((text, i) => line(text, SIDE, top + i * lineHeight, lineHeight, style, fill)).join('');
+}
+
+function frame(label: string, middle: Block, left: string, right: string): string {
+ // Top row, its items centred on each other. Left, the wordmark: mono 36px/36px, -0.055em, the dot in the accent
+ // colour. Right, the label tab: mono 20px/28px on the tint, padding 10px 20px 9px, with a 3px accent line under it.
+ const mark: TextStyle = { size: 36, tracking: -0.055 };
+ const tab: TextStyle = { size: 20 };
+ const tabWidth = textWidth(label, tab) + 40;
+ const tabHeight = 10 + 28 + 9 + 3;
+ const markTop = TOP + (tabHeight - 36) / 2;
+ const tabLeft = RIGHT - tabWidth;
+ const top =
+ line('meaningfool', SIDE, markTop, 36, mark, INK) +
+ line('.', SIDE + 11 * charWidth(mark), markTop, 36, mark, ACCENT) +
+ rect(tabLeft, TOP, tabWidth, tabHeight - 3, TINT) +
+ rect(tabLeft, TOP + tabHeight - 3, tabWidth, 3, ACCENT) +
+ line(label, tabLeft + 20, TOP + 10, 28, tab, INK);
+
+ // Bottom row: a 2px line, 24px of space, then mono 22px/30px, ink on the left and muted on the right.
+ const foot: TextStyle = { size: 22 };
+ const footTop = HEIGHT - BOTTOM - (2 + 24 + 30);
+ const bottom =
+ rect(SIDE, footTop, RIGHT - SIDE, 2, LINE) +
+ line(left, SIDE, footTop + 26, 30, foot, INK) +
+ line(right, RIGHT - textWidth(right, foot), footTop + 26, 30, foot, MUTED);
+
+ // The middle, with as much space above as below.
+ const space = footTop - (TOP + tabHeight);
+ const centre = middle.draw(TOP + tabHeight + (space - middle.height) / 2);
+
+ return (
+ `'
+ );
+}
+
+const png = (svg: string) => sharp(Buffer.from(svg)).removeAlpha().png({ compressionLevel: 9 }).toBuffer();
+
+/**
+ * An article's image: "Article" on the tab, the title in mono 68px/80px, -0.05em, at most 1020px wide, and the name,
+ * date and reading time at the bottom. A title longer than four lines gets smaller until it fits in four.
+ */
+function articleImageSvg({ title, meta }: { title: string; meta: string }): string {
+ let style: TextStyle = { size: 68, tracking: -0.05 };
+ let texts = wrap(title, style, 1020);
+ while (texts.length > 4 && style.size > 36) {
+ style = { ...style, size: style.size - 4 };
+ texts = wrap(title, style, 1020);
+ }
+ if (texts.length > 4) texts = [...texts.slice(0, 3), `${texts[3].slice(0, -1)}…`];
+ const lineHeight = Math.round((style.size * 80) / 68);
+ const middle = {
+ height: texts.length * lineHeight,
+ draw: (top: number) => lines(texts, top, lineHeight, style, INK),
+ };
+ return frame('Article', middle, 'Josselin Perrus', meta);
+}
+
+/**
+ * The image of Home and About: "Projects · Writing" on the tab, the name in mono 84px/92px, -0.05em, and 20px under it
+ * the home's intro in mono 28px/42px, -0.02em, muted, at most 920px wide. At the bottom, the site and its counts.
+ */
+function siteImageSvg({ intro, counts }: { intro: string; counts: string }): string {
+ const name: TextStyle = { size: 84, tracking: -0.05 };
+ const lede: TextStyle = { size: 28, tracking: -0.02 };
+ const texts = wrap(intro, lede, 920);
+ const middle = {
+ height: 92 + 20 + texts.length * 42,
+ draw: (top: number) => line('Josselin Perrus', SIDE, top, 92, name, INK) + lines(texts, top + 112, 42, lede, MUTED),
+ };
+ return frame('Projects · Writing', middle, 'meaningfool.net', counts);
+}
+
+export const articleImage = (text: { title: string; meta: string }) => png(articleImageSvg(text));
+export const siteImage = (text: { intro: string; counts: string }) => png(siteImageSvg(text));
diff --git a/src/lib/share.ts b/src/lib/share.ts
new file mode 100644
index 0000000..02a0368
--- /dev/null
+++ b/src/lib/share.ts
@@ -0,0 +1,53 @@
+// What the share images say and where they are served, for the endpoints that draw them (src/lib/share-images.ts)
+// and the pages whose og:image they are.
+import type { CollectionEntry } from 'astro:content';
+import { isDailyLog } from './posts.mjs';
+import { readingMinutes } from './reading-time.mjs';
+import { projects } from '../data/projects';
+
+type Post = CollectionEntry<'writing'>;
+
+export interface Share {
+ path: string;
+ alt: string;
+}
+
+/** The home's intro, which the share image of Home and About repeats under the name. */
+export const HOME_INTRO =
+ "I'm a product manager building AI products with AI. My projects are an opportunity to test ideas and learn in public.";
+
+/** The share image of Home and About. The activity logs, which have none of their own, use it too. */
+export const SITE_SHARE: Share = { path: '/share.png', alt: `Josselin Perrus. ${HOME_INTRO}` };
+
+/** "18 articles", "1 project" */
+export const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : 's'}`;
+
+/** "May 27, 2026": the date in the content is a day, which UTC keeps whatever the build machine's time zone. */
+export function formatDate(date: Date): string {
+ return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' });
+}
+
+/** The articles, not the activity logs. */
+export const articlesOnly = (posts: Post[]) => posts.filter((post) => !isDailyLog(post));
+
+/** "18 articles · 3 projects", for the share image of Home and About. */
+export function siteCounts(posts: Post[]): string {
+ return `${plural(articlesOnly(posts).length, 'article')} · ${plural(projects.length, 'project')}`;
+}
+
+/** An article's date and reading time, as its share image shows them: "May 27, 2026 · 3 min read". */
+export function articleMeta(article: Post): { date: string; minutes: number; line: string } {
+ const date = formatDate(article.data.date);
+ const minutes = readingMinutes(article.body);
+ return { date, minutes, line: `${date} · ${minutes} min read` };
+}
+
+/** A post's share image: its own for an article, the site's for an activity log. */
+export function postShare(post: Post): Share {
+ if (isDailyLog(post)) return SITE_SHARE;
+ const { date, minutes } = articleMeta(post);
+ return {
+ path: `/articles/${post.id}/share.png`,
+ alt: `${post.data.title}. Josselin Perrus, ${date}, ${minutes} min read.`,
+ };
+}
diff --git a/src/pages/404.astro b/src/pages/404.astro
index 5fa680f..6dd4607 100644
--- a/src/pages/404.astro
+++ b/src/pages/404.astro
@@ -1,14 +1,27 @@
---
+import '../styles/global.css';
+import Background from '../components/Background.astro';
+import Frame from '../components/Frame.astro';
import NotFound from '../components/NotFound.astro';
+import Favicons from '../components/Favicons.astro';
+import monoFont from '../fonts/dejavu-sans-mono.woff2?url';
---
+
+
404 - Page Not Found | meaningfool
+
+
-
+ Skip to main content
+
+
+
+
diff --git a/src/pages/about.astro b/src/pages/about.astro
index 97f3641..cdcef40 100644
--- a/src/pages/about.astro
+++ b/src/pages/about.astro
@@ -1,6 +1,9 @@
---
-import Header from '../components/Header.astro';
+// About: the same intro as the home, so the label, the title and the map sit at the same place on both pages, then
+// one part per island of the map. Each part's heading stays in view under the intro text while its text, under the
+// map, is read. The map's orange dots link to the parts.
import Layout from '../layouts/Layout.astro';
+import Intro from '../components/Intro.astro';
import JsonLd from '../components/JsonLd.astro';
// JSON-LD structured data for Person
@@ -16,25 +19,153 @@ const personSchema = {
"https://www.linkedin.com/in/josselinperrus/"
]
};
+
+// The boundary model Skills, in the owner's public skills repository.
+const boundaryModelSkills = 'https://github.com/meaningfool/skills/tree/main/skills/boundary-model';
---
-
-
-
-
About me
-
🏁 Customer success is the goal. (Productized) software may be solution. And it may not. Whatever works
-
👂 Actively listening is where it all starts and ends.
-
⚙️ "Perfection is achieved when there is nothing left to take away." is the rightful defintion of a solution / system
-
-
🐥 I'm the father of 1, and when I get away, I'm probably bikepaching 🚴♂️, or sailing ⛵.
-
👨🚀 What get's me going: solving problems, learning, exploring / pioneering a field.
-
🗺️ AI is clearing the map. I'm out there trying to make sense of the new landscape. In public
-
Work with me
-
👨💻 I mainly do freelance product manager work.
-
💥 But if you think I could be a unique fit for a permanent position, please reach out.
+
+
+ Customer success, Product, AI agents.
+ I'm Josselin Perrus, product manager turned builder. I started in customer-facing roles, spent seven years building the product at Linkurious (2026 exit), and the last three years building with AI.
+
+
+
+
+
+
Customer Success
+
Identify the value
+
+
+
I started in client-facing positions. Talking to customers and making them successful is still the highlight of my day. It's also where I learnt to listen and be pragmatic.
+
Listen, because when you have little leverage on the product, that's your only chance to narrow the problem enough that you may be able to find a solution.
+
Pragmatic, because a now-solution beats everyday a beautiful solution.
+
+
+
+
+
+
Product
+
Decide what to build
+
+
+
Linkurious grew from €1K / year / customer to over €100K / year / customer. Self-financed. As the Founding PM, I had to identify the highest leverage bets, and build alignment with the cofounders. And push back on the rest.
+
I build focus and clarity in fluid environments on a deep understanding of customers (120 interviews in the first 6 months at Linkurious), their problems, and how they create opportunities to meet the company strategy.
+
+
+
+
+
+
AI Agents
+
Software factory
+
+
+
My very first AI project was Dec. 2023: building a knowledge graph from a sum of articles using structured extraction. Later came a data lineage tool, a report on agent frameworks and 2 voice agent projects.
+
I build them end to end with coding agents, from the infrastructure to the prompts, from backend/frontend to benchmarks and evals.
+
I compound building my own set of tools such as my boundary model Skills (GitHub).
+
+
+
diff --git a/src/pages/apple-touch-icon.png.ts b/src/pages/apple-touch-icon.png.ts
new file mode 100644
index 0000000..c53523c
--- /dev/null
+++ b/src/pages/apple-touch-icon.png.ts
@@ -0,0 +1,6 @@
+// The 180px icon that iPhones and iPads put on the home screen.
+import type { APIRoute } from 'astro';
+import { tilePng } from '../lib/favicon';
+
+export const GET: APIRoute = async () =>
+ new Response(new Uint8Array(await tilePng(180)), { headers: { 'Content-Type': 'image/png' } });
diff --git a/src/pages/articles/[slug].astro b/src/pages/articles/[slug].astro
index 3016c50..6522f9a 100644
--- a/src/pages/articles/[slug].astro
+++ b/src/pages/articles/[slug].astro
@@ -1,20 +1,57 @@
---
+// An article or an activity log, built from the Paper design "V2 · Article — Serif text" with its column widened to
+// 720px: the back link, the meta line, the title and the text, then the project the article belongs to and the
+// previous and next posts. The text's styles are in src/styles/prose.css.
import type { GetStaticPaths } from 'astro';
import { getCollection, render, type CollectionEntry } from 'astro:content';
import Layout from '../../layouts/Layout.astro';
-import Header from '../../components/Header.astro';
import JsonLd from '../../components/JsonLd.astro';
+import ProjectPanel from '../../components/ProjectPanel.astro';
+import PrevNext from '../../components/PrevNext.astro';
+import { projects, projectOf } from '../../data/projects';
+import { isDailyLog } from '../../lib/posts.mjs';
+import { newestFirst } from '../../lib/sort-posts';
+import { articleMeta, postShare } from '../../lib/share';
+import { excerpt } from '../../lib/excerpt';
+import '../../styles/prose.css';
+
+type Post = CollectionEntry<'writing'>;
+
+interface Props {
+ article: Post;
+ previous?: Post;
+ next?: Post;
+}
export const getStaticPaths: GetStaticPaths = async () => {
- const articles = await getCollection('writing');
- return articles.map((article) => ({
- params: { slug: article.id },
- props: article,
- }));
-}
+ const posts = await getCollection('writing');
+ const ids = new Set(posts.map((post) => post.id));
+ for (const project of projects) {
+ for (const id of project.articles) {
+ if (!ids.has(id)) console.warn(`src/data/projects.ts: ${project.name} lists an article that doesn't exist: ${id}`);
+ }
+ }
-const article = Astro.props as CollectionEntry<'writing'>;
+ // Previous and Next go through the posts newest first: Previous is the older post, Next the newer one. Articles link
+ // to articles, as the redesigned home's Writing list (#31) holds only articles; activity logs link to activity logs.
+ return [posts.filter((post) => !isDailyLog(post)), posts.filter(isDailyLog)].flatMap((group) => {
+ const sorted = group.sort(newestFirst);
+ return sorted.map((article, index) => ({
+ params: { slug: article.id },
+ props: { article, previous: sorted[index + 1], next: sorted[index - 1] },
+ }));
+ });
+};
+
+const { article, previous, next } = Astro.props;
const { Content } = await render(article);
+const share = postShare(article);
+
+const project = projectOf(article.id);
+// The date and reading time as the share image prints them
+const { date: dateText, minutes } = articleMeta(article);
+const description = article.data.description || excerpt(article.rendered?.html ?? '') || article.data.title;
+const date = article.data.date;
// JSON-LD structured data for BlogPosting
const canonical = new URL(Astro.url.pathname, Astro.site).toString();
@@ -27,12 +64,12 @@ const articleSchema = {
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": article.data.title,
- "description": article.data.description || article.data.title,
- "image": new URL('/og-default.jpg', Astro.site).toString(),
+ "description": description,
+ "image": new URL(share.path, Astro.site).toString(),
"author": author,
"publisher": author,
- "datePublished": article.data.date.toISOString(),
- "dateModified": (article.data.updated ?? article.data.date).toISOString(),
+ "datePublished": date.toISOString(),
+ "dateModified": (article.data.updated ?? date).toISOString(),
"mainEntityOfPage": { "@type": "WebPage", "@id": canonical },
"url": canonical
};
@@ -40,159 +77,93 @@ const articleSchema = {
-
-
+
+ ← All writing
-
+
+