Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
91e126d
Keep the approved redesign prototype as a reference
meaningfool Nov 3, 2026
b30e303
Add the Paper designs for the share images, favicon and article page
meaningfool Nov 3, 2026
7d38043
Lay the redesign's foundations: fonts, colours, header, footer and 404
claude Nov 3, 2026
4cf01a4
Merge #36: redesign foundations (fonts, colours, header, footer, 404)
meaningfool Nov 3, 2026
6250a4e
Draw the share images and the favicon at build time
claude Nov 3, 2026
9fab9bf
Read at 235 words a minute, which gives the prototype's reading times
claude Nov 3, 2026
c4f0f46
Draw the page background and the topic map from prepared images
claude Nov 3, 2026
3df7e38
Merge #37: redesign share images and favicon
meaningfool Nov 3, 2026
da44483
Build the redesigned article page
claude Nov 3, 2026
c8250b4
Merge origin/redesign (#37, share images and favicon) into redesign-3…
claude Nov 3, 2026
f72416c
Merge redesign into redesign-30-background-map
claude Nov 3, 2026
acfbd34
Keep each heading at most one level below the one before it
claude Nov 3, 2026
94fffd2
Render the posts again when the Markdown plugins change
claude Nov 3, 2026
572200e
Merge #39: redesign article page
meaningfool Nov 3, 2026
a26fefa
Draw the topic map at 2x or more, as the shader did
claude Nov 3, 2026
78b78fd
Merge #38: redesign page background and topic map
meaningfool Nov 3, 2026
89eea52
Build the redesigned home
claude Nov 3, 2026
468a01a
Tidy the home's filter after review
claude Nov 3, 2026
ee4801f
Build the redesigned About page
claude Nov 3, 2026
c792224
Keep the count's announcements and the picked map in step
claude Nov 3, 2026
0d8e675
Polish the home's links for screen readers and hover
claude Nov 3, 2026
016a2ce
Give the router to Home and About only, from one list
claude Nov 3, 2026
cd5b9e8
Warn about an empty topics list too
claude Nov 3, 2026
1f36b5e
Say in TopicMap that only the canvas goes to the next page
claude Nov 3, 2026
319deac
Merge #40: redesign home page
meaningfool Nov 3, 2026
118b781
Merge #41: redesign About page
meaningfool Nov 3, 2026
61b24fd
Remove the unused legacy stylesheet
claude Nov 3, 2026
b5c7f46
Merge pull request #42: Redesign: remove the unused legacy stylesheet
meaningfool Nov 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 23 additions & 1 deletion astro.config.mjs
Original file line number Diff line number Diff line change
@@ -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),
Expand All @@ -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)
}
}
});
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 30 additions & 0 deletions docs/redesign/README.md
Original file line number Diff line number Diff line change
@@ -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).
41 changes: 41 additions & 0 deletions docs/redesign/map-tools/calib.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<meta charset="utf-8">
<title>Shader calibration</title>
<div id="h" style="width:540px;aspect-ratio:420/300;position:relative"></div>
<pre id="out"></pre>
<script type="module">
// Print a left-to-right ramp of ink (0 to 0.62) through the same shader settings as the maps, then measure
// how much of each column the dots cover. That gives ink -> dot coverage for the previews.
import * as P from 'https://cdn.jsdelivr.net/npm/@paper-design/shaders@0.0.81/+esm';
const W = 420, H = 300, holder = document.getElementById('h');
const c = document.createElement('canvas'); c.width = W; c.height = H;
const g = c.getContext('2d'), d = g.createImageData(W, H);
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
const ink = 0.62 * x / (W - 1), i = (y * W + x) * 4;
d.data[i] = d.data[i + 1] = d.data[i + 2] = 255 * (1 - ink); d.data[i + 3] = 255;
}
g.putImageData(d, 0, 0);
const img = new Image(); img.src = c.toDataURL('image/png'); await new Promise((r) => { img.onload = r; });
const PITCH = 7.8, sizeFor = (h) => Math.pow((300 - Math.min(300, Math.max(7, h / PITCH))) / 293, 1 / 0.7);
const m = 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), 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);
window.measure = () => {
m.devicePixelsSupported = false; m.parentWidth = holder.clientWidth; m.parentHeight = holder.clientHeight; m.handleResize(); m.render(performance.now());
const cv = holder.querySelector('canvas'), t = document.createElement('canvas'); t.width = cv.width; t.height = cv.height;
const tg = t.getContext('2d'); tg.drawImage(cv, 0, 0);
const px = tg.getImageData(0, Math.round(cv.height * 0.2), cv.width, Math.round(cv.height * 0.6)).data, rows = Math.round(cv.height * 0.6);
const bins = 31, cov = new Array(bins).fill(0), cnt = new Array(bins).fill(0);
for (let y = 0; y < rows; y++) for (let x = 0; x < cv.width; x++) {
const b = Math.min(bins - 1, Math.floor(x / cv.width * bins)), a = px[(y * cv.width + x) * 4 + 3] / 255;
cov[b] += a; cnt[b]++;
}
const table = cov.map((s, b) => [+(0.62 * (b + 0.5) / bins).toFixed(3), +(s / cnt[b]).toFixed(3)]);
document.getElementById('out').textContent = JSON.stringify(table);
return table;
};
</script>
1 change: 1 addition & 0 deletions docs/redesign/map-tools/calib.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[[0.0,0.0],[0.01,0.002],[0.03,0.002],[0.05,0.003],[0.07,0.002],[0.09,0.004],[0.11,0.002],[0.13,0.005],[0.15,0.003],[0.17,0.005],[0.19,0.005],[0.21,0.007],[0.23,0.008],[0.25,0.009],[0.27,0.014],[0.29,0.015],[0.31,0.022],[0.33,0.025],[0.35,0.037],[0.37,0.041],[0.39,0.061],[0.41,0.068],[0.43,0.096],[0.45,0.112],[0.47,0.146],[0.49,0.175],[0.51,0.206],[0.53,0.244],[0.55,0.286],[0.57,0.323],[0.59,0.369],[0.61,0.402],[0.62,0.41]]
122 changes: 122 additions & 0 deletions docs/redesign/map-tools/gen.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""Three-island maps from a few parameters, so variations differ on purpose and none is symmetrical.

An island is a low, irregular footprint (a few broad lobes, a bay cut into one coast) with 2-4 hills on it; the
hill under the topic's dot is the island's top. Links are chains of patches whose height rises and falls along
the way. Patches are [x, y, radius, weight] in the 420 x 300 box. Heights are tuned against the shader's
measured dot sizes (lib.height), aiming for round 7's spread: little land at the very top, most of it in between.
"""
import json
import math
import random
import sys

sys.path.insert(0, __import__('os').path.dirname(__file__))
from lib import sheet, spread # noqa: E402


def island(rnd, c, size, top, hills, bay, body=0.85, lobes=3):
"""c: the dot. body: weight of the island's footprint (its mid heights). top: extra weight at the dot's hill.
hills: [(dx, dy, r, w)] relative to c. bay: (angle deg, depth)."""
x0, y0 = c
out = [[x0, y0, size, body]]
for _ in range(lobes):
a = rnd.uniform(0, 2 * math.pi)
d = rnd.uniform(0.4, 0.8) * size
out.append([x0 + math.cos(a) * d * 1.2, y0 + math.sin(a) * d, size * rnd.uniform(0.5, 0.75), body * rnd.uniform(0.4, 0.6)])
out.append([x0 + rnd.uniform(-0.01, 0.01), y0 + rnd.uniform(-0.01, 0.01), 0.04, top])
# Each variation nudges the hills a little, so no two islands repeat each other.
for dx, dy, r, w in hills:
out.append([x0 + dx + rnd.uniform(-0.015, 0.015), y0 + dy + rnd.uniform(-0.015, 0.015), r * rnd.uniform(0.85, 1.15), w * rnd.uniform(0.85, 1.15)])
if bay:
a, depth = bay
out.append([x0 + math.cos(math.radians(a)) * size * 1.25, y0 + math.sin(math.radians(a)) * size * 0.95, size * 0.45, -depth])
return out


def chain(rnd, p0, p1, n, r, weights, bow=0.0, jitter=0.012):
"""Patches along a line from p0 to p1, bowed sideways by `bow` (positive bends to the right of travel)."""
out = []
(x0, y0), (x1, y1) = p0, p1
nx, ny = (y1 - y0), -(x1 - x0)
ln = math.hypot(nx, ny) or 1
for i in range(n):
t = (i + 0.5) / n
b = bow * math.sin(math.pi * t)
x = x0 + (x1 - x0) * t + nx / ln * b + rnd.uniform(-jitter, jitter)
y = y0 + (y1 - y0) * t + ny / ln * b + rnd.uniform(-jitter, jitter)
w = weights[min(len(weights) - 1, int(t * len(weights)))]
out.append([x, y, r * rnd.uniform(0.8, 1.2), w * rnd.uniform(0.85, 1.15)])
return out


def rocks(rnd, p0, p1, n, bow=0.0, spread_=0.02, big=0.25):
out = []
for i in range(n):
t = rnd.uniform(0.05, 0.95)
(x0, y0), (x1, y1) = p0, p1
nx, ny = (y1 - y0), -(x1 - x0)
ln = math.hypot(nx, ny) or 1
b = bow * math.sin(math.pi * t)
x = x0 + (x1 - x0) * t + nx / ln * b + rnd.uniform(-spread_, spread_)
y = y0 + (y1 - y0) * t + ny / ln * b + rnd.uniform(-spread_, spread_)
isbig = rnd.random() < big
out.append([x, y, rnd.uniform(0.016, 0.024) if isbig else rnd.uniform(0.009, 0.014), rnd.uniform(1.0, 1.4) if isbig else rnd.uniform(0.6, 0.9)])
return out


def build(v):
"""The map as one patch list (see parts() for the same patches grouped by what they draw)."""
return [p for g in parts(v).values() for p in g]


def parts(v):
"""Patches grouped: cs, product, agents (islands), left, right, top (links), extra (hand-placed), islets.
Grouping doesn't change the random sequence, so the same spec always gives the same patches."""
rnd = random.Random(v['seed'])
cs, pr, ag = v['cs'], v['product'], v['agents']
G = {}
G['cs'] = island(rnd, cs, v.get('cs_size', 0.12), v.get('cs_top', 0.45), v['cs_hills'], v.get('cs_bay', (200, 0.8)), body=v.get('cs_body', 0.72))
G['product'] = island(rnd, pr, v.get('pr_size', 0.11), v.get('pr_top', 0.65), v['pr_hills'], v.get('pr_bay'), body=v.get('pr_body', 0.82))
G['agents'] = island(rnd, ag, v.get('ag_size', 0.13), v.get('ag_top', 0.85), v['ag_hills'], v.get('ag_bay'), body=v.get('ag_body', 0.92))
# Customer success to Product: substantial, with a dip and its own hill
G['left'] = chain(rnd, (cs[0] + 0.01, cs[1] + 0.1), (pr[0] - 0.12, pr[1] - 0.08), 6, 0.04, v.get('left_w', [0.7, 0.5, 0.9, 0.6, 0.7]), bow=v.get('left_bow', -0.02))
# Product to AI agents: merged, a saddle between rises
G['right'] = chain(rnd, (pr[0] + 0.1, pr[1] - 0.05), (ag[0] - 0.09, ag[1] + 0.11), 5, 0.036, v.get('right_w', [0.65, 0.35, 0.6]), bow=v.get('right_bow', 0.02))
# Customer success to AI agents: shallow; its path is what the variations play with
G['top'], G['extra'] = [], []
for seg in v['top']:
kind = seg[0]
if kind == 'shelf':
_, p0, p1, n, w, bow = seg
G['top'] += chain(rnd, p0, p1, n, 0.034, [w], bow=bow, jitter=0.015)
elif kind == 'rocks':
_, p0, p1, n, bow = seg
G['top'] += rocks(rnd, p0, p1, n, bow=bow)
elif kind == 'patch':
G['extra'].append(list(seg[1]))
G['islets'] = [[x, y, rnd.uniform(0.016, 0.022), rnd.uniform(0.7, 1.0)] for x, y in v.get('islets', [])]
return {k: [[round(a, 4) for a in p] for p in g] for k, g in G.items()}


def compose(specs, recipe):
"""recipe: {'from': {group: spec name}, 'pins': {'cs'|'product'|'agents': spec name}} -> patches and pins,
taking each group exactly as it was generated in the named spec."""
built = {name: parts(specs[name]) for name in set(recipe['from'].values()) | set(recipe['pins'].values())}
P = [p for group, name in recipe['from'].items() for p in built[name][group]]
pins = [specs[recipe['pins'][k]][key] for k, key in (('cs', 'cs'), ('product', 'product'), ('agents', 'agents'))]
return P, pins


if __name__ == '__main__':
specs = json.load(open(sys.argv[1]))
items, out = [], {}
for name, v in specs.items():
if 'recipe' in v:
P, pins = compose(specs, v['recipe'])
else:
P, pins = build(v), [v['cs'], v['product'], v['agents']]
out[name] = {'patches': P, 'pins': pins}
items.append((name, P, pins))
print(name, spread(P)[0])
json.dump(out, open(sys.argv[2], 'w'))
sheet(items, sys.argv[3])
Loading
Loading