diff --git a/CLAUDE.md b/CLAUDE.md index 6fc1918c..76b9c493 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/scripts/generate-topo.js b/scripts/generate-topo.js new file mode 100644 index 00000000..d30b4e5a --- /dev/null +++ b/scripts/generate-topo.js @@ -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 = ` + + + + + +`; + +fs.writeFileSync(OUT, svg); +console.log(`Wrote ${path.relative(process.cwd(), OUT)} (${(svg.length / 1024).toFixed(1)} KB, seed ${SEED})`); diff --git a/src/_includes/base.njk b/src/_includes/base.njk index eea9f847..480173cf 100644 --- a/src/_includes/base.njk +++ b/src/_includes/base.njk @@ -112,5 +112,9 @@ })(); + + {%- if motion %} + + {%- endif %} diff --git a/src/_includes/hero-scene.njk b/src/_includes/hero-scene.njk new file mode 100644 index 00000000..11ba84d8 --- /dev/null +++ b/src/_includes/hero-scene.njk @@ -0,0 +1,68 @@ + diff --git a/src/_includes/markdown.njk b/src/_includes/markdown.njk index effad0b7..1e04ee0b 100644 --- a/src/_includes/markdown.njk +++ b/src/_includes/markdown.njk @@ -8,7 +8,7 @@ layout: base.njk {% endif %} -
+

{{ title }}

{{ description }}

{%- if (headerButton) -%} @@ -21,7 +21,7 @@ layout: base.njk {{ content | safe }}
{% else %} - + {{ content | safe }} {% endif %} diff --git a/src/_includes/page.njk b/src/_includes/page.njk index 4917903c..0764e12d 100644 --- a/src/_includes/page.njk +++ b/src/_includes/page.njk @@ -2,7 +2,7 @@ layout: base.njk --- -
+

{{ title }}

{{ description }}

{%- if (headerButton) -%} @@ -10,7 +10,7 @@ layout: base.njk {%- else -%}{%- endif -%}
- + {{ content | safe }} diff --git a/src/assets/img/topo.svg b/src/assets/img/topo.svg new file mode 100644 index 00000000..922ec48a --- /dev/null +++ b/src/assets/img/topo.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/index.md b/src/index.md index eeb1112c..74d6464e 100644 --- a/src/index.md +++ b/src/index.md @@ -1,15 +1,27 @@ --- layout: base.njk +motion: true --- -
-

Adam Jolicoeur

-

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.

-

Enterprise product designer. Systems thinker. Builder.

+
+ {% include 'hero-scene.njk' %} +
+

Adam Jolicoeur

+

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.

+

Enterprise product designer. Systems thinker. Builder.

+
+ + Start the hike + + +
-

About Me

-
+

About Me

+

Lead Product designer building AI-native products, from interaction patterns to the systems that govern them.

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.

@@ -33,9 +45,9 @@ layout: base.njk
-

Featured Work

+

Featured Work

-
+
CASim dashboard screen

CASim: Internal AI Assistant

@@ -46,7 +58,7 @@ layout: base.njk View Case Study
-
+
Weekly AI-powered work summary loading screen

Weekly AI-Powered Work Summary

@@ -59,7 +71,7 @@ layout: base.njk
-
+

ArchParser

Architectural drawing analysis platform for OCR-based architectural drawing analysis management.

@@ -97,8 +109,8 @@ layout: base.njk
-

What Colleagues Say

-
+

What Colleagues Say

+

[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.

@@ -120,10 +132,10 @@ layout: base.njk
-

Open Source Contributions

+

Open Source Contributions

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.

-
+

🚀 diff --git a/src/js/motion.js b/src/js/motion.js new file mode 100644 index 00000000..ba2879ce --- /dev/null +++ b/src/js/motion.js @@ -0,0 +1,126 @@ +/* +** Page motion, opted into with `motion: true` in front matter: hero parallax +** and trail hiker (homepage scene), plus scroll reveals for marked or templated content. +** Everything here is progressive enhancement. Without JS, or with +** prefers-reduced-motion, the page renders fully static and visible. +*/ +(function () { + var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); + if (reduceMotion.matches) return; + + var root = document.documentElement; + + var hero = document.querySelector('.hero-trail'); + var scene = hero && hero.querySelector('.hero-scene'); + if (!scene) hero = null; + + if (hero) { + // Hero parallax: expose scroll distance to CSS, each layer scales it by its --depth + var ticking = false; + var update = function () { + scene.style.setProperty('--parallax', Math.min(window.scrollY, hero.offsetHeight).toFixed(1)); + ticking = false; + }; + window.addEventListener('scroll', function () { + if (!ticking) { + window.requestAnimationFrame(update); + ticking = true; + } + }, { passive: true }); + update(); + + // Pause control for the looping scenery (clouds, flag), WCAG 2.2.2 + var pause = hero.querySelector('.hero-pause'); + if (pause) { + pause.hidden = false; + pause.addEventListener('click', function () { + var paused = hero.classList.toggle('is-paused'); + pause.setAttribute('aria-pressed', String(paused)); + }); + } + + // Hiker: follows the tip of the trail as the CSS draw animation reveals it. + // Starts hidden behind the foothills; without JS it stands at the summit. + var hiker = hero.querySelector('.hero-hiker'); + var figure = hiker && hiker.querySelector('.hero-hiker-figure'); + var trail = hero.querySelector('.hero-trail-path'); + var reveal = hero.querySelector('.hero-trail-reveal'); + if (hiker && figure && trail && reveal && trail.getTotalLength) { + var length = trail.getTotalLength(); + var lastX = null; + var walking = false; + var place = function (progress) { + var pt = trail.getPointAtLength(length * progress); + // Step aside from the flagpole as the summit gets close + var x = pt.x - 10 * Math.pow(progress, 6); + hiker.setAttribute('transform', 'translate(' + x.toFixed(1) + ' ' + pt.y.toFixed(1) + ')'); + if (lastX !== null && Math.abs(x - lastX) > 0.3) { + figure.setAttribute('transform', x < lastX ? 'scale(-1.25 1.25)' : 'scale(1.25)'); + } + lastX = x; + }; + var walk = function () { + if (!walking) return; + var offset = parseFloat(window.getComputedStyle(reveal).strokeDashoffset) || 0; + place(Math.min(Math.max(1 - offset, 0), 1)); + window.requestAnimationFrame(walk); + }; + place(0); + reveal.addEventListener('animationstart', function () { + walking = true; + hiker.classList.add('is-walking'); + window.requestAnimationFrame(walk); + }); + reveal.addEventListener('animationend', function () { + walking = false; + hiker.classList.remove('is-walking'); + place(1); + figure.setAttribute('transform', 'scale(1.25)'); + }); + } + } + + // Scroll reveals: single elements and staggered groups + if (!('IntersectionObserver' in window)) return; + + var targets = Array.prototype.slice.call(document.querySelectorAll('[data-reveal]')); + var addGroup = function (group, maxIndex) { + Array.prototype.forEach.call(group.children, function (child, i) { + child.style.setProperty('--reveal-index', Math.min(i, maxIndex)); + targets.push(child); + }); + }; + document.querySelectorAll('[data-reveal-group]').forEach(function (group) { + addGroup(group, Infinity); + }); + + // Auto reveals for templated pages: card grids stagger card by card, + // other top-level sections reveal their children in a short cascade + document.querySelectorAll('[data-reveal-auto]').forEach(function (container) { + var sections = container.matches('section') ? [container] : container.querySelectorAll(':scope > section'); + Array.prototype.forEach.call(sections, function (section) { + var grids = section.querySelectorAll('.work-grid'); + if (grids.length) { + grids.forEach(function (grid) { addGroup(grid, Infinity); }); + } else { + addGroup(section, 4); + } + }); + }); + if (!targets.length) return; + + var observer = new IntersectionObserver(function (entries) { + entries.forEach(function (entry) { + if (entry.isIntersecting) { + entry.target.classList.add('is-revealed'); + observer.unobserve(entry.target); + } + }); + }, { rootMargin: '0px 0px -10% 0px', threshold: 0.1 }); + + targets.forEach(function (el) { + el.classList.add('reveal'); + observer.observe(el); + }); + root.classList.add('js-reveal'); +})(); diff --git a/src/js/topo.js b/src/js/topo.js new file mode 100644 index 00000000..bfb54882 --- /dev/null +++ b/src/js/topo.js @@ -0,0 +1,24 @@ +/* +** Topographic background drift: exposes --topo-shift so the fixed contour +** texture (see sass/_topo.scss) moves slightly with scroll progress. +** Skipped for prefers-reduced-motion; the texture then stays still. +*/ +(function () { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; + + var root = document.documentElement; + var ticking = false; + var update = function () { + var scrollable = root.scrollHeight - window.innerHeight; + var progress = scrollable > 0 ? Math.min(window.scrollY / scrollable, 1) : 0; + root.style.setProperty('--topo-shift', (progress * window.innerHeight * 0.2).toFixed(1)); + ticking = false; + }; + window.addEventListener('scroll', function () { + if (!ticking) { + window.requestAnimationFrame(update); + ticking = true; + } + }, { passive: true }); + update(); +})(); diff --git a/src/pages/about/adam.md b/src/pages/about/adam.md index 9f4d8405..00f697ae 100644 --- a/src/pages/about/adam.md +++ b/src/pages/about/adam.md @@ -7,9 +7,10 @@ description: "Product designer and product manager building AI-native tools for eleventyNavigation: key: About order: 1 +motion: true --- -
+

Hello!

Lead Product Designer and Product Manager building AI-native tools for the construction world. Previously at Red Hat and AWS. Collector of nerdy things...forever. diff --git a/src/pages/about/resume.md b/src/pages/about/resume.md index 5f4f32f1..892c0ba6 100644 --- a/src/pages/about/resume.md +++ b/src/pages/about/resume.md @@ -10,6 +10,7 @@ eleventyNavigation: key: Resume parent: About order: 2 +motion: true ---

diff --git a/src/pages/portfolio.md b/src/pages/portfolio.md index 38e636f3..7f3e0673 100644 --- a/src/pages/portfolio.md +++ b/src/pages/portfolio.md @@ -7,6 +7,7 @@ eleventyNavigation: key: Portfolio order: 1 templateEngineOverride: njk,md +motion: true ---
diff --git a/src/sass/_landing.scss b/src/sass/_landing.scss new file mode 100644 index 00000000..094eeb7f --- /dev/null +++ b/src/sass/_landing.scss @@ -0,0 +1,361 @@ +/* +** Page motion: homepage trail hero scene, page header intros, and scroll reveals +** Motion lives inside a prefers-reduced-motion: no-preference query, +** so reduced-motion users get the finished, static scene. +*/ + +/* Hero layout */ +.hero-trail { + position: relative; + max-width: none; + min-height: calc(100vh - 4rem); + min-height: calc(100svh - 4rem); + justify-content: flex-start; + padding: clamp(3rem, 10vh, 7rem) var(--space-lg, 1.5rem) 0; + overflow: hidden; + isolation: isolate; +} +.hero-content { + max-width: 900px; +} +.hero .hero-tagline { + font-style: italic; + padding: var(--space-xl, 2rem) 0; +} + +/* Scene: stacked SVG ridgelines, each layer offset by scroll * --depth */ +.hero-scene { + position: absolute; + inset: auto 0 0; + z-index: -1; + height: 55%; + pointer-events: none; + + /* Feather the bottom edge into the page gradient */ + mask-image: linear-gradient(to bottom, #000 calc(100% - 24px), transparent); +} +.hero-layer { + position: absolute; + inset: 0; + transform: translate3d(0, calc(var(--parallax, 0) * var(--depth, 0) * 1px), 0); + will-change: transform; +} +.hero-layer svg { + display: block; + width: 100%; + height: 100%; + overflow: visible; +} +.hero-sun { + fill: var(--accent-coral); + opacity: 0.85; +} +.hero-cloud path { + fill: var(--earth-cream); + stroke: var(--earth-brown); + stroke-width: 2; + stroke-linejoin: round; +} +.hero-cloud-one { + --cloud-y: 96px; + + transform: translate(1180px, var(--cloud-y)); +} +.hero-cloud-two { + --cloud-y: 176px; + + transform: translate(420px, var(--cloud-y)); +} +.hero-layer-far path { + fill: var(--earth-sand); + opacity: 0.5; +} +.hero-layer-mid path { + fill: var(--earth-sand); +} +.hero-peak { + fill: var(--earth-sage); +} +.hero-snow { + fill: var(--earth-cream); +} +.hero-trail-reveal { + fill: none; + stroke: #fff; + stroke-width: 12; + stroke-dasharray: 1; + stroke-dashoffset: 0; +} +.hero-trail-path { + fill: none; + stroke: var(--earth-cream); + stroke-width: 3; + stroke-dasharray: 8 9; + stroke-linecap: round; +} +.hero-flag line { + stroke: var(--earth-dark); + stroke-width: 3; + stroke-linecap: round; +} +.hero-flag-cloth { + fill: var(--accent-coral); +} +.hero-hiker line { + stroke: var(--earth-dark); + stroke-width: 2.2; + stroke-linecap: round; +} +.hero-hiker .hero-hiker-stick { + stroke-width: 1.2; +} +.hero-hiker-head { + fill: var(--earth-dark); +} +.hero-hiker-pack { + fill: var(--accent-coral); + stroke: var(--earth-dark); + stroke-width: 1; +} +.hero-pines { + fill: var(--earth-brown); +} +.hero-foreground { + fill: var(--earth-cream); + stroke: var(--earth-brown); + stroke-width: 4; + stroke-linejoin: round; +} + +/* Scroll cue + scenery pause control */ +.hero-cue { + position: absolute; + bottom: var(--space-lg, 1.5rem); + left: 50%; + display: inline-flex; + align-items: center; + gap: var(--space-xs, 0.5rem); + padding: var(--space-xs, 0.5rem) var(--space-md, 1rem); + color: var(--earth-dark); + font-size: var(--font-size-sm, 0.875rem); + font-weight: 600; + letter-spacing: 1px; + text-decoration: none; + text-transform: uppercase; + background: var(--earth-cream); + border: var(--border-thin, 2px) solid var(--earth-brown); + border-radius: var(--radius-pill, 50px); + box-shadow: 3px 3px 0 var(--earth-brown); + transform: translateX(-50%); + transition: + box-shadow 0.2s, + color 0.2s; +} +.hero-cue:hover, +.hero-cue:focus-visible { + color: var(--accent-coral-dark); + box-shadow: 1px 1px 0 var(--earth-brown); +} +.hero-pause { + position: absolute; + bottom: var(--space-lg, 1.5rem); + left: var(--space-lg, 1.5rem); + width: 2.75rem; + height: 2.75rem; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--earth-dark); + background: var(--earth-cream); + border: var(--border-thin, 2px) solid var(--earth-brown); + border-radius: 999px; + cursor: pointer; +} +.hero-pause:hover { + color: var(--accent-coral-dark); +} +.hero-pause[hidden], +.hero-pause[aria-pressed='true'] .hero-pause-icon, +.hero-pause[aria-pressed='false'] .hero-play-icon { + display: none; +} + +@media (prefers-reduced-motion: no-preference) { + /* Ridges rise in back-to-front, then the trail draws to the summit */ + .hero-layer svg { + animation: hero-ridge-rise 1.2s cubic-bezier(0.2, 0.7, 0.2, 1) both; + animation-delay: var(--delay, 0s); + } + .hero-sun { + animation: hero-sun-rise 2.4s cubic-bezier(0.2, 0.7, 0.2, 1) 0.2s both; + } + .hero-cloud-one { + animation: hero-cloud-drift 120s linear -70s infinite; + } + .hero-cloud-two { + animation: hero-cloud-drift 160s linear -40s infinite; + } + .hero-trail-reveal { + animation: hero-trail-draw 2.4s ease-in-out 1.3s both; + } + .hero-flag { + transform-box: fill-box; + transform-origin: 0 100%; + animation: hero-flag-plant 0.5s cubic-bezier(0.3, 1.6, 0.5, 1) 3.6s both; + } + .hero-flag-cloth { + transform-box: fill-box; + transform-origin: 0 50%; + animation: hero-flag-wave 1.6s ease-in-out 4.1s infinite alternate; + } + .hero-content > *, + .hero-intro > * { + animation: hero-text-in 0.8s cubic-bezier(0.2, 0.7, 0.2, 1) both; + } + .hero-content > :nth-child(2), + .hero-intro > :nth-child(2) { + animation-delay: 0.15s; + } + .hero-content > :nth-child(3), + .hero-intro > :nth-child(3) { + animation-delay: 0.3s; + } + .hero-cue i { + animation: hero-cue-bob 1.6s ease-in-out 4s 3; + } + .hero-hiker.is-walking .hero-hiker-leg { + transform-box: fill-box; + transform-origin: 50% 0; + animation: hero-hiker-step 0.32s ease-in-out infinite alternate; + } + .hero-hiker.is-walking .hero-hiker-leg-back { + animation-direction: alternate-reverse; + } + .hero-trail.is-paused .hero-cloud, + .hero-trail.is-paused .hero-flag-cloth { + animation-play-state: paused; + } + + /* Scroll reveals (classes added by js/motion.js) */ + .js-reveal .reveal:not(.is-revealed) { + opacity: 0; + } + .js-reveal .reveal.is-revealed { + animation: reveal-up 0.7s cubic-bezier(0.2, 0.7, 0.2, 1) both; + animation-delay: calc(var(--reveal-index, 0) * 90ms); + } + + /* Heading underline draws like a trail marker */ + .js-reveal h2.reveal::after { + transform: scaleX(0); + transition: transform 0.6s cubic-bezier(0.2, 0.7, 0.2, 1) 0.3s; + } + .js-reveal h2.reveal.is-revealed::after { + transform: scaleX(1); + } +} + +@media (width <= 768px) { + .hero-scene { + height: 50%; + } +} + +@keyframes hero-ridge-rise { + from { + opacity: 0; + transform: translateY(40px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes hero-sun-rise { + from { + opacity: 0; + transform: translateY(140px); + } + to { + opacity: 0.85; + transform: translateY(0); + } +} + +@keyframes hero-cloud-drift { + from { + transform: translate(-160px, var(--cloud-y)); + } + to { + transform: translate(1700px, var(--cloud-y)); + } +} + +@keyframes hero-trail-draw { + from { + stroke-dashoffset: 1; + } + to { + stroke-dashoffset: 0; + } +} + +@keyframes hero-flag-plant { + from { + transform: scaleY(0); + } + to { + transform: scaleY(1); + } +} + +@keyframes hero-flag-wave { + from { + transform: scaleX(1) skewY(0deg); + } + to { + transform: scaleX(0.82) skewY(-6deg); + } +} + +@keyframes hero-hiker-step { + from { + transform: rotate(-24deg); + } + to { + transform: rotate(24deg); + } +} + +@keyframes hero-text-in { + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes hero-cue-bob { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(4px); + } +} + +@keyframes reveal-up { + from { + opacity: 0; + translate: 0 24px; + } + to { + opacity: 1; + translate: 0 0; + } +} diff --git a/src/sass/_topo.scss b/src/sass/_topo.scss new file mode 100644 index 00000000..36817c2d --- /dev/null +++ b/src/sass/_topo.scss @@ -0,0 +1,24 @@ +/* +** Topographic texture: fixed behind every page, drifts with scroll progress +** via --topo-shift (set by js/topo.js). Regenerate the SVG with +** scripts/generate-topo.js. +*/ +body::before { + content: ''; + position: fixed; + top: 0; + right: 0; + left: 0; + z-index: -1; + height: 120vh; + background: url('/assets/img/topo.svg') center / cover no-repeat; + opacity: 0.08; + pointer-events: none; + transform: translate3d(0, calc(var(--topo-shift, 0) * -1px), 0); +} + +@media print { + body::before { + display: none; + } +} diff --git a/src/sass/style.scss b/src/sass/style.scss index 823f29f9..f39ad9a2 100644 --- a/src/sass/style.scss +++ b/src/sass/style.scss @@ -176,6 +176,8 @@ figcaption { @import 'buttons'; @import 'cards'; @import 'gallery'; +@import 'landing'; +@import 'topo'; // @import 'containers'; /* stylint-enable */