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
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,9 @@ categories: [development, design]
3. Place images in `src/assets/img-raw/[project-name]/`
4. Run `pnpm run images:optimize`

### Page Motion (opt-in)
Add `motion: true` to a page's front matter to load `src/js/motion.js`. On `page.njk` and `markdown.njk` layouts this fades in the page header and reveals top-level content sections (card grids stagger card by card) as they scroll into view. Pages on `base.njk` can mark elements directly with `data-reveal`, `data-reveal-group`, or `data-reveal-auto`. All motion is skipped for `prefers-reduced-motion`, and content stays visible without JS. The topographic background (`src/sass/_topo.scss`, `src/js/topo.js`) applies sitewide; regenerate its SVG with `node scripts/generate-topo.js [seed]`.

### Modify Site Navigation
Edit `eleventyNavigation` in page front matter:
- `key`: Unique identifier
Expand Down
180 changes: 180 additions & 0 deletions scripts/generate-topo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/*
** Generates src/assets/img/topo.svg, the faint topographic contour texture
** used as the site background. Builds a seeded height field (a few hills plus
** noise), traces contour lines with marching squares, and writes smoothed paths.
**
** Usage: node scripts/generate-topo.js [seed]
*/
const fs = require('fs');
const path = require('path');

const WIDTH = 1600;
const HEIGHT = 1000;
const CELL = 8;
const LEVELS = 18;
const SEED = Number(process.argv[2]) || 1977;
const OUT = path.join(__dirname, '../src/assets/img/topo.svg');

// Seeded PRNG (mulberry32) so the output is reproducible
function rng(seed) {
return function () {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const rand = rng(SEED);

// Smooth value noise for irregular ridgelines
const NOISE_SIZE = 64;
const lattice = Array.from({ length: NOISE_SIZE * NOISE_SIZE }, rand);
function noise(x, y) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const at = (i, j) => lattice[((j & 63) * NOISE_SIZE) + (i & 63)];
const sx = fx * fx * (3 - 2 * fx);
const sy = fy * fy * (3 - 2 * fy);
const top = at(xi, yi) + (at(xi + 1, yi) - at(xi, yi)) * sx;
const bottom = at(xi, yi + 1) + (at(xi + 1, yi + 1) - at(xi, yi + 1)) * sx;
return top + (bottom - top) * sy;
}

const hills = Array.from({ length: 7 }, () => ({
x: rand() * WIDTH,
y: rand() * HEIGHT,
r: 180 + rand() * 260,
h: 0.5 + rand() * 0.8,
}));

function height(x, y) {
let v = 0;
for (const hill of hills) {
const d2 = (x - hill.x) ** 2 + (y - hill.y) ** 2;
v += hill.h * Math.exp(-d2 / (2 * hill.r * hill.r));
}
v += 0.35 * noise(x / 220, y / 220) + 0.12 * noise(x / 90 + 17, y / 90 + 5);
return v;
}

// Sample the field
const cols = WIDTH / CELL + 1;
const rows = HEIGHT / CELL + 1;
const field = [];
let min = Infinity;
let max = -Infinity;
for (let j = 0; j < rows; j++) {
for (let i = 0; i < cols; i++) {
const v = height(i * CELL, j * CELL);
field.push(v);
min = Math.min(min, v);
max = Math.max(max, v);
}
}
const f = (i, j) => field[j * cols + i];

// Marching squares: one level -> list of segments
function segmentsFor(level) {
const segs = [];
const lerp = (a, b, va, vb) => a + (b - a) * ((level - va) / (vb - va));
for (let j = 0; j < rows - 1; j++) {
for (let i = 0; i < cols - 1; i++) {
const a = f(i, j);
const b = f(i + 1, j);
const c = f(i + 1, j + 1);
const d = f(i, j + 1);
const x = i * CELL;
const y = j * CELL;
const top = [lerp(x, x + CELL, a, b), y];
const right = [x + CELL, lerp(y, y + CELL, b, c)];
const bottom = [lerp(x, x + CELL, d, c), y + CELL];
const left = [x, lerp(y, y + CELL, a, d)];
const idx = (a > level ? 8 : 0) | (b > level ? 4 : 0) | (c > level ? 2 : 0) | (d > level ? 1 : 0);
switch (idx) {
case 1: case 14: segs.push([left, bottom]); break;
case 2: case 13: segs.push([bottom, right]); break;
case 3: case 12: segs.push([left, right]); break;
case 4: case 11: segs.push([top, right]); break;
case 6: case 9: segs.push([top, bottom]); break;
case 7: case 8: segs.push([left, top]); break;
case 5: segs.push([left, top], [bottom, right]); break;
case 10: segs.push([top, right], [left, bottom]); break;
default: break;
}
}
}
return segs;
}

// Join segments that share endpoints into polylines
function join(segs) {
const key = (p) => `${p[0].toFixed(2)},${p[1].toFixed(2)}`;
const byPoint = new Map();
segs.forEach((s, n) => {
for (const p of s) {
const k = key(p);
if (!byPoint.has(k)) byPoint.set(k, []);
byPoint.get(k).push(n);
}
});
const used = new Set();
const lines = [];
const extend = (line, atEnd) => {
for (;;) {
const tip = atEnd ? line[line.length - 1] : line[0];
const next = (byPoint.get(key(tip)) || []).find((n) => !used.has(n));
if (next === undefined) return;
used.add(next);
const [p, q] = segs[next];
const other = key(p) === key(tip) ? q : p;
if (atEnd) line.push(other);
else line.unshift(other);
}
};
segs.forEach((s, n) => {
if (used.has(n)) return;
used.add(n);
const line = [s[0], s[1]];
extend(line, true);
extend(line, false);
lines.push(line);
});
return lines;
}

// Smooth polyline into a path of quadratic curves through segment midpoints
function toPath(pts) {
if (pts.length < 4) return '';
const r = (n) => Math.round(n * 10) / 10;
const mid = (p, q) => [(p[0] + q[0]) / 2, (p[1] + q[1]) / 2];
let d = `M${r(pts[0][0])} ${r(pts[0][1])}`;
for (let k = 1; k < pts.length - 1; k += 2) {
const m = mid(pts[k], pts[k + 1]);
d += `Q${r(pts[k][0])} ${r(pts[k][1])} ${r(m[0])} ${r(m[1])}`;
}
const last = pts[pts.length - 1];
d += `L${r(last[0])} ${r(last[1])}`;
return d;
}

const minor = [];
const index = [];
for (let n = 1; n <= LEVELS; n++) {
const level = min + ((max - min) * n) / (LEVELS + 1);
const d = join(segmentsFor(level)).map(toPath).filter(Boolean).join('');
(n % 5 === 0 ? index : minor).push(d);
}

const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${WIDTH} ${HEIGHT}" preserveAspectRatio="xMidYMid slice">
<g fill="none" stroke="#4a3426" stroke-linecap="round" stroke-linejoin="round">
<path stroke-width="1.2" d="${minor.join('')}"/>
<path stroke-width="2.4" d="${index.join('')}"/>
</g>
</svg>
`;

fs.writeFileSync(OUT, svg);
console.log(`Wrote ${path.relative(process.cwd(), OUT)} (${(svg.length / 1024).toFixed(1)} KB, seed ${SEED})`);
4 changes: 4 additions & 0 deletions src/_includes/base.njk
Original file line number Diff line number Diff line change
Expand Up @@ -112,5 +112,9 @@
})();
</script>
<script src="{{ '/js/prism.js' | url }}"></script>
<script src="{{ '/js/topo.js' | url }}"></script>
{%- if motion %}
<script src="{{ '/js/motion.js' | url }}"></script>
{%- endif %}
</body>
</html>
68 changes: 68 additions & 0 deletions src/_includes/hero-scene.njk
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<div class="hero-scene" aria-hidden="true">
<div class="hero-layer hero-layer-sky" style="--depth: 0.6; --delay: 0s;">
<svg viewBox="0 0 1600 500" preserveAspectRatio="xMidYMax slice" focusable="false">
<circle class="hero-sun" cx="1000" cy="150" r="56" />
<g class="hero-cloud hero-cloud-one">
<path d="M0,40 a22,22 0 0 1 30,-20 a30,30 0 0 1 56,4 a20,20 0 0 1 24,16 z" />
</g>
<g class="hero-cloud hero-cloud-two">
<path d="M0,40 a18,18 0 0 1 26,-16 a26,26 0 0 1 48,2 a16,16 0 0 1 20,14 z" />
</g>
</svg>
</div>
<div class="hero-layer hero-layer-far" style="--depth: 0.45; --delay: 0.1s;">
<svg viewBox="0 0 1600 500" preserveAspectRatio="xMidYMax slice" focusable="false">
<path d="M0,300 L120,240 L210,270 L330,180 L430,240 L520,200 L640,250 L760,170 L880,230 L1000,190 L1120,250 L1240,160 L1360,230 L1480,200 L1600,250 L1600,500 L0,500 Z" />
</svg>
</div>
<div class="hero-layer hero-layer-mid" style="--depth: 0.3; --delay: 0.25s;">
<svg viewBox="0 0 1600 500" preserveAspectRatio="xMidYMax slice" focusable="false">
<path d="M0,360 L140,300 L240,330 L380,250 L500,320 L600,290 L720,340 L800,300 L960,360 L1080,280 L1200,330 L1320,270 L1460,330 L1600,300 L1600,500 L0,500 Z" />
</svg>
</div>
<div class="hero-layer hero-layer-peak" style="--depth: 0.15; --delay: 0.4s;">
<svg viewBox="0 0 1600 500" preserveAspectRatio="xMidYMax slice" focusable="false">
<defs>
<mask id="heroTrailMask" maskUnits="userSpaceOnUse" x="0" y="0" width="1600" height="500">
<path class="hero-trail-reveal" pathLength="1" d="M590,480 C640,455 700,452 720,432 S660,402 700,386 S800,370 790,342 S730,302 770,282 S850,262 830,232 S800,202 845,186 S878,165 880,152" />
</mask>
</defs>
<path class="hero-peak" d="M0,440 L160,400 L300,420 L440,350 L560,380 L700,260 L780,220 L840,170 L880,150 L920,175 L980,230 L1060,290 L1160,340 L1300,380 L1420,360 L1600,410 L1600,500 L0,500 Z" />
<path class="hero-snow" d="M840,170 L880,150 L920,175 L906,186 L892,174 L876,190 L860,178 Z" />
<path class="hero-trail-path" mask="url(#heroTrailMask)" d="M590,480 C640,455 700,452 720,432 S660,402 700,386 S800,370 790,342 S730,302 770,282 S850,262 830,232 S800,202 845,186 S878,165 880,152" />
<g class="hero-hiker" transform="translate(870 152)">
<g class="hero-hiker-figure" transform="scale(1.25)">
<line class="hero-hiker-leg hero-hiker-leg-back" x1="0" y1="-8" x2="-2" y2="0" />
<line class="hero-hiker-leg hero-hiker-leg-front" x1="0" y1="-8" x2="2" y2="0" />
<rect class="hero-hiker-pack" x="-5.5" y="-16" width="4.5" height="7.5" rx="1.5" />
<line class="hero-hiker-torso" x1="0" y1="-8" x2="0.6" y2="-16" />
<circle class="hero-hiker-head" cx="1.2" cy="-19" r="2.6" />
<line class="hero-hiker-stick" x1="3.5" y1="-12" x2="6" y2="0" />
</g>
</g>
<g class="hero-flag">
<line x1="880" y1="152" x2="880" y2="104" />
<path class="hero-flag-cloth" d="M880,104 L914,114 L880,124 Z" />
</g>
</svg>
</div>
<div class="hero-layer hero-layer-front" style="--depth: 0; --delay: 0.55s;">
<svg viewBox="0 0 1600 500" preserveAspectRatio="xMidYMax slice" focusable="false">
<defs>
<symbol id="heroPine" viewBox="0 0 30 50">
<path d="M15,0 L26,20 L21,20 L30,38 L18,38 L18,50 L12,50 L12,38 L0,38 L9,20 L4,20 Z" />
</symbol>
</defs>
<g class="hero-pines">
<use href="#heroPine" x="120" y="400" width="30" height="50" />
<use href="#heroPine" x="150" y="390" width="38" height="62" />
<use href="#heroPine" x="186" y="408" width="26" height="42" />
<use href="#heroPine" x="520" y="410" width="28" height="46" />
<use href="#heroPine" x="1250" y="396" width="34" height="56" />
<use href="#heroPine" x="1284" y="406" width="26" height="44" />
<use href="#heroPine" x="1316" y="392" width="38" height="62" />
</g>
<path class="hero-foreground" d="M-10,470 C120,440 240,450 360,460 S560,430 700,455 S900,468 1040,450 S1300,430 1420,455 S1560,450 1610,440 L1610,510 L-10,510 Z" />
</svg>
</div>
</div>
4 changes: 2 additions & 2 deletions src/_includes/markdown.njk
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ layout: base.njk
<link rel="stylesheet" href="{{ '/css/containers.css' | url }}" />
{% endif %}

<section class="hero">
<section class="hero{% if motion %} hero-intro{% endif %}">
<h1>{{ title }}</h1>
<p class="text-lead mb-4">{{ description }}</p>
{%- if (headerButton) -%}
Expand All @@ -21,7 +21,7 @@ layout: base.njk
{{ content | safe }}
</section>
{% else %}
<content>
<content{% if motion %} data-reveal-auto{% endif %}>
{{ content | safe }}
</content>
{% endif %}
Expand Down
4 changes: 2 additions & 2 deletions src/_includes/page.njk
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@
layout: base.njk
---

<section class="hero" aria-labelledby="hero-heading">
<section class="hero{% if motion %} hero-intro{% endif %}" aria-labelledby="hero-heading">
<h1 id="hero-heading">{{ title }}</h1>
<p>{{ description }}</p>
{%- if (headerButton) -%}
<a href="{{ primaryURL}}" class="btn">{{ primaryAction }}</a>
{%- else -%}{%- endif -%}
</section>

<content>
<content{% if motion %} data-reveal-auto{% endif %}>
{{ content | safe }}
</content>

Expand Down
6 changes: 6 additions & 0 deletions src/assets/img/topo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
40 changes: 26 additions & 14 deletions src/index.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
---
layout: base.njk
motion: true
---

<section class="hero" aria-labelledby="hero-heading" style="height: calc(100vh - 4rem);">
<h1 id="hero-heading">Adam Jolicoeur</h1>
<p>Product designer building AI-native products, from interaction patterns to the systems that govern them. 15+ years at AWS, Red Hat, and high-growth B2B.</p>
<p class="text-body" style="font-style: italic; padding: 2rem 0;">Enterprise product designer. Systems thinker. Builder.</p>
<section class="hero hero-trail" aria-labelledby="hero-heading">
{% include 'hero-scene.njk' %}
<div class="hero-content">
<h1 id="hero-heading">Adam Jolicoeur</h1>
<p>Product designer building AI-native products, from interaction patterns to the systems that govern them. 15+ years at AWS, Red Hat, and high-growth B2B.</p>
<p class="text-body hero-tagline">Enterprise product designer. Systems thinker. Builder.</p>
</div>
<a class="hero-cue" href="#about">
<span>Start the hike</span>
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
</a>
<button type="button" class="hero-pause" aria-label="Pause scenery animation" aria-pressed="false" hidden>
<svg class="hero-pause-icon" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false"><path d="M4 2h3v12H4zM9 2h3v12H9z" fill="currentColor" /></svg>
<svg class="hero-play-icon" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false"><path d="M4 2l10 6-10 6z" fill="currentColor" /></svg>
</button>
</section>
<section id="about" aria-labelledby="about-heading">
<h2 id="about-heading" class="text-h2 no-underline">About Me</h2>
<div class="card about-card">
<h2 id="about-heading" class="text-h2 no-underline" data-reveal>About Me</h2>
<div class="card about-card" data-reveal>
<div class="card-body">
<p class="text-body-lg text-center">Lead Product designer building AI-native products, from interaction patterns to the systems that govern them.</p>
<p>15+ years across AWS, Red Hat, and high-growth B2B, including an internal AI assistant, MCP architecture, and design systems used on 100+ construction projects.</p>
Expand All @@ -33,9 +45,9 @@ layout: base.njk
</div>
</section>
<section id="work" aria-labelledby="work-heading">
<h2 id="work-heading" class="text-h2 no-underline">Featured Work</h2>
<h2 id="work-heading" class="text-h2 no-underline" data-reveal>Featured Work</h2>
<div class="work-grid">
<article class="showcase-large">
<article class="showcase-large" data-reveal>
<img src="{{ '/assets/img/casim-dashboard.webp' | url }}" alt="CASim dashboard screen" class="showcase-image" />
<div class="showcase-content">
<h3>CASim: Internal AI Assistant</h3>
Expand All @@ -46,7 +58,7 @@ layout: base.njk
<a href="{{ '/portfolio/casim' | url }}" class="btn btn-primary" alt="View CASim case study">View Case Study</a>
</div>
</article>
<article class="showcase-large">
<article class="showcase-large" data-reveal>
<img src="{{ '/assets/img/timetracker-loading-state.webp' | url }}" alt="Weekly AI-powered work summary loading screen" class="showcase-image" />
<div class="showcase-content">
<h3>Weekly AI-Powered Work Summary</h3>
Expand All @@ -59,7 +71,7 @@ layout: base.njk
</article>
</div>
<div class="work-grid">
<div class="small-showcase-cards">
<div class="small-showcase-cards" data-reveal-group>
<article class="card-flex showcase-small">
<h4>ArchParser</h4>
<p>Architectural drawing analysis platform for OCR-based architectural drawing analysis management.</p>
Expand Down Expand Up @@ -97,8 +109,8 @@ layout: base.njk
</div>
</section>
<section id="testimonials" aria-labelledby="callouts-heading">
<h2 id="callouts-heading">What Colleagues Say</h2>
<div class="callouts-grid">
<h2 id="callouts-heading" data-reveal>What Colleagues Say</h2>
<div class="callouts-grid" data-reveal-group>
<blockquote class="callout">
<p class="callout-text">[Adam] is self-driven and keeps stakeholders (Project manager, Development manager, Engineers) aligned on UX solutions...design proposals are spot-on and considerate of [our] diverse set of users.</p>
<cite>
Expand All @@ -120,10 +132,10 @@ layout: base.njk
</div>
</section>
<section id="opensource" aria-labelledby="opensource-heading">
<h2 id="opensource-heading">Open Source Contributions</h2>
<h2 id="opensource-heading" data-reveal>Open Source Contributions</h2>
<div>
<p class="text-body-lg" style="margin-bottom: 2rem;">Beyond my professional work, I actively contribute to the open source community. My contributions span developer tools, design systems, and infrastructure projects that support the broader tech ecosystem.</p>
<div class="about-grid">
<div class="about-grid" data-reveal-group>
<div class="card-flex showcase-small">
<h4 style="margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
<span style="font-size: 1.5rem;">🚀</span>
Expand Down
Loading
Loading