diff --git a/AGENTS.md b/AGENTS.md index 2ee8e4a..dd8ad55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,7 @@ Josselin Perrus's personal site: an Astro 7 static site served by Cloudflare Wor - `src/`: pages, layouts and components. Pages are built in the `Frame` component, which holds the header and the footer; the portfolio view of `/documents/` has its own. - `src/styles/`: `tokens.css` holds the colours and fonts as custom properties, `global.css` the base styles, and `legacy.css` the content styles of the pages not yet rebuilt in the redesign. - `src/fonts/`: the self-hosted fonts, DejaVu Sans Mono and Newsreader, subset by `scripts/subset-fonts.py`, with their licences. The site loads no outside fonts. +- 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. - `docs/document-sharing.md`: how the encrypted portfolio links on `/documents/` work and how to create them. diff --git a/astro.config.mjs b/astro.config.mjs index d5e09bf..f8a7364 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -18,7 +18,8 @@ export default defineConfig({ }), }, integrations: [sitemap({ - filter: (page) => !/^\/documents\/?$/.test(new URL(page).pathname), + // TEMPORARY (#30): /map-preview/ shows the intro and the map until the home uses them; #31 deletes it. + filter: (page) => !/^\/(documents|map-preview)\/?$/.test(new URL(page).pathname), serialize: (item) => ({ ...item, lastmod: lastModified.get(new URL(item.url).pathname) }), }), trailingSlashRedirects()], site: 'https://meaningfool.net', @@ -29,6 +30,11 @@ export default defineConfig({ 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/docs/redesign/README.md b/docs/redesign/README.md index 8cbe61f..54810d0 100644 --- a/docs/redesign/README.md +++ b/docs/redesign/README.md @@ -26,5 +26,5 @@ python3 docs/redesign/prototype/build.py ## 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 at build time (#30). +- 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/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/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/components/Background.astro b/src/components/Background.astro new file mode 100644 index 0000000..38792f8 --- /dev/null +++ b/src/components/Background.astro @@ -0,0 +1,260 @@ +--- +// 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. +interface Props { + /** The page has the centred 720px text column of articles: the pattern is cleared behind it. */ + textColumn?: boolean; +} + +const { textColumn = false } = Astro.props; +--- + + + + + + 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/TopicMap.astro b/src/components/TopicMap.astro new file mode 100644 index 0000000..d202f8b --- /dev/null +++ b/src/components/TopicMap.astro @@ -0,0 +1,316 @@ +--- +// 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. +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] })); +--- + + + + +
+ {topics.map((t) => {t.area})} + {topics.map((t) => + t.href ? ( + + {t.name} + + ) : ( + + ), + )} +
+
+ + + + 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/layouts/Layout.astro b/src/layouts/Layout.astro index dd7dfc5..7717a49 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -1,17 +1,32 @@ --- import '../styles/global.css'; import '../styles/legacy.css'; +import Background from '../components/Background.astro'; import Frame from '../components/Frame.astro'; import Favicons from '../components/Favicons.astro'; import monoFont from '../fonts/dejavu-sans-mono.woff2?url'; import { SITE_SHARE, type Share } from '../lib/share'; +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', share = SITE_SHARE as Share, - type = 'website' + type = 'website', + textColumn = type === 'article', + noindex = false, } = Astro.props; const ogImage = new URL(share.path, Astro.site).toString(); @@ -29,6 +44,7 @@ const canonical = canonicalURL.toString(); {title} + {noindex && } @@ -59,6 +75,7 @@ const canonical = canonicalURL.toString(); + diff --git a/src/pages/404.astro b/src/pages/404.astro index 72baa37..6dd4607 100644 --- a/src/pages/404.astro +++ b/src/pages/404.astro @@ -1,5 +1,6 @@ --- 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'; @@ -18,6 +19,7 @@ import monoFont from '../fonts/dejavu-sans-mono.woff2?url'; + diff --git a/src/pages/documents.astro b/src/pages/documents.astro index 44c9d39..803a124 100644 --- a/src/pages/documents.astro +++ b/src/pages/documents.astro @@ -1,5 +1,6 @@ --- import '../styles/tokens.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'; @@ -27,7 +28,8 @@ import Favicons from '../components/Favicons.astro'; -
+ {/* The not-found view looks like /404.html, background included. The portfolio view keeps its plain page. */} +