Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ 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
Expand Down
13 changes: 11 additions & 2 deletions src/components/Background.astro
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,24 @@
// 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 } = Astro.props;
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;
---

<page-background aria-hidden="true">
<page-background aria-hidden="true" data-astro-transition-persist={keep}>
<div class="paper"></div>
<canvas class="pattern" data-mask={textColumn ? 'column' : undefined}></canvas>
</page-background>
Expand Down
32 changes: 32 additions & 0 deletions src/components/IntroRouter.astro
Original file line number Diff line number Diff line change
@@ -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 <head> 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';
---

<ClientRouter fallback="swap" />

<script>
import { isIntroPage } from '../lib/intro-pages';

document.addEventListener('astro:before-preparation', (event) => {
if (!isIntroPage(event.to.pathname)) event.preventDefault();
});
</script>

{/* Astro's transition:animate="none", for the whole page. Inline, so that only these pages have it. */}
<style is:inline>
::view-transition-group(root) { animation: none; }
::view-transition-old(root) { animation: none; opacity: 0; mix-blend-mode: normal; }
::view-transition-new(root) { animation: none; mix-blend-mode: normal; }
</style>
38 changes: 31 additions & 7 deletions src/components/TopicMap.astro
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
// - With `links`, the dots are links, for example to the matching part of a page; otherwise they are buttons.
// - Setting data-picked="<topic>" on the <topic-map> 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 <topic-map>, 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';

Expand Down Expand Up @@ -38,7 +43,8 @@ const topics = spec.topics.map((t) => ({ ...t, id: t.id as Topic, href: links[t.

<link rel="preload" href={mapUrl} as="image" type="image/png" />
<topic-map class="tm" role="group" aria-label={label} data-src={mapUrl} style={`aspect-ratio: ${boxW} / ${boxH}`}>
<canvas class="tm-print" aria-hidden="true"></canvas>
{/* data-astro-transition-persist is what transition:persist sets, without Astro's view transition styles. */}
<canvas class="tm-print" aria-hidden="true" data-astro-transition-persist="topic-map-print"></canvas>
<div class="tm-labels">
{topics.map((t) => <span class="tm-area" style={place(t.areaAt)}>{t.area}</span>)}
{topics.map((t) =>
Expand Down Expand Up @@ -155,6 +161,10 @@ const topics = spec.topics.map((t) => ({ ...t, id: t.id as Topic, href: links[t.
type MapData = { grey: Uint8Array; w: number; h: number; mask: HTMLCanvasElement };
let loading: Promise<MapData> | null = null;

// What each canvas holds: its [width, height, pixel ratio] when drawn. It is kept by canvas rather than by map, so a
// canvas carried over to the next page's map counts as drawn there.
const drawnSizes = new WeakMap<HTMLCanvasElement, number[]>();

// map.png (data-src) is the grey image the prototype gave the shader. The prototype also masked the print to where
// there is ink, as the shader leaves a faint dot on bare paper, and faded the land over the outer 4% of the box.
// If the image can't be had, the map shows its labels and dots alone, and the next draw tries again.
Expand Down Expand Up @@ -245,7 +255,6 @@ const topics = spec.topics.map((t) => ({ ...t, id: t.id as Topic, href: links[t.

class TopicMap extends HTMLElement {
static observedAttributes = ['data-picked'];
#drawn: number[] | null = null;
#drawing = false;
#timer = 0;
#observer = new ResizeObserver(() => this.#resized());
Expand All @@ -260,7 +269,8 @@ const topics = spec.topics.map((t) => ({ ...t, id: t.id as Topic, href: links[t.
});
}

// Runs again if the element is moved, for example kept from one page to the next: what is drawn stays.
// Runs again if the element is moved: what is drawn stays. From one page to the next, only the canvas is kept, and
// the next page's map takes it (see the top of this file).
connectedCallback() {
this.#mark();
this.#observer.observe(this);
Expand All @@ -277,27 +287,41 @@ const topics = spec.topics.map((t) => ({ ...t, id: t.id as Topic, href: links[t.
this.#mark();
}

#canvas() {
return this.querySelector<HTMLCanvasElement>('.tm-print');
}

#draw() {
const canvas = this.querySelector<HTMLCanvasElement>('.tm-print');
const canvas = this.#canvas();
if (!canvas || !this.dataset.src || this.#drawing || !this.clientWidth) return;
this.#drawing = true;
load(this.dataset.src)
.then((data) => {
this.#drawn = draw(this, canvas, data);
// The canvas may have gone to another page's map meanwhile: that map draws it if it needs to.
if (!this.contains(canvas)) return;
const drawn = draw(this, canvas, data);
if (drawn) drawnSizes.set(canvas, drawn);
})
.catch(() => {})
.finally(() => {
this.#drawing = false;
});
}

#drawnSize() {
const canvas = this.#canvas();
return canvas && drawnSizes.get(canvas);
}

// Drawn once. While the map is resized the browser stretches what is drawn; once resizing stops, it is drawn
// again, and only if its size changed by more than a tenth, or it moved to a screen with another pixel density.
#resized = () => {
clearTimeout(this.#timer);
if (!this.#drawn) return this.#draw();
if (!this.#drawnSize()) return this.#draw();
this.#timer = window.setTimeout(() => {
const [w, h, dpr] = this.#drawn!;
const drawn = this.#drawnSize();
if (!drawn) return this.#draw();
const [w, h, dpr] = drawn;
if (Math.abs(this.clientWidth - w) > w / 10 || Math.abs(this.clientHeight - h) > h / 10 || pixelRatio() !== dpr) this.#draw();
}, 200);
};
Expand Down
10 changes: 9 additions & 1 deletion src/layouts/Layout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import '../styles/legacy.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;
Expand All @@ -30,6 +32,10 @@ const {
} = 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);
const canonical = canonicalURL.toString();
Expand Down Expand Up @@ -72,10 +78,12 @@ const canonical = canonicalURL.toString();

<!-- Every page's text starts in the mono font, so fetch it with the page -->
<link rel="preload" href={monoFont} as="font" type="font/woff2" crossorigin />

{introPage && <IntroRouter />}
</head>
<body>
<a class="skip-link" href="#main">Skip to main content</a>
<Background textColumn={textColumn} />
<Background textColumn={textColumn} persist={introPage} />
<Frame>
<slot />
</Frame>
Expand Down
6 changes: 6 additions & 0 deletions src/lib/intro-pages.ts
Original file line number Diff line number Diff line change
@@ -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);
157 changes: 142 additions & 15 deletions src/pages/about.astro
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
---
// 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
Expand All @@ -15,30 +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';
---

<Layout title="About | meaningfool" description="About Josselin Perrus - Product manager based in Paris">
<JsonLd data={personSchema} />
<main id="main" class="legacy">
<div class="content">
<h1>About me</h1>
<p>🏁 Customer success is the goal. (Productized) software may be solution. And it may not. Whatever works</p>
<p>👂 Actively listening is where it all starts and ends.</p>
<p>⚙️ "Perfection is achieved when there is nothing left to take away." is the rightful defintion of a solution / system</p>
<p>&nbsp;</p>
<p>🐥 I'm the father of 1, and when I get away, I'm probably bikepaching 🚴‍♂️, or sailing ⛵.</p>
<p>👨‍🚀 What get's me going: solving problems, learning, exploring / pioneering a field.</p>
<p>🗺️ AI is clearing the map. I'm out there trying to make sense of the new landscape. In public</p>
<h1>Work with me</h1>
<p>👨‍💻 I mainly do freelance product manager work.</p>
<p>💥 But if you think I could be a unique fit for a permanent position, please reach out.</p>
<main id="main" class="about">
<Intro label="About" links={{ 'customer-success': '#customer-success', product: '#product', 'ai-agents': '#ai-agents' }}>
<Fragment slot="title">Customer success,<br />Product,<br />AI agents<span>.</span></Fragment>
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.
</Intro>

<div class="parts">
<section class="part" id="customer-success" aria-labelledby="customer-success-name">
<div class="tag">
<h2 id="customer-success-name">Customer Success</h2>
<p>Identify the value</p>
</div>
<div class="text">
<p>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.</p>
<p>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.</p>
<p>Pragmatic, because a now-solution beats everyday a beautiful solution.</p>
</div>
</section>

<section class="part" id="product" aria-labelledby="product-name">
<div class="tag">
<h2 id="product-name">Product</h2>
<p>Decide what to build</p>
</div>
<div class="text">
<p>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.</p>
<p>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.</p>
</div>
</section>

<section class="part" id="ai-agents" aria-labelledby="ai-agents-name">
<div class="tag">
<h2 id="ai-agents-name">AI Agents</h2>
<p>Software factory</p>
</div>
<div class="text">
<p>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.</p>
<p>I build them end to end with coding agents, from the infrastructure to the prompts, from backend/frontend to benchmarks and evals.</p>
<p>I <em>compound</em> building my own set of tools such as my <em>boundary model</em> Skills (<a href={boundaryModelSkills}>GitHub</a>).</p>
</div>
</section>
</div>
</main>
</Layout>

<style>
/* The text of About is in the serif, like the text of articles. */
.content p {
.about {
padding-bottom: 24px;
}

/* The parts sit on the intro's two columns: the heading under the intro text, the text under the map. */
.part {
display: grid;
grid-template-columns: 500px minmax(480px, 636px);
justify-content: space-between;
gap: 12px 56px;
padding: 32px 0 44px;
border-top: 1px solid var(--line);
scroll-margin-top: 24px;
}

/* The heading stays in view while its part is read. */
.tag {
position: sticky;
top: 32px;
align-self: start;
display: grid;
gap: 6px;
}

/* The orange dot, then the name. */
h2 {
display: flex;
align-items: center;
gap: 10px;
margin: 0;
font-weight: 400;
font-size: 18px;
line-height: 1.6;
letter-spacing: -0.04em;
}

h2::before {
content: '';
flex: none;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--accent);
}

/* The map's label for the part, in grey capitals. */
.tag p {
font-size: 10px;
line-height: 1.6;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}

/* The article's serif and size. */
.text {
display: grid;
gap: 20px;
font-family: var(--serif);
font-size: 20px;
line-height: 32px;
}

.text a {
color: inherit;
text-decoration: underline;
text-decoration-color: var(--accent);
text-underline-position: auto;
text-underline-offset: 4px;
}

/* The map's dots link to the parts: scroll there smoothly, unless motion is to be reduced. */
@media (prefers-reduced-motion: no-preference) {
:global(:root:has(.about .parts)) {
scroll-behavior: smooth;
}
}

/* Below 1120px, as the intro, one column, up to 720px. */
@media (max-width: 1119px) {
.part {
grid-template-columns: minmax(0, 720px);
justify-content: start;
}

.tag {
position: static;
}
}

@media (max-width: 900px) {
.text {
font-size: 18px;
line-height: 29px;
}
}
</style>
Loading