PageSourceSearch

https://portfolio.gateaux-09suntan.workers.dev/

html portfolio.gateaux-09suntan.workers.dev collected 2026-10-03 08:09:16 UTC 143,900 bytes, 1,573 lines download raw bytes

1<!DOCTYPE html>
2<html lang="en-GB">
3<head>
4  <meta charset="UTF-8">
5  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
6
7  <!-- Warms up the DNS/TCP/TLS handshake to the Cal.com booking embed ahead
8       of time, so that gap isn't part of the wait the first time someone
9       opens the "Book a call" popover (book-call.js additionally starts
10       loading the embed itself on hover/focus of that button, before the
11       click). app.cal.eu, not cal.eu: that's the actual origin the embed
12       script and its booking iframe load from (see book-call.js). -->
13  <link rel="preconnect" href="https://app.cal.eu">
14  <link rel="dns-prefetch" href="https://app.cal.eu">
15
16  <!-- Microsoft Clarity -->
17  
17<script type="text/javascript">
18    (function(c,l,a,r,i,t,y){
19        c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
20        t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
21        y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
22    })(window, document, "clarity", "script", "xb1o8ifxda");
23  </script>
23
24
25  <title>John Miller · Senior Product Design Portfolio</title>
26  <meta name="description" content="John is a Senior Product Designer with 6+ years experience across startups, agencies, and enterprise. Skilled in end-to-end design, from research to systems thinking, with a focus on clarity, collaboration, and shipping work that lasts.">
27
28  <!-- Open Graph -->
29  <meta property="og:type" content="website">
30  <meta property="og:title" content="John Miller · Senior Product Design Portfolio">
31  <meta property="og:description" content="John is a Senior Product Designer with 6+ years experience across startups, agencies, and enterprise.">
32  <meta property="og:image" content="https://portfolio.gateaux-09suntan.workers.dev/og-image.png">
33  <meta name="twitter:card" content="summary_large_image">
34  <meta name="twitter:title" content="John Miller · Senior Product Design Portfolio">
35  <meta name="twitter:description" content="John is a Senior Product Designer with 6+ years experience across startups, agencies, and enterprise.">
36  <meta name="twitter:image" content="https://portfolio.gateaux-09suntan.workers.dev/og-image.png">
37
38  <!-- Favicon — the href is set by the script below the theme bootstrap, so it
39       follows the RESOLVED theme (manual data-theme override > OS preference)
40       rather than prefers-color-scheme alone. -->
41  <link rel="icon" id="favicon" href="/favicon.png">
42  <link rel="apple-touch-icon" href="/apple-touch-icon.png">
43
44  <!-- Shader engine is self-hosted (shader-base.js → /vendor/paper-shaders-<ver>.min.js).
45       Preload the module AND its bundled engine so a fresh tab fetches both in
46       parallel during HTML parse — instead of discovering them late and stalling
47       the entrance animation while the engine downloads.
48       fetchpriority="low": the engine (~195KB) is only the decorative card
49       background, and the entrance animation is pure CSS (not gated on this JS),
50       so on a slow connection it must yield bandwidth to the high-priority LCP
51       image rather than competing with it. It still starts fetching during parse;
52       it just doesn't starve the image that drives LCP. -->
53  <link rel="modulepreload" href="/shader-base.js?v=1" fetchpriority="low">
54  <link rel="modulepreload" href="/vendor/paper-shaders-0.0.76.min.js" fetchpriority="low">
55
56  <!-- Fonts (self-hosted; @font-face in tokens.css) -->
57  <link rel="preload" href="/fonts/geist-latin.woff2" as="font" type="font/woff2" crossorigin>
58
59  <!-- LCP image: preload the responsive variant so it starts downloading
60       during HTML parse (before the in-body image tag is reached).
61       imagesrcset/imagesizes mirror the first card's <picture> so the preload
62       scanner picks the same variant the browser would. Static pre-generated
63       AVIF files (scripts/generate-image-variants.mjs) — not the live
64       /cdn-cgi/ transform — so the LCP image has no cold-start transform
65       latency and can't be broken by /cdn-cgi/ blockers. type="image/avif"
66       gates the preload so non-AVIF browsers skip it and fall back through
67       the <picture> (WebP → PNG) instead. -->
68  <link
69    rel="preload"
70    as="image"
71    type="image/avif"
72    href="/bella-duke-mockup-1280.avif"
73    imagesrcset="/bella-duke-mockup-768.avif 768w, /bella-duke-mockup-1280.avif 1280w, /bella-duke-mockup-2880.avif 2880w"
74    imagesizes="(min-width: 1280px) 1216px, 85vw"
75    fetchpriority="high">
76
77  <!-- Theme bootstrap — runs synchronously before stylesheets paint so the
78       saved theme choice is applied before the first frame (no flash of
79       wrong theme on reload). Kept inline + tiny on purpose: must not block
80       the critical path beyond a single attribute read/write. -->
81  
81<script>
82    (function () {
83      try {
84        var saved = localStorage.getItem('theme');
85        var theme = (saved === 'light' || saved === 'dark') ? saved : 'auto';
86        document.documentElement.setAttribute('data-theme', theme);
87      } catch (e) {
88        document.documentElement.setAttribute('data-theme', 'auto');
89      }
90    })();
91  </script>
91
92
93  <!-- Favicon swap. MUST stay below the theme bootstrap above: it reads the
94       resolved data-theme, so running it earlier (when the attribute is still
95       unset) silently falls back to prefers-color-scheme — the dark favicon on
96       a dark OS even when the user has manually switched the page to light.
97       Re-runs on OS-scheme change and the theme toggle (themechange). -->
98  
98<script>
99    (function () {
100      var fav = document.getElementById('favicon');
101      function set() {
102        var t = document.documentElement.getAttribute('data-theme');
103        var dark = t === 'dark' || (t !== 'light' && matchMedia('(prefers-color-scheme: dark)').matches);
104        fav.href = dark ? '/favicon-dark.png' : '/favicon.png';
105      }
106      set();
107      matchMedia('(prefers-color-scheme: dark)').addEventListener('change', set);
108      window.addEventListener('themechange', set);
109    })();
110  </script>
110
111
112  <!-- Mobile status-bar tint (the strip behind the clock/battery, and on some
113       mobile browsers the address bar itself). Sets <meta name="theme-color">
114       so the OS chrome always matches whatever surface is actually behind it
115       — the page background normally, but the About card / photo lightbox /
116       Book-a-call panel's OWN surface while one of those is open fullscreen
117       (see the body-class MutationObserver below). Without this the browser
118       bar stayed painted with the page's --color-bg while a popover with a
119       different surface shade (most visible in dark mode: true-black page vs.
120       the card's off-black) filled the rest of the screen — a visible seam
121       right where the bar meets the popover. Sourced live from tokens (single
122       source of truth, OKLCH — no duplicated colour to keep in sync);
123       re-synced on OS-scheme change, the theme toggle, and popover open/close. -->
124  
124<script>
125    (function () {
126      var meta = document.createElement('meta');
127      meta.name = 'theme-color';
128      document.head.appendChild(meta);
129      function resolveColor() {
130        var body = document.body;
131        if (body && body.classList.contains('book-call-open')) {
132          var panel = document.getElementById('book-call-panel');
133          var calBg = panel && getComputedStyle(panel).getPropertyValue('--book-call-cal-bg').trim();
134          if (calBg) return calBg;
135        }
136        if (body && (body.classList.contains('about-open') || body.classList.contains('about-lightbox-open'))) {
137          var cardBg = getComputedStyle(document.documentElement).getPropertyValue('--about-card-surface').trim();
138          if (cardBg) return cardBg;
139        }
140        return getComputedStyle(document.documentElement).getPropertyValue('--color-bg').trim();
141      }
142      function paint() {
143        var bg = resolveColor();
144        if (bg) meta.content = bg;
145      }
146      // tokens.css is render-blocking, so the tokens resolve by the first frame.
147      requestAnimationFrame(paint);
148      matchMedia('(prefers-color-scheme: dark)').addEventListener('change', paint);
149      window.addEventListener('themechange', paint);
150      // The About card, photo lightbox, and Book-a-call popover each toggle a
151      // class on <body> when they open/close (about-open, about-lightbox-open,
152      // book-call-open) — watching that is the one hook that covers all three
153      // without editing their own JS files.
154      function watchBody() {
155        new MutationObserver(paint).observe(document.body, { attributes: true, attributeFilter: ['class'] });
156      }
157      if (document.body) watchBody();
158      else document.addEventListener('DOMContentLoaded', watchBody);
159    })();
160  </script>
160
161
162  <!-- Tokens -->
163  <style>:root{color-scheme:light dark;--color-bg: oklch(.97 .001 106.424);--color-bg-secondary: oklch(.985 .001 106.423);--color-bg-secondary-hover: oklch(1 0 0);--color-bg-secondary-pressed:oklch(.97 .001 106.424);--about-card-surface: var(--color-bg-secondary);--color-bg-inverted: oklch(0 0 0);--color-bg-primary: rgb(12, 10, 9);--color-bg-alpha: oklch(.147 .004 49.25 / .031);--color-bg-behind: oklch(.985 .001 106.423);--color-bg-browser: oklch(.985 .001 106.423 / .502);--color-text-primary: oklch(.147 .004 49.25);--color-text-secondary: oklch(.444 .01 73.639);--color-text-muted: oklch(.716 .009 56.259);--color-text-primary-inverted: oklch(.985 .001 106.423);--color-text-title: oklch(.147 .004 49.25);--color-text-disabled: oklch(.444 .01 73.639);--color-text-secondary-alpha: oklch(.444 .01 73.639);--color-text-secondary-inverted: oklch(.716 .009 56.259);--color-icon-primary: var(--color-text-secondary);--color-icon-secondary: var(--color-text-muted);--color-icon-strong: var(--color-text-primary);--color-icon-hover: var(--color-icon-strong);--color-fg-primary: oklch(.444 .01 73.639);--color-fg-secondary: oklch(.716 .009 56.259);--color-fg-primary-alpha: oklch(.147 .004 49.25 / .7);--color-fg-secondary-alpha: oklch(.147 .004 49.25 / .4);--color-fg-secondary-inverted: oklch(.444 .01 73.639);--color-fg-tertiary-alpha: oklch(.147 .004 49.25 / .102);--color-fg-muted-alpha: oklch(.147 .004 49.25 / .6);--color-border: oklch(.923 .003 48.717);--color-border-hover: oklch(.869 .004 56.366);--color-border-bg: oklch(.923 .003 48.717);--color-border-bg-hover: oklch(.923 .003 48.717);--color-border-inverted: oklch(.216 .006 56.043);--color-border-secondary-alpha: oklch(.147 .004 49.25 / .078);--color-focus-ring: #0C0A09;--color-selection-bg: var(--color-border-hover);--color-selection-text: var(--color-text-primary);--color-bd: oklch(.52 .095 194.8);--color-bd-bg: oklch(.52 .095 194.8 / .161);--color-bd-border: oklch(.52 .095 194.8 / .502);--color-hm: oklch(.665 .223 21.421);--color-hm-bg: oklch(.665 .223 21.421 / .161);--color-hm-border: oklch(.665 .223 21.421 / .502);--color-pikl: oklch(.58 .213 2.613);--color-pikl-bg: oklch(.58 .213 2.613 / .161);--color-pikl-border: oklch(.58 .213 2.613 / .502);--color-wren: oklch(.654 .176 151.021);--color-wren-bg: oklch(.654 .176 151.021 / .161);--color-wren-border: oklch(.654 .176 151.021 / .502);--color-wren-text: oklch(.513 .121 152.545);--color-pikl-ds: oklch(.62 .19 251.1);--color-pikl-ds-bg: oklch(.62 .19 251.1 / .161);--color-pikl-ds-border: oklch(.62 .19 251.1 / .502);--color-white-alpha-5: oklch(1 0 0 / .05);--color-white-alpha-10: oklch(1 0 0 / .1);--color-white-alpha-20: oklch(1 0 0 / .2);--color-white-alpha-30: oklch(1 0 0 / .3);--color-shadow: oklch(.147 .004 49.25 / .02);--color-highlight: oklch(1 0 0);--color-success-fg: oklch(.741 .222 144.737);--color-failure-fg: oklch(.655 .232 28.997);--color-warning-fg: oklch(.765 .175 62.574);--color-informational-fg: oklch(.603 .218 257.424);--color-special-fg: oklch(.603 .195 309.108);--color-success-bg-alpha: oklch(.744 .224 144.612 / .122);--color-failure-bg-alpha: oklch(.655 .232 29.178 / .122);--color-warning-bg-alpha: oklch(.765 .175 62.574 / .122);--color-informational-bg-alpha: oklch(.605 .217 257.214 / .122);--color-special-bg-alpha: oklch(.601 .196 309.226 / .122);--color-success-text: oklch(.512 .163 144.52);--color-failure-text: oklch(.553 .225 27.269);--color-warning-text: oklch(.552 .192 35.326);--color-informational-text: oklch(.463 .241 263.288);--color-special-text: oklch(.553 .161 324.469);--color-success-border: oklch(.741 .222 144.737);--color-failure-border: oklch(.655 .232 28.997);--color-warning-border: oklch(.87 .11 74.759);--color-informational-border: oklch(.783 .114 251.286);--color-special-border: oklch(.752 .121 310.907)}@font-face{font-family:Geist;font-style:normal;font-weight:400 600;font-display:fallback;src:url(/fonts/geist-latin.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}:root{--font-sans: "Geist", sans-serif;--font-weight-regular: 400;--font-weight-medium: 500;--font-weight-semibold: 600;--font-smoothing-webkit: antialiased;--font-smoothing-moz: grayscale;--type-h1: 40px;--type-h2: 24px;--type-h2nc: 18px;--type-h3: 18px;--type-body: 18px}@media(min-width:810px)and (max-width:1199px){:root{--type-h1: 28px;--type-h2: 18px;--type-h2nc: 18px;--type-h3: 16px;--type-body: 16px}}@media(max-width:809px){:root{--type-h1: 28px;--type-h2: 18px;--type-h2nc: 18px;--type-h3: 16px;--type-body: 16px}}:root{--space-1: 4px;--space-1-5: 6px;--space-2: 8px;--space-3: 12px;--space-4: 16px;--space-5: 20px;--space-6: 24px;--space-8: 32px;--space-10: 40px;--space-12: 48px;--space-16: 64px;--space-20: 80px;--space-24: 96px;
163--space-32: 128px;--radius-btn: 20px;--card-padding: 4px;--radius: 40px;--radius-pill: 99px;--button-height: 48px;--button-padding-inline: 16px;--button-gap: var(--space-2);--shadow-ring: oklch(.147 .004 49.25 / .08);--shadow-ring-hover: oklch(.147 .004 49.25 / .18);--shadow-ring-pressed: var(--shadow-ring);--shadow-layer: oklch(.147 .004 49.25 / .06);--shadow-ring-inverted: rgba(250, 250, 249, .16);--shadow-ring-inverted-hover: rgba(250, 250, 249, .28);--shadow-with-ring-inverted: var(--shadow-ring-inverted) 0px 0px 0px 1px, var(--shadow-layer) 0px 1px 1px -.5px, var(--shadow-layer) 0px 3px 3px -1.5px, var(--shadow-layer) 0px 6px 6px -3px;--shadow-btn-hover-inverted: var(--shadow-ring-inverted-hover) 0px 0px 0px 1px, var(--shadow-layer) 0px 2px 2px -1px, var(--shadow-layer) 0px 6px 6px -3px, var(--shadow-layer) 0px 12px 12px -6px;--shadow-btn-pressed-inverted: var(--shadow-ring-inverted) 0px 0px 0px 1px, var(--shadow-layer) 0px 1px 1px -.5px;--shadow-card-outline: oklch(.147 .004 49.25 / .08);--shadow-card-outline-hover: oklch(.147 .004 49.25 / .18);--inner-frame-border: var(--shadow-ring);--inner-frame-light: var(--shadow-ring);--avatar-border: var(--shadow-ring);--shadow-with-ring: var(--shadow-ring) 0px 0px 0px 1px, var(--shadow-layer) 0px 1px 1px -.5px, var(--shadow-layer) 0px 3px 3px -1.5px, var(--shadow-layer) 0px 6px 6px -3px;--shadow-card: var(--shadow-ring) 0px 1px 0px 0px, var(--shadow-layer) 0px 1px 1px -.5px, var(--shadow-layer) 0px 3px 3px -1.5px, var(--shadow-layer) 0px 6px 6px -3px, var(--shadow-layer) 0px 12px 12px -6px;--shadow-mockup: var(--shadow-layer) 0px 1px 1px -.5px, var(--shadow-layer) 0px 3px 3px -1.5px, var(--shadow-layer) 0px 6px 6px -3px, var(--shadow-layer) 0px 12px 12px -6px;--shadow-inset-highlight: inset 0px 1px 2px 0px oklch(1 0 0);--shadow-btn-hover: var(--shadow-ring-hover) 0px 0px 0px 1px, var(--shadow-layer) 0px 2px 2px -1px, var(--shadow-layer) 0px 6px 6px -3px, var(--shadow-layer) 0px 12px 12px -6px;--shadow-btn-pressed: var(--shadow-ring-pressed) 0px 0px 0px 1px, var(--shadow-layer) 0px 1px 1px -.5px;--shadow-mockup-hover: var(--shadow-layer) 0px 2px 2px -1px, var(--shadow-layer) 0px 6px 6px -3px, var(--shadow-layer) 0px 12px 12px -6px, var(--shadow-layer) 0px 20px 20px -10px;--shadow-card-hover: var(--shadow-ring-hover) 0px 1px 0px 0px, var(--shadow-layer) 0px 2px 2px -1px, var(--shadow-layer) 0px 6px 6px -3px, var(--shadow-layer) 0px 12px 12px -6px, var(--shadow-layer) 0px 20px 20px -10px;--shadow-card-pressed: var(--shadow-ring-pressed) 0px 1px 0px 0px, var(--shadow-layer) 0px 1px 1px -.5px, var(--shadow-layer) 0px 2px 2px -1px;--shadow-card-opacity: 1}@media(max-width:809px){:root{--radius: 32px}}@supports not (corner-shape: superellipse(1.4)){:root{--radius-btn: 16px;--radius: 32px}@media(max-width:809px){:root{--radius: 24px}}}:root{--page-outer-padding: var(--space-8);--content-indent: var(--page-outer-padding);--card-header-padding: var(--space-8)}@media(min-width:810px)and (max-width:1199px){:root{--page-outer-padding: var(--space-6);--card-header-padding: var(--space-6)}}@media(max-width:809px){:root{--page-outer-padding: var(--space-5);--card-header-padding: var(--space-5)}}:root{--anim-enter-duration: .8s;--anim-enter-easing: cubic-bezier(.23, 1, .32, 1);--anim-enter-rest-opacity: .001;--anim-interactive-duration: .15s;--anim-interactive-easing: cubic-bezier(.2, 0, 0, 1)}@media(prefers-color-scheme:dark){:root:not([data-theme=light]){--color-bg: rgb(0, 0, 0);--color-bg-secondary: rgb(12, 10, 9);--color-bg-secondary-hover: rgb(41, 37, 36);--color-bg-secondary-pressed:rgb(12, 10, 9);--about-card-surface: var(--color-bg-secondary);--color-bg-inverted: rgb(245, 245, 244);--color-bg-primary: oklch(.985 .001 106.423);--color-bg-alpha: rgba(255, 255, 255, .1);--color-bg-behind: rgb(0, 0, 0);--color-bg-browser: rgba(28, 25, 23, .5);--color-text-primary: rgb(250, 250, 249);--color-text-secondary: rgb(168, 162, 158);--color-text-muted: rgb(87, 83, 78);--color-text-primary-inverted: rgb(12, 10, 9);--color-text-title: rgb(0, 0, 0);--color-text-disabled: rgb(87, 83, 78);--color-text-secondary-alpha: rgba(250, 250, 249, .6);--color-text-secondary-inverted: rgb(87, 83, 78);--color-fg-primary: rgb(168, 162, 158);--color-fg-secondary: rgb(87, 83, 78);--color-fg-primary-alpha: rgba(250, 250, 249, .9);--color-fg-secondary-alpha: rgba(250, 250, 249, .4);--color-fg-secondary-inverted: rgb(168, 162, 158);--color-fg-tertiary-alpha: rgba(250, 250, 249, .2);--color-fg-muted-alpha: rgba(250, 250, 249, .6);--color-border: rgb(28, 25, 23);--color-border-hover: rgb(68, 64, 60);--color-border-bg: rgb(41, 37, 36);--color-border-bg-hover: rgb(68, 64, 60);--color-border-inverted: rgb(231, 229, 228);--color-border-secondary-alpha: rgba(250, 250, 249, .08);--color-focus-ring: rgb(250, 250, 249);--color-bd: rgb(22, 188, 187);--color-bd-bg: rgba(22, 188, 187, .2);--color-bd-border: rgba(22, 188, 187, .75);--color-hm: rgb(255, 67, 83);--color-hm-bg: rgba(255, 67, 83, .2);--color-hm-border: rgba(255, 67, 83, .75);--color-pikl: rgb(214, 38, 111);--color-pikl-bg: rgba(214, 38, 111, .2);--color-pikl-border: rgba(212, 37, 110, .75);--color-wren: rgb(0, 173, 84);--color-wren-bg: rgba(37, 177, 98, .2);--color-wren-border: rgba(37, 177, 98, .75);--color-wren-text: rgb(28, 122, 67);--color-pikl-ds: rgb(10, 132, 255);--color-pikl-ds-bg: rgba(10, 132, 255, .2);--color-pikl-ds-border: rgba(10, 132, 255, .75);--color-shadow: rgba(12, 10, 9, .1);--color-highlight: rgba(255, 255, 255, .12);--shadow-ring: rgba(250, 250, 249, .16);--shadow-ring-hover: rgba(250, 250, 249, .28);--shadow-ring-inverted: oklch(.147 .004 49.25 / .08);--shadow-ring-inverted-hover: oklch(.147 .004 49.25 / .18);--shadow-card-outline: oklch(1 0 0 / .16);--shadow-card-outline-hover: oklch(1 0 0 / .32);--shadow-inset-highlight: inset 0px 1px 2px 0px rgba(255, 255, 255, .12);--color-success-fg: rgb(50, 215, 75);--color-failure-fg: rgb(255, 69, 58);
163--color-warning-fg: rgb(255, 159, 11);--color-informational-fg: rgb(11, 132, 255);--color-special-fg: rgb(178, 96, 234);--color-success-bg-alpha: rgba(51, 215, 75, .16);--color-failure-bg-alpha: rgba(255, 66, 56, .2);--color-warning-bg-alpha: rgba(255, 157, 10, .2);--color-informational-bg-alpha: rgba(10, 133, 255, .2);--color-special-bg-alpha: rgba(179, 97, 234, .2);--color-success-text: rgb(50, 222, 75);--color-failure-text: rgb(255, 104, 97);--color-warning-text: rgb(255, 179, 63);--color-informational-text: rgb(65, 156, 255);--color-special-text: rgb(207, 146, 248);--color-success-border: rgb(29, 175, 51);--color-failure-border: rgb(204, 10, 0);--color-warning-border: rgb(255, 159, 11);--color-informational-border: rgb(11, 132, 255);--color-special-border: rgb(178, 96, 234)}}:root[data-theme=dark]{color-scheme:dark;--color-bg: rgb(0, 0, 0);--color-bg-secondary: rgb(12, 10, 9);--color-bg-secondary-hover: rgb(41, 37, 36);--color-bg-secondary-pressed:rgb(12, 10, 9);--about-card-surface: var(--color-bg-secondary);--color-bg-inverted: rgb(245, 245, 244);--color-bg-primary: oklch(.985 .001 106.423);--color-bg-alpha: rgba(255, 255, 255, .1);--color-bg-behind: rgb(0, 0, 0);--color-bg-browser: rgba(28, 25, 23, .5);--color-text-primary: rgb(250, 250, 249);--color-text-secondary: rgb(168, 162, 158);--color-text-muted: rgb(87, 83, 78);--color-text-primary-inverted: rgb(12, 10, 9);--color-text-title: rgb(0, 0, 0);--color-text-disabled: rgb(87, 83, 78);--color-text-secondary-alpha: rgba(250, 250, 249, .6);--color-text-secondary-inverted: rgb(87, 83, 78);--color-fg-primary: rgb(168, 162, 158);--color-fg-secondary: rgb(87, 83, 78);--color-fg-primary-alpha: rgba(250, 250, 249, .9);--color-fg-secondary-alpha: rgba(250, 250, 249, .4);--color-fg-secondary-inverted: rgb(168, 162, 158);--color-fg-tertiary-alpha: rgba(250, 250, 249, .2);--color-fg-muted-alpha: rgba(250, 250, 249, .6);--color-border: rgb(28, 25, 23);--color-border-hover: rgb(68, 64, 60);--color-border-alpha: rgba(250, 250, 249, .12);--color-border-bg: rgb(41, 37, 36);--color-border-bg-hover: rgb(68, 64, 60);--color-border-alpha-hover: rgba(250, 250, 249, .24);--color-border-inverted: rgb(231, 229, 228);--color-border-secondary-alpha: rgba(250, 250, 249, .08);--color-focus-ring: rgb(250, 250, 249);--color-bd: rgb(22, 188, 187);--color-bd-bg: rgba(22, 188, 187, .2);--color-bd-border: rgba(22, 188, 187, .75);--color-hm: rgb(255, 67, 83);--color-hm-bg: rgba(255, 67, 83, .2);--color-hm-border: rgba(255, 67, 83, .75);--color-pikl: rgb(214, 38, 111);--color-pikl-bg: rgba(214, 38, 111, .2);--color-pikl-border: rgba(212, 37, 110, .75);--color-wren: rgb(0, 173, 84);--color-wren-bg: rgba(37, 177, 98, .2);--color-wren-border: rgba(37, 177, 98, .75);--color-wren-text: rgb(28, 122, 67);--color-pikl-ds: rgb(10, 132, 255);--color-pikl-ds-bg: rgba(10, 132, 255, .2);--color-pikl-ds-border: rgba(10, 132, 255, .75);--color-shadow: rgba(12, 10, 9, .1);--color-highlight: rgba(255, 255, 255, .12);--shadow-ring: rgba(250, 250, 249, .16);--shadow-ring-hover: rgba(250, 250, 249, .28);--shadow-ring-inverted: oklch(.147 .004 49.25 / .08);--shadow-ring-inverted-hover: oklch(.147 .004 49.25 / .18);--shadow-card-outline: oklch(1 0 0 / .16);--shadow-card-outline-hover: oklch(1 0 0 / .32);--shadow-inset-highlight: inset 0px 1px 2px 0px rgba(255, 255, 255, .12);--color-success-fg: rgb(50, 215, 75);--color-failure-fg: rgb(255, 69, 58);--color-warning-fg: rgb(255, 159, 11);--color-informational-fg: rgb(11, 132, 255);--color-special-fg: rgb(178, 96, 234);--color-success-bg-alpha: rgba(51, 215, 75, .16);--color-failure-bg-alpha: rgba(255, 66, 56, .2);--color-warning-bg-alpha: rgba(255, 157, 10, .2);--color-informational-bg-alpha: rgba(10, 133, 255, .2);--color-special-bg-alpha: rgba(179, 97, 234, .2);--color-success-text: rgb(50, 222, 75);--color-failure-text: rgb(255, 104, 97);--color-warning-text: rgb(255, 179, 63);--color-informational-text: rgb(65, 156, 255);--color-special-text: rgb(207, 146, 248);--color-success-border: rgb(29, 175, 51);--color-failure-border: rgb(204, 10, 0);
163--color-warning-border: rgb(255, 159, 11);--color-informational-border: rgb(11, 132, 255);--color-special-border: rgb(178, 96, 234)}:root[data-theme=light]{color-scheme:light}[data-project=bella-duke]{--color-selection-bg: var(--color-bd-bg)}[data-project=homemove]{--color-selection-bg: var(--color-hm-bg)}[data-project=pikl]{--color-selection-bg: var(--color-pikl-bg)}[data-project=wren]{--color-selection-bg: var(--color-wren-bg)}[data-project=pikl-ds]{--color-selection-bg: var(--color-pikl-ds-bg)}</style>
164  <link rel="stylesheet" href="components.css?v=284">
165
166  <!-- Styles -->
167  <link rel="stylesheet" href="styles.css?v=3">
168
169  <!-- Motion (vendored UMD bundle exposing globalThis.Motion). Used by the
170       contact button morphs and the About / Book-a-call popovers — nothing on
171       the critical path (animations.js is native CSS / Web Animations), so it
172       is injected AFTER first paint and its ~132 KB parse/compile doesn't
173       compete with the LCP image on slower mobile CPUs. -->
174  
174<script src="animations.js?v=skel4" defer></script>
174
175  
175<script>
176    (function () {
177      function loadMotion() {
178        var s = document.createElement('script');
179        s.src = 'vendor/motion.min.js?v=1';
180        s.onload = function () { window.dispatchEvent(new Event('motion:ready')); };
181        document.head.appendChild(s);
182      }
183      if ('requestIdleCallback' in window) {
184        requestIdleCallback(loadMotion, { timeout: 2000 });
185      } else {
186        window.addEventListener('load', loadMotion);
187      }
188    })();
189  </script>
189
190
191  <!-- Components -->
192  
192<script src="contact-button-group.js?v=1" defer></script>
192
193  
193<script src="theme-switcher.js?v=1" defer></script>
193
194  
194<script src="card-blur.js?v=22" defer></script>
194
195
196  <!-- iOS-style 60% corner smoothing (squircles) on safe rounded elements -->
197
198  <!-- ThumbHash placeholders -->
199  
199<script type="module" src="thumbhash-loader.js?v=skel10"></script>
199
200
201  <!-- Local dev only: rewrite Cloudflare image URLs to originals (no-op in prod).
202       Synchronous (no defer) so the MutationObserver is live before images load. -->
203
204  <!-- Shaders -->
205  
205<script type="module">
206    import {
207      ShaderMount,
208      brandColor,
209      cardBg,
210      buildShaderColors,
211      siteFragmentShader,
212      createShaderUniforms,
213      SHADER_MAX_PIXELS,
214      getCardScale,
215      getHeroSpeed,
216      onBreakpointChange,
217      createSpeedRamp,
218      shaderFrameOffset,
219    } from './shader-base.js?v=1';
220
221    // Brand stops use `brandColor()` so light-mode pastels are auto-darkened
222    // in dark mode (see shader-base.js). `cardBg()` is white in light, near-
223    // black in dark — so the gradient melts into the card surface in both.
224    // Wrapped in a builder function so the matchMedia change listener below
225    // can re-evaluate brandColor/cardBg with the fresh isDark() result when
226    // the user toggles OS theme mid-session.
227    const buildShaderConfigs = () => [
228      { id: 'gradient-bella-duke',     colors: buildShaderColors({ brand: [brandColor(0.9, 0.045, 194.8), brandColor(0.9, 0.045, 158.1)], bg: brandColor(0.959, 0.009, 84.6) }) },
229      { id: 'gradient-homemove',       colors: buildShaderColors({ brand: [brandColor(0.899, 0.050, 279.1), brandColor(0.915, 0.044, 21.5)], bg: cardBg() }) },
230      { id: 'gradient-pikl-dashboard', colors: buildShaderColors({ brand: [brandColor(0.896, 0.064, 350.7)], bg: cardBg() }) },
231      { id: 'gradient-wren',           colors: buildShaderColors({ brand: [brandColor(0.891, 0.051, 162.3)], transition: [brandColor(0.966, 0.007, 160.1)], bg: cardBg() }) },
232      { id: 'gradient-pikl-ds',        colors: buildShaderColors({ brand: [brandColor(0.881, 0.032, 251.1)], bg: cardBg() }) },
233    ];
234    let shaderConfigs = buildShaderConfigs();
235
236    // Per-card fade tint. The `.card-fade` gradient fades the mockup's bottom
237    // into this colour; we derive it statically from the average of the card's
238    // own shader colour stops (close to the colour the shader sits at down the
239    // card's lower half) — no canvas readback, no preserveDrawingBuffer. Set on
240    // load and re-set on theme change.
241    const averageColor = (colors) => {
242      let r = 0, g = 0, b = 0;
243      for (const c of colors) { r += c[0]; g += c[1]; b += c[2]; }
244      const n = colors.length || 1;
245      return `rgb(${Math.round(r / n * 255)} ${Math.round(g / n * 255)} ${Math.round(b / n * 255)})`;
246    };
247    const applyCardFadeColors = () => {
248      for (const cfg of shaderConfigs) {
249        const card = document.getElementById(cfg.id)?.closest('.card');
250        if (card) card.style.setProperty('--card-fade-color', averageColor(cfg.colors));
251      }
252    };
253
254    const mounts = new Map();       // el → ShaderMount
255    const ramps = new Map();        // el → SpeedRamp controller (smooth setSpeed)
256    const baseColors = new Map();   // el → [[r,g,b,a], …] parsed base colours
257    const shadowEls = new Map();    // el → sibling .card-shadow
258    // el → that shadow's .card-shadow__layer, which carries the intensity pulse
259    // (components.css). The off-screen pause below is written onto it.
260    const shadowLayerEls = new Map();
261    // el → the leaf layers the SCRUBBED scroll fade writes onto (.card-blur /
262    // .card-fade). Cached once so the per-frame scroll handler sets the
263    // non-inheriting --card-scroll-fade straight on these, never on the parent
264    // .card (which would restyle the whole cloned-mockup subtree every frame).
265    const fadeEls = new Map();
266    // el → the header leaf elements (.card-title / .card-arrow / .card-tag). These
267    // get the TRIGGERED exit instead: --card-head-fade flips 1↔0 once when the
268    // card crosses the hold-zone edge (CSS then plays the reverse-entrance
269    // through), rather than being scrubbed every frame. headHeld tracks the last
270    // written boolean so we only write on a crossing (no transition restarts).
271    const headEls = new Map();
272    const headHeld = new Map();
273    // The shader's own colour alpha now shares the header's TRIGGERED hysteresis
274    // too, but WebGL uniforms aren't a CSS property, so there's no transition to
275    // let the browser animate — JS has to tween it manually. scrollAlpha is each
276    // card's current 0..1 position in that tween (read by writeShaderColours);
277    // scrollTweenGen is a per-card generation counter so a crossing that fires
278    // mid-tween (fast back-and-forth scroll) can supersede the in-flight rAF
279    // loop instead of fighting it.
280    const scrollAlpha = new Map();
281    const scrollTweenGen = new Map();
282
283    // Skeleton-first: hold each card's shader gradient at 0 alpha until its
284    // entrance finishes, then fade it in. The bottom glow is NOT gated — it
285    // runs during the skeleton as the loading indicator. `revealAlpha` is the
286    // per-card 0→1 fade multiplier applied to the shader colours.
287    const entranceRevealed = new WeakSet(); // .card-wrap whose entrance finished
288    const revealAlpha = new Map();          // .card-gradient el → 0..1
289    // el → signature of the last u_colors pushed, so a frame whose colours are
290    // identical to the previous one skips the redundant setUniforms()/GPU redraw
291    // (e.g. a card parked at the centre plateau, or a pre-warmed off-screen card
292    // sitting at 0 alpha while its neighbours scroll). See the skip in
293    // updateShaderOpacity.
294    const lastColorSig = new Map();
295
296    // --- Tunable constants ---
297    const HOLD_ZONE = 0.10;
298    // Header exit trigger (hysteresis). The header plays its one-shot IN once the
299    // card climbs to HEAD_IN and its one-shot OUT once it drops to HEAD_OUT; the
300    // gap between them holds the current state so a card settling near the edge
301    // can't flicker.
302    //
303    // The band is deliberately WIDE and biased toward "present": the header lives
304    // at the card's BOTTOM edge, so it's the first thing on screen as the card
305    // rises into view and the last thing to leave as it climbs past the top. A
306    // narrow, centred band (the old 0.92/0.80) made the title pop in only when the
307    // card was almost dead-centre and dissolve while it was still firmly in view —
308    // reading as "enters too late, exits too early". Triggering IN well before
309    // centre and holding OUT until the card has clearly started leaving keeps the
310    // header present across the whole stretch where its region is comfortably
311    // readable. (Measured against the live scroll curve.) Tunable to taste.
312    //
313    // HEAD_IN is set LOW on purpose: because the header sits at the card's bottom,
314    // on shorter viewports it only scrolls fully into view near dead-centre, so the
315    // entrance has to fire well before then for the ~800ms fade+rise+un-blur to
316    // have settled by the time the title is prominently visible. A higher trigger
317    // left it still visibly assembling as it arrived ("enters too late"); 0.50 gives
318    // it the lead time to read as already-there. HEAD_OUT now matches HEAD_IN
319    // (0.50) so the header exits as soon as the card crosses back below the same
320    // centre-distance point it entered at — a symmetric in/out at one threshold.
321    // (No hysteresis gap at this setting: a card parked right at 0.50 could toggle
322    // on scroll jitter, but normal scrolling crosses it cleanly in one direction.)
323    const HEAD_IN = 0.50;
324    const HEAD_OUT = 0.50;
325    const prefersReducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
326
327    // Hold each card's interior (title/tags/icons) for the fill: the empty frame
328    // shows on the cascade as the skeleton, then the interior reveals together
329    // with the shader + picture. Added now (deferred module runs before DCL, so
330    // before animations.js flips `.is-visible`) to avoid any flash of the text.
331    if (!prefersReducedMotion) {
332      document.querySelectorAll('.card-wrap').forEach((cw) => {
333        cw.classList.add('is-skeleton');
334        // Header + blur + gradient additionally wait for the picture to decode
335        // (see whenCardImageLoaded); dropped a step after `.is-skeleton`.
336        cw.classList.add('is-img-pending');
337        // Randomise each loading cue's starting phase so the top highlight begins
338        // at a different point along its drift on every refresh (and each card
339        // differs from its neighbours). A negative animation-delay offsets into the
340        // paused timeline; it resumes from there once `.is-skeleton` runs it. The
341        // duration is read from CSS so the keyframe stays the single source of truth.
342        const cue = cw.querySelector('.card-loader');
343        if (cue) {
344          const dur = parseFloat(getComputedStyle(cue).animationDuration) || 0;
345          cue.style.animationDelay = `-${(Math.random() * dur).toFixed(3)}s`;
346        }
347      });
348    }
349
350    // Matches --anim-enter-easing (cubic-bezier(0.23, 1, 0.32, 1), ease-
350out-quint)
351    // closely enough for the JS-driven shader colour tween below — snappy in,
352    // soft settle, same shape as the CSS curve the header/blur transitions use.
353    function easeOutQuint(t) {
354      return 1 - Math.pow(1 - t, 5);
355    }
356
357    function clamp(v, min, max) {
358      return v < min ? min : v > max ? max : v;
359    }
360
361    function initShader(el) {
362      if (mounts.has(el)) return;
363      const cfg = shaderConfigs.find(c => c.id === el.id);
364      if (!cfg) return;
365
366      const parsed = cfg.colors;
367      baseColors.set(el, parsed);
368
369      const shadow = el.closest('.card-wrap')?.querySelector('.card-shadow');
370      if (shadow) {
371        shadowEls.set(el, shadow);
372        const layer = shadow.querySelector('.card-shadow__layer');
373        if (layer) shadowLayerEls.set(el, layer);
374        if (!prefersReducedMotion) shadow.style.setProperty('--shadow-progress', '0');
375      }
376
377      const card = el.closest('.card');
378      if (card) {
379        // Scrubbed layers: blur + colour fade follow scroll continuously.
380        fadeEls.set(el, [
381          card.querySelector('.card-blur'),
382          card.querySelector('.card-fade'),
383        ].filter(Boolean));
384        // Triggered layers: the header's reverse-entrance lives on the
385        // title/arrow/tags themselves (transform/filter on the blend-mode element
386        // doesn't isolate its own blend; on the .card-header parent it would).
387        // --card-head-fade is inherits:false, so write it onto each leaf.
388        headEls.set(el, [...card.querySelectorAll('.card-title, .card-arrow, .card-tag')]);
389      }
390
391      const initialColors = prefersReducedMotion
392        ? parsed
393        : parsed.map(c => [c[0], c[1], c[2], 0]);
394
395      try {
396        const mount = new ShaderMount(el, siteFragmentShader, {
397          ...createShaderUniforms(),
398          u_colors: initialColors,
399          u_colorsCount: cfg.colors.length,
400        }, undefined, 0, shaderFrameOffset(cfg.id));
401        mount.setMaxPixelCount(SHADER_MAX_PIXELS);
402        mounts.set(el, mount);
403        ramps.set(el, createSpeedRamp(mount, 0));
404
405        if (!prefersReducedMotion) {
406          updateShaderOpacity(el, mount);
407        }
408      } catch (e) { return; }
409    }
410
411    // --- Scroll-driven colour opacity stagger ---
412    // Firefox gate, mirroring the `@supports (-moz-appearance: none)` blocks in
413    // components.css. Drives the .is-scrolling class in onScroll below.
414    const isFirefox = CSS.supports('-moz-appearance', 'none');
415    let ticking = false;
416    let isScrolling = false;
417    let scrollIdleTimer = null;
418    const SCROLL_IDLE_MS = 150;
419
420    // READ phase — the ONLY layout query in the per-frame loop. Kept apart from
421    // every DOM/shader write below so updateShaderOpacities() can measure all
422    // cards first and mutate them second: one layout reflow per frame instead of
423    // one per card. (Interleaving a getBoundingClientRect() read after a
424    // style write re-invalidates layout, so the next card's read forces a fresh
425    // synchronous reflow — N cards = N reflows/frame. Measured as the single
426    // biggest scroll cost; this split removes it with no change to output.)
427    // vpCentre/vpHeight are passed in so window.innerHeight is read once per
428    // frame, not once per card.
429    function shaderProgress(el, vpCentre, vpHeight) {
430      const rect = el.getBoundingClientRect();
431      const cardCentre = rect.top + rect.height / 2;
432      const distance = Math.abs(cardCentre - vpCentre);
433      const maxDistance = (vpHeight + rect.height) / 2;
434
435      // Raw progress: 0 = off-screen edge, 1 = viewport centre
436      let progress = 1 - clamp(distance / maxDistance, 0, 1);
437
438      // Hold zone: remap so progress reaches 1.0 before dead centre
439      // This creates a plateau where all colours stay fully opaque
440      return clamp(progress / (1 - HOLD_ZONE), 0, 1);
441    }
442
443    // Recompute + push this card's shader colours from its current scrollAlpha
444    // (the triggered tween position — see runShaderColourTween) and revealAlpha
445    // (the entrance ramp). Called every scroll frame AND every tween/entrance
446    // tick; cheap to over-call because the signature check below skips the
447    // actual setUniforms()/GPU redraw whenever nothing has changed.
448    function writeShaderColours(el, mount) {
449      const colors = baseColors.get(el);
450      if (!colors) return;
451
452      // Scroll-activation alpha (0..1), flat across EVERY stop so the whole
453      // gradient fades in/out as one synced unit — exactly like the blur/fade/
454      // header/shadow layers, which are a single --anim-enter-easing opacity
455      // transition. (An earlier per-stop stagger front-loaded the leading stops
456      // to full within ~100ms while the other layers glided over the full 800ms,
457      // so the gradient read as a hard snap instead of an animation.) The easing
458      // and 800ms timing live in runShaderColourTween, which writes scrollAlpha;
459      // here we just pass it straight through.
460      //   × revealAlpha (rev): the entrance skeleton gate — holds the colours at
461      //   0 until the card's entrance finishes, then fades them in.
462      const alpha01 = clamp(scrollAlpha.get(el) ?? 0, 0, 1);
463      const cw = el.closest('.card-wrap');
464      const rev = cw ? (revealAlpha.get(el) ?? (entranceRevealed.has(cw) ? 1 : 0)) : 1;
465      const a = alpha01 * rev;
466
467      const updated = colors.map((c) => [c[0], c[1], c[2], a]);
468
469      // Skip the uniform upload + redraw when the alpha matches last frame's.
470      // rgb is constant per stop and every stop shares one alpha now, so that
471      // alpha alone identifies the frame; rounding to ~10-bit precision avoids
472      // re-pushing sub-perceptual deltas. The living drift animation runs off the
473      // mount's own time loop, not setUniforms, so this never freezes it; a fresh
474      // mount has no cached sig and always draws its first frame.
475      const sig = String(Math.round(a * 1000));
476      if (sig === lastColorSig.get(el)) return;
477      lastColorSig.set(el, sig);
478
479      mount.setUniforms({ u_colors: updated });
480    }
481
482    // Plays the shader colour's own reverse-entrance: a fixed --anim-enter-duration
483    // tween of scrollAlpha toward `target` (1 = shown, 0 = hidden), independent of
484    // whether the user keeps scrolling — WebGL uniforms aren't a CSS property, so
485    // unlike --card-scroll-fade/--card-head-fade this can't just be flipped and
486    // left to a CSS transition; JS has to drive every frame itself. A generation
487    // counter lets a crossing that fires mid-tween (fast back-and-forth scroll)
488    // supersede the in-flight loop instead of fighting it.
489    function runShaderColourTween(el, mount, target) {
490      const gen = (scrollTweenGen.get(el) || 0) + 1;
491      scrollTweenGen.set(el, gen);
492      const start = scrollAlpha.get(el) ?? (target ? 0 : 1);
493      if (start === target) return;
494      const t0 = performance.now();
495      const DUR = 800; // match --anim-enter-duration so the shader plays in step with the header/blur
496      const tick = (now) => {
497        if (scrollTweenGen.get(el) !== gen) return; // superseded by a newer crossing
498        const t = clamp((now - t0) / DUR, 0, 1);
499        scrollAlpha.set(el, start + (target - start) * easeOutQuint(t));
500        writeShaderColours(el, mount);
501        if (t < 1) requestAnimationFrame(tick);
502      };
503      requestAnimationFrame(tick);
504    }
505
506    // WRITE phase — all DOM/shader mutations for one card, given its
507    // already-measured `progress`. Contains NO layout reads (ramps.snap,
508    // getHeroSpeed and the style/uniform writes never query geometry), so it is
509    // safe to run in a batched write pass after every card has been measured.
510    function applyShaderOpacity(el, mount, progress) {
511      // Freeze shader time during active scrolling — setUniforms still
512      // redraws each frame for the alpha stagger, but the sphere stops
513      // rotating until the user settles. `.snap()` also cancels any active
514      // hover ramp so scroll always wins.
515      ramps.get(el)?.snap(progress > 0 && !isScrolling ? getHeroSpeed() : 0);
516
517      // Pause the glow's intensity pulse entirely when the card is off-screen
518      // (unrelated to the triggered hysteresis below — this is a continuous
519      // perf optimisation, not a visual state). Only written on a change: this
520      // runs every scroll frame for every card, and an unconditional write is a
521      // style mutation each time (a recalc + a session-recorder event).
522      const shadow = shadowEls.get(el);
523      const layer = shadowLayerEls.get(el);
524      if (layer) {
525        const playState = progress > 0 ? 'running' : 'paused';
526        if (layer.style.animationPlayState !== playState) layer.style.animationPlayState = playState;
527      }
528
529      // Bottom treatment: blur + colour fade, the header (title/arrow/tags), the
530      // coloured drop-shadow, AND the shader's own colour alpha all now share ONE
531      // TRIGGERED hysteresis off the same centre-distance progress — they flip
532      // together once the card crosses the hold-zone edge, then each plays its
533      // own reverse-entrance through over --anim-enter-duration, independent of
534      // how far the card has actually scrolled. Only acted on a crossing so none
535      // of these restart every frame.
536      const held = headHeld.get(el);
537      let next;
538      if (held === undefined) next = progress >
538= (HEAD_IN + HEAD_OUT) / 2;
539      else if (held && progress < HEAD_OUT) next = false;
540      else if (!held && progress >= HEAD_IN) next = true;
541      else next = held;
542      if (next !== held) {
543        headHeld.set(el, next);
544        const v = next ? '1' : '0';
545        const layers = fadeEls.get(el);
546        if (layers) for (const n of layers) n.style.setProperty('--card-scroll-fade', v);
547        const heads = headEls.get(el);
548        if (heads) for (const n of heads) n.style.setProperty('--card-head-fade', v);
549        if (shadow) shadow.style.setProperty('--shadow-progress', v);
550
551        if (held === undefined) {
552          // First sight (freshly mounted, e.g. scrolled into the pre-warm band):
553          // nothing has painted yet, so snap the shader colour straight to its
554          // resting value instead of animating in from off-screen.
555          scrollAlpha.set(el, next ? 1 : 0);
556        } else {
557          runShaderColourTween(el, mount, next ? 1 : 0);
558        }
559      }
560
561      // Keep the shader's uploaded colours in sync every frame — cheap, since
562      // writeShaderColours dedupes against the last signature and only actually
563      // reflects a moving revealAlpha (entrance) or an in-flight colour tween.
564      writeShaderColours(el, mount);
565    }
566
567    // Single-card convenience (used by initShader for a freshly mounted card):
568    // one card means one reflow regardless, so read + write inline is fine.
569    function updateShaderOpacity(el, mount) {
570      const vpHeight = window.innerHeight;
571      applyShaderOpacity(el, mount, shaderProgress(el, vpHeight / 2, vpHeight));
572    }
573
574    function updateShaderOpacities() {
575      // READ pass: measure every card up front while layout is still clean, so
576      // the browser does a single reflow here instead of one per card.
577      const vpHeight = window.innerHeight;
578      const vpCentre = vpHeight / 2;
579      const measured = [];
580      for (const [el, mount] of mounts) {
581        measured.push([el, mount, shaderProgress(el, vpCentre, vpHeight)]);
582      }
583      // WRITE pass: apply all DOM/shader mutations — no interleaved reads, so
584      // none of these writes forces another layout this frame.
585      for (const [el, mount, progress] of measured) {
586        applyShaderOpacity(el, mount, progress);
587      }
588
589      ticking = false;
590    }
591
592    function onScroll() {
593      isScrolling = true;
594      if (isFirefox) document.documentElement.classList.add('is-scrolling');
595      if (scrollIdleTimer) clearTimeout(scrollIdleTimer);
596      scrollIdleTimer = setTimeout(() => {
597        isScrolling = false;
598        if (isFirefox) document.documentElement.classList.remove('is-scrolling');
599        // Restore animation speed on visible cards now that scrolling settled.
600        updateShaderOpacities();
601      }, SCROLL_IDLE_MS);
602
603      if (!ticking) {
604        ticking = true;
605        requestAnimationFrame(updateShaderOpacities);
606      }
607    }
608
609    if (!prefersReducedMotion) {
610      window.addEventListener('scroll', onScroll, { passive: true });
611    }
612
613    // Fade a card's shader in once its entrance finishes (skeleton → fill).
614    // entrance:done bubbles from the .card-wrap (see animations.js).
615    function revealCardShader(cw) {
616      if (entranceRevealed.has(cw)) return;
617      entranceRevealed.add(cw);
618      const el = cw.querySelector('.card-gradient');
619      if (!el) return;
620      if (prefersReducedMotion) { revealAlpha.set(el, 1); updateShaderOpacities(); return; }
621      const t0 = performance.now();
622      const DUR = 800;   // match --anim-enter-duration so shader + text + image fill as one
623      let sampled = false;
624      const tick = (now) => {
625        const p = Math.min(1, (now - t0) / DUR);
626        revealAlpha.set(el, p);
627        updateShaderOpacities();
628        // Begin easing the gradient tint from its static guess toward the LIVE
629        // shader colour PARTWAY through the reveal — as soon as the shader is
630        // opaque enough to read reliably (~0.6) — so the fade settles onto the
631        // real colour as it appears, instead of snapping to it after the
632        // animation has finished.
633        if (!sampled && p >
633= 0.6 && !window.__pauseCardFadeSampling) { sampled = true; sampleCardFadeColor(el); }
634        if (p < 1) requestAnimationFrame(tick);
635      };
636      requestAnimationFrame(tick);
637    }
638
639    // --- Live fade-colour sampling (option 2) ----------------------------------
640    // averageColor() above is a static first guess (the mean of the card's palette
641    // stops) used until the shader has actually painted. Once a card is shown we
642    // read the colour the shader is REALLY displaying along the card's bottom edge
643    // and use that as the gradient tint, so the fade melts into exactly what's on
644    // screen — and we re-read on a lazy cadence so it tracks the (slow) noise drift.
645    //
646    // To avoid taxing every frame: the WebGL canvas is created WITHOUT
647    // preserveDrawingBuffer (keeping it cheap), so instead of reading at an
648    // arbitrary moment we force ONE extra draw and read that freshly-drawn frame in
649    // the same task — valid without buffer preservation. Runs ~1.5s apart, never
650    // per frame. Colours are premultiplied by the reveal alpha, so we divide it
651    // back out (and skip near-transparent pixels) to recover the true hue.
652    //
653    // Each reading is a TARGET, not the value we paint: a single rAF eases the
654    // shown colour toward the latest target with time-based exponential smoothing
655    // (fadeTweenTau), so the tint drifts slowly and continuously at roughly the
656    // shader's own pace instead of stepping each tick. The loop only paints when
657    // the rounded colour actually changes (so the gradient repaints just a handful
658    // of times per sample, not every frame) and stops itself once everything has
659    // settled.
660    //
661    // Both the sample cadence and the tween's time-constant scale INVERSELY with
662    // the live shader speed (globalThis.SHADER_ANIM_SPEED, mirrored from
663    // getHeroSpeed()) so the tint tracks the shader at any tempo — sampling and
664    // easing faster when the shader runs faster, exactly like the bottom-glow
665    // drift. The base values below are tuned at FADE_REF_SPEED (the hero speed at
666    // the default BASE_SPEED = 1.5), so today's timings are unchanged; bumping
667    // BASE_SPEED now speeds the gradient up in lockstep instead of leaving it to
668    // lag. Floored so a very fast shader can't drive runaway readbacks.
669    const FADE_SAMPLE_BASE_MS = 1500;
670    const FADE_TWEEN_BASE_MS = 1500;   // base time-constant — higher = slower/softer
671    const FADE_REF_SPEED = 1.125;      // getHeroSpeed() at the default BASE_SPEED
672    const fadeSpeedFactor = () => Math.min(3, FADE_REF_SPEED / (globalThis.SHADER_ANIM_SPEED || FADE_REF_SPEED));
673    const fadeSampleMs = () => Math.max(500, FADE_SAMPLE_BASE_MS * fadeSpeedFactor());
674    const fadeTweenTau = () => Math.max(500, FADE_TWEEN_BASE_MS * fadeSpeedFactor());
675    const fadeCur = new Map();     // el → [r,g,b] currently shown (floats)
676    const fadeTarget = new Map();  // el → [r,g,b] latest reading
677    const fadeShown = new Map();   // el → last "r g b" string actually written
678    let fadeRAF = 0, fadeLast = 0;
679
680    function writeFadeColor(el, rgb) {
681      const key = `${Math.round(rgb[0])} ${Math.round(rgb[1])} ${Math.round(rgb[2])}`;
682      if (fadeShown.get(el) === key) return;   // rounds the same — no repaint
683      fadeShown.set(el, key);
684      el.closest('.card')?.style.setProperty('--card-fade-color', `rgb(${key})`);
685    }
686    function fadeTweenStep(now) {
687      const dt = fadeLast ? now - fadeLast : 16;
688      fadeLast = now;
689      const k = 1 - Math.exp(-dt / fadeTweenTau());   // frame-rate- & speed-aware
690      let active = false;
691      for (const [el, target] of fadeTarget) {
692        let cur = fadeCur.get(el);
693        if (!cur) { cur = target.slice(); fadeCur.set(el, cur); }
694        let moved = false;
695        for (let i = 0; i < 3; i++) {
696          const d = target[i] - cur[i];
697          if (Math.abs(d) < 0.3) { cur[i] = target[i]; continue; }
698          cur[i] += d * k; moved = true; active = true;
699        }
700        if (moved || !fadeShown.has(el)) writeFadeColor(el, cur);
701      }
702      fadeRAF = active ? requestAnimationFrame(fadeTweenStep) : (fadeLast = 0, 0);
703    }
704    // `snap` (used for theme switches): jump the shown tint straight to the new
705    // reading instead of easing toward it — the whole palette changed at once, so
706    // a slow drift across would look wrong.
707    function sampleCardFadeColor(el, snap) {
708      const mount = mounts.get(el);
709      if (!mount) return;
710      const gl = mount.gl;
711      if (!gl) return;
712      const w = gl.drawingBufferWidth, h = gl.drawingBufferHeight;
713      if (!w || !h) return;
714      if (!mount.program) return;
715      mount.render(performance.now());   // fresh frame into the backbuffer, read it now
716      // WebGL's y origin is the bottom, so rows [0, stripH) ARE the card's bottom edge.
717      const stripH = Math.max(1, Math.round(h * 0.06));
718      const buf = new Uint8Array(w * stripH * 4);
719      if (gl.isContextLost()) return;
720      gl.readPixels(0, 0, w, stripH, gl.RGBA, gl.UNSIGNED_BYTE, buf);
721      let r = 0, g = 0, b = 0, n = 0;
722      for (let i = 0; i < buf.length; i += 4) {
723        const a = buf[i + 3];
724        if (a < 8) continue;             // skeleton / mid-reveal transparency — ignore
725        const f = 255 / a;               // un-premultiply
726        r += buf[i] * f; g += buf[i + 1] * f; b += buf[i + 2] * f; n++;
727      }
728      if (!n) return;
729      const target = [r / n, g / n, b / n];
730      fadeTarget.set(el, target);
731      if (snap) {                         // theme switch — jump straight there
732        fadeCur.set(el, target.slice());
733        fadeShown.delete(el);             // force the write over the static reset
734        writeFadeColor(el, target);
735        return;
736      }
737      if (!fadeCur.has(el)) {
738        // Seed from the static guess currently shown so the FIRST live reading
739        // EASES in from the default tint rather than snapping to it (the old
740        // snap was the harsh jump). Falls back to the target if nothing's set.
741        fadeCur.set(el, readShownFadeColor(el) || target.slice());
742      }
743      if (!fadeRAF) { fadeLast = 0; fadeRAF = requestAnimationFrame(fadeTweenStep); }
744    }
745    // Parse the gradient tint currently on the card (`rgb(r g b)` from
746    // applyCardFadeColors) into [r,g,b] floats, so the tween can start there.
747    function readShownFadeColor(el) {
748      const v = el.closest('.card')?.style.getPropertyValue('--card-fade-color');
749      const m = v && v.match(/[\d.]+/g);
750      return m && m.length >= 3 ? [+m[0], +m[1], +m[2]] : null;
751    }
752    function sampleAllCardFadeColors() {
753      if (document.hidden) return;       // nothing painting while the tab is backgrounded
754      // gl.readPixels below can block the main thread for 100-300ms — skip this
755      // round while something else (e.g. the Book a Call popover's open/close
756      // spring, see book-call.js's pauseCardFadeSampling()) needs a smooth frame.
757      if (window.__pauseCardFadeSampling) return;
758      // Never mid-scroll: the shader's clock is frozen while scrolling (see
759      // applyShaderOpacity), so the colour can't have moved — and the readback
760      // stall would land right in the scroll. Off-screen cards are skipped for
761      // the same reason: nobody can see the tint, and every mounted card would
762      // otherwise pay the readback every round.
763      if (isScrolling) return;
764      const vh = window.innerHeight;
765      for (const el of mounts.keys()) {
766        const cw = el.closest('.card-wrap');
767        if (!cw || !entranceRevealed.has(cw)) continue;
768        const r = el.getBoundingClientRect();
769        if (r.bottom <= 0 || r.top >= vh) continue;
770        sampleCardFadeColor(el);
771      }
772    }
773    // Every mounted card shader keeps its own requestAnimationFrame loop running
774    // continuously (ShaderMount.render, vendor/paper-shaders) — that's real,
775    // ongoing GPU/compositor work on top of whatever else the page is doing.
776    // book-call.js calls these around its open/close spring: profiling traced a
777    // real dropped-frames "pop" (the popover's clip-path/border-radius jumping
778    // most of the way to its target in one visible frame instead of animating
779    // smoothly) to the browser running out of paint budget while every card
780    // shader keeps rendering underneath. setCurrentSpeed(0) is the exact call
781    // ShaderMount's own handleDocumentVisibilityChange uses to fully cancel its
782    // rafId when the tab goes hidden (see paper-shaders' source) — reusing it
783    // here stops the shaders' rAF loop outright for the ~600ms the spring runs,
784    // rather than just freezing their visual motion (setSpeed(0) alone still
785    // redraws every frame). Restoring `mount.speed` (not a hardcoded value)
786    // picks back up wherever createSpeedRamp's hover state had it.
787    function pauseHeroShaders() {
788      for (const mount of mounts.values()) mount.setCurrentSpeed(0);
789    }
790    function resumeHeroShaders() {
791      if (document.hidden) return;  // stay paused — the tab-hidden handler owns this state
792      for (const mount of mounts.values()) mount.setCurrentSpeed(mount.speed);
793    }
794    window.__pauseHeroShaders = pauseHeroShaders;
795    window.__resumeHeroShaders = resumeHeroShaders;
796    // Every ShaderMount registers its OWN visibilitychange handler that runs
797    // setCurrentSpeed(hidden ? 0 : this.speed) — so returning to the tab
798    // restarts every shader, even one we deliberately paused. That used to be
799    // harmless (the pause only spanned the ~600ms open/close spring), but the
800    // About card and Book-a-call popover now hold the pause for the WHOLE open
801    // (their full-viewport backdrop makes running shaders very expensive,
802    // especially in Firefox — see about-card.js / book-call.js). Re-assert the
803    // pause on the next frame — after every visibilitychange listener has run,
804    // whatever order they were registered in — so a tab switch mid-open can't
805    // silently undo it. __pauseCardFadeSampling is the shared "something needs
806    // smooth frames right now" flag both popovers set alongside the pause.
807    document.addEventListener('visibilitychange', function () {
808      if (document.hidden || !window.__pauseCardFadeSampling) return;
809      requestAnimationFrame(function () {
810        if (window.__pauseCardFadeSampling) pauseHeroShaders();
811      });
812    });
813    // Run `cb` once every mockup PICTURE in the card has decoded (the original
814    // imagery, not the .card-blur clones). Used to hold the header + blur +
815    // gradient back until there's a real, sharp picture for them to settle onto.
816    // Fires immediately if already loaded, and is backstopped by a timeout so a
817    // stalled/blocked image can never strand the card half-revealed.
818    function whenCardImageLoaded(cw, cb) {
819      const imgs = [...cw.querySelectorAll('.thumb-wrap img')]
820        .filter((img) => !img.closest('.card-blur'));   // originals only
821      let pending = 0, done = false;
822      const finish = () => { if (done) return; done = true; cb(); };
823      imgs.forEach((img) => {
824        if (img.complete && img.naturalWidth > 0) return;
825        pending++;
826        const settle = () => { if (--pending <= 0) finish(); };
827        img.addEventListener('load', settle, { once: true });
828        img.addEventListener('error', settle, { once: true });
829      });
830      if (pending === 0) finish();
831      setTimeout(finish, 6000);          // safety net — never strand the reveal
832    }
833
834    // Match the thumbhash loader's skeleton hold so the shader and image fill
835    // the empty frame together. 0 = the interior fills the moment the frame
836    // entrance finishes (no pause between skeleton and fill).
837    const SKELETON_HOLD_MS = 0;
838    document.addEventListener('entrance:done', (e) => {
839      const cw = e.target instanceof Element ? e.target.closest('.card-wrap') : null;
840      if (cw) setTimeout(() => {
841        cw.classList.remove('is-skeleton');   // loader stops; image area + shader fill
842        revealCardShader(cw);                  // shader colour fades in — same tick
843        // Header + blur + gradient hold until the picture has actually decoded,
844        // then animate in over it (header staggers per element; see components.css).
845        whenCardImageLoaded(cw, () => cw.classList.remove('is-img-pending'));
846      }, SKELETON_HOLD_MS);
847    });
848
849    // Update every live card shader when the viewport crosses a breakpoint.
850    // Speed is breakpoint-dependent (via getHeroSpeed()), so refresh the
851    // ramp targets too — re-running updateShaderOpacities picks up the new
852    // value for visible cards on the next snap().
853    onBreakpointChange(() => {
854      const scale = getCardScale();
855      for (const mount of mounts.values()) {
856        mount.setUniforms({ u_scale: scale });
857      }
858      if (!prefersReducedMotion) updateShaderOpacities();
859    });
860
861    document.addEventListener('DOMContentLoaded', async () => {
862      // Yield two frames before the first shader compile so the entrance
863      // animation can paint cleanly first. Shader compilation is a heavy
864      // synchronous main-thread task, and the entrance animation's
865      // `filter: blur(...)` interpolation also runs on the main thread —
866      // if both happen in the same frame, the browser can't paint the
867      // blur transition and the animation appears to skip.
868      const yieldFrames = () =>
869        new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
870
871      await yieldFrames();
872
873      // Every card's shader is compiled exactly ONCE and kept for the whole
874      // visit; off-screen cards simply sit at speed 0 (their rAF loop cancelled
875      // by applyShaderOpacity), which costs nothing. Cards on screen compile
876      // now, one per frame so the entrance can paint between compiles; the rest
877      // wait until the entrance has finished (goSteady below), again one per
878      // frame. An earlier version disposed off-screen shaders and re-created
879      // them from an IntersectionObserver band: rebuilding that observer ~1.5s
880      // after load fired for every card in the band at once and compiled them
881      // back-to-back inside ONE callback — a measured 633ms frozen frame right
882      // as a visitor starts their first scroll — and scrolling back up
883      // recompiled cards again (57–143ms hitches). Five live WebGL contexts
884      // are well inside the browser's ~16 cap.
885      const vh = window.innerHeight;
886      const deferred = [];
887      for (const cfg of shaderConfigs) {
888        const el = document.getElementById(cfg.id);
889        if (!el) continue;
890        const rect = el.getBoundingClientRect();
891        if (rect.top < vh && rect.bottom > 0) {
892          initShader(el);
893          await yieldFrames();
894        } else {
895          deferred.push(el);
896        }
897      }
898      applyCardFadeColors();   // static per-card fade tint from the shader palette
899      // …then keep each card's fade tint in step with what its shader is actually
900      // showing, on a lazy cadence (see sampleAllCardFadeColors). Self-reschedules
901      // so the delay re-reads the speed-scaled cadence each tick — when the shader
902      // tempo changes (breakpoint, or a BASE_SPEED tweak) the sampling follows.
903      (function scheduleFadeSample() {
904        setTimeout(() => { sampleAllCardFadeColors(); scheduleFadeSample(); }
904, fadeSampleMs());
905      })();
906
907      if (!prefersReducedMotion) {
908        // Initial update for cards already in viewport on load
909        requestAnimationFrame(updateShaderOpacities);
910      }
911
912      // Once the entrance has stopped competing for the main thread, compile
913      // the remaining (off-screen) cards, one per frame. This timing isn't
914      // visually load-bearing (those cards aren't on screen yet), so a coarse
915      // delay derived from the entrance's own CSS timing is enough. Each compile
916      // is a 50–150ms main-thread block, so if the visitor is already scrolling
917      // it waits for a pause — unless the card is within a viewport of coming
918      // into view, in which case it's needed now and compiles regardless.
919      //
920      // The next card is re-CHOSEN each pass rather than walked in a fixed
921      // order, or that "needed now" exception can never fire: a sequential loop
922      // parks on whichever card comes first in the document, so on a fast flick
923      // to the footer it would sit waiting on a card now far ABOVE the visitor
924      // (nearViewport false, isScrolling true) while the cards actually on
925      // screen stayed uncompiled until the momentum scroll settled.
926      const nearViewport = (el) => {
927        const r = el.getBoundingClientRect();
928        const vh = window.innerHeight;
929        return r.top < vh * 2 && r.bottom > -vh;
930      };
931      const goSteady = async () => {
932        const pending = new Set(deferred);
933        while (pending.size) {
934          // Idle: take them in document order. Mid-scroll: only take a card the
935          // visitor is about to reach, and otherwise wait for the scroll to ease.
936          let next = null;
937          for (const el of pending) {
938            if (!isScrolling || nearViewport(el)) { next = el; break; }
939          }
940          if (!next) {
941            await new Promise((r) => setTimeout(r, SCROLL_IDLE_MS));
942            continue;
943          }
944          pending.delete(next);
945          initShader(next);
946          await yieldFrames();
947        }
948      };
949      if (prefersReducedMotion) {
950        goSteady();
951      } else {
952        const footer = document.querySelector('.footer') || document.querySelector('.card-wrap');
953        const toMs = (s) => { s = (s || '').trim(); return s.endsWith('ms') ? parseFloat(s) : (parseFloat(s) || 0) * 1000; };
954        let entranceMs = 850;
955        if (footer) {
956          const cs = getComputedStyle(footer);
957          entranceMs = toMs(cs.animationDelay) + toMs(cs.animationDuration);
958        }
959        setTimeout(goSteady, entranceMs + 200);
960      }
961
962      // React to theme changes without requiring a page refresh. Rebuilds
963      // shaderConfigs (which re-evaluates brandColor / cardBg with the fresh
964      // isDark() result) and pushes the new u_colors to every active mount.
965      //
966      // Two triggers, both call the same rebuild:
967      //   • OS preference change (matchMedia)
968      //   • Manual theme switcher click (window 'themechange' event from
969      //     theme-switcher.js)
970      // URL params (?dark / ?light) override both, so isDark() — and therefore
971      // the rebuilt configs — stay locked when a param is present.
972      const rebuildShaders = () => {
973        shaderConfigs = buildShaderConfigs();
974        for (const [el, mount] of mounts) {
975          const cfg = shaderConfigs.find(c => c.id === el.id);
976          if (!cfg) continue;
977          baseColors.set(el, cfg.colors);
978          // Push the new palette at the card's CURRENT scroll/entrance alpha, NOT
979          // full opacity. A raw setUniforms(cfg.colors) forces every card fully
980          // opaque, which desyncs the GPU from scrollAlpha/lastColorSig for any
981          // card that isn't fully in view: it then flashes full colour, and the
982          // next hold-zone crossing starts its tween from the stale scrollAlpha=0,
983          // snapping the card down to its plain background before fading back up
984          // (the "starts on the plain black/white colour, then slowly catches up"
985          // bug). Routing through writeShaderColours keeps an off-screen card at
986          // alpha 0 so it fades in cleanly on scroll, exactly like a no-toggle
987          // reveal. lastColorSig is cleared first so the write lands even when the
988          // computed alpha is unchanged (the dedup guard only fingerprints alpha).
989          lastColorSig.delete(el);
990          writeShaderColours(el, mount);
991        }
992        applyCardFadeColors();   // static per-card fade tint for the new theme (every card)
993        // applyCardFadeColors just wrote the correct new-theme tint onto every
994        // card's --card-fade-color. Resync the fade-tint TWEEN bookkeeping to that
995        // value up front so a card too faded to read live right now (off-screen,
996        // GPU alpha 0 after the writeShaderColours above) doesn't keep its stale
997        // OLD-theme fadeCur/fadeTarget and then ease from the old colour to the new
998        // one over ~1.5s the next time it scrolls into view and gets sampled.
999        for (const el of mounts.keys()) {
1000          const cw = el.closest('.card-wrap');
1001          if (!(cw && entranceRevealed.has(cw))) continue;
1002          const shown = readShownFadeColor(el);   // the static new-theme avg just set
1003          if (shown) { fadeCur.set(el, shown.slice()); fadeTarget.set(el, shown.slice()); fadeShown.delete(el); }
1004          // Cards solid enough to read get an instant live snap on top of that; the
1005          // faded ones no-op inside sampleCardFadeColor and keep the correct static
1006          // value we just seeded.
1007          sampleCardFadeColor(el, true);
1008        }
1009      };
1010      matchMedia('(prefers-color-scheme: dark)').addEventListener('change', rebuildShaders);
1011      window.addEventListener('themechange', rebuildShaders);
1012    });
1013  </script>
1013
1014
1015
1016  <!-- Speculation Rules — prerender same-origin page navigations on link
1017       hover/touch (eagerness: moderate), so project pages (and back-to-home)
1018       open near-instantly. Progressive enhancement: unsupported browsers
1019       ignore the block and links navigate normally. Safe with the entrance
1020       cascade — runEntranceCascade() holds the reveal until the page is first
1021       visible, so a prerendered page plays its entrance on activation, not in
1022       the background. Cloudflare's injected Speed Brain only *prefetches*
1023       conservatively; this upgrades the six site pages to full prerender. -->
1024  
1024<script type="speculationrules">
1025  {
1026    "prerender": [{
1027      "where": { "href_matches": ["/", "/bella-duke{/}?", "/homemove{/}?", "/pikl-dashboard{/}?", "/pikl-design-system{/}?", "/wren-kitchens{/}?"] },
1028      "eagerness": "moderate"
1029    }]
1030  }
1031  </script>
1031
1032
1033</head>
1034<body>
1035
1036  <div class="page">
1037
1038    <!-- ========== PROFILE ========== -->
1039    <header class="profile-section">
1040      <div class="profile-group">
1041        <div class="profile">
1042          <!-- Avatar pill: holds the avatar(s) and, when music is playing, the
1043               vinyl disc injected by now-playing.js. -->
1044          <div class="avatar-pill" id="avatar-pill" role="button" tabindex="0"
1045               aria-expanded="false" aria-haspopup="dialog" aria-label="Open About me">
1046            <!-- Two avatars rendered side-by-side; CSS toggles visibility based on
1047                 the resolved theme (manual data-theme override OR OS preference).
1048                 <picture><source media="prefers-color-scheme"> would only follow
1049                 the OS and miss manual switcher toggles. -->
1050            <picture class="avatar--light"><source type="image/webp" srcset="/avatar-light.webp 128w" sizes="32px"><img class="avatar" src="/avatar-light.png" srcset="/avatar-light.png 128w" sizes="32px" alt="John Miller" width="128" height="128" decoding="async"></picture>
1051            <picture class="avatar--dark"><source type="image/webp" srcset="/avatar.webp 128w" sizes="32px"><img class="avatar" src="/avatar.png" srcset="/avatar.png 128w" sizes="32px" alt="" aria-hidden="true" width="128" height="128" decoding="async"></picture>
1052            <!-- Vinyl injected here by now-playing.js when music is active -->
1053            <span class="about-expand" id="about-expand" aria-hidden="true">
1054              <!-- Nucleo "arrows-expand-diagonal", filled. fill=currentColor so it
1055                   inherits the theme-aware icon colour set on .about-expand. -->
1056              <svg viewBox="0 0 18 18" fill="currentColor" aria-hidden="true">
1057                <path d="M15.25,2h-4.5c-.414,0-.75,.336-.75,.75s.336,.75,.75,.75h2.689l-3.22,3.22c-.293,.293-.293,.768,0,1.061,.146,.146,.338,.22,.53,.22s.384-.073,.53-.22l3.22-3.22v2.689c0,.414,.336,.75,.75,.75s.75-.336,.75-.75V2.75c0-.414-.336-.75-.75-.75Z"/>
1058                <path d="M7.78,10.22c-.293-.293-.768-.293-1.061,0l-3.22,3.22v-2.689c0-.414-.336-.75-.75-.75s-.75,.336-.75,.75v4.5c0,.414,.336,.75,.75,.75H7.25c.414,0,.75-.336,.75-.75s-.336-.75-.75-.75h-2.689l3.22-3.22c.293-.293,.293-.768,0-1.061Z"/>
1059              </svg>
1060            </span>
1061          </div>
1062          <div class="identity">
1063            <div class="name-row">
1064              <h3 class="name">John Miller</h3>
1065              <span class="name-separator">&middot;</span>
1066              <span class="name-label">Portfolio</span>
1067            </div>
1068            <p class="job-title">Senior Product Designer</p>
1069          </div>
1070        </div>
1071      </div>
1072
1073      <p class="bio">
1074        I bring structure to the problems that need it, without sacrificing the craft. Teams that work better than I found them matter to me as much as what we build.
1075      </p>
1076
1077      <div class="contact">
1078        <button type="button" class="btn btn-primary" id="book-call-btn"
1079                aria-haspopup="dialog" aria-expanded="false" aria-controls="book-call-panel">
1080          <!-- Nucleo "calendar-phone", filled. fill=currentColor so it inherits
1081               the button's inverted text colour (see .btn-primary). -->
1082          <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" fill="currentColor" aria-hidden="true">
1083            <path d="m5.75,4c-.4141,0-.75-.3359-.75-.75V1.25c0-.4141.3359-.75.75-.75s.75.3359.75.75v2c0,.4141-.3359.75-.75.75Z"/>
1084            <path d="m12.25,4c-.4141,0-.75-.3359-.75-.75V1.25c0-.4141.3359-.75.75-.75s.75.3359.75.75v2c0,.4141-.3359.75-.75.75Z"/>
1085            <path d="m13.75,2.5H4.25c-1.5166,0-2.75,1.2334-2.75,2.75v8.5c0,1.5166,1.2334,2.75,2.75,2.75h4.3789c.4141,0,.75-.3359.75-.75s-.3359-.75-.75-.75h-4.3789c-.6895,0-1.25-.5605-1.25-1.25v-6.25h12v3.4062c0,.4141.3359.75.75.75s.75-.3359.75-.75v-5.6562c0-1.5166-1.2334-2.75-2.75-2.75Z"/>
1086            <path d="m16.2349,13.499l-.8623-.3501c-.52-.2295-1.1284-.0801-1.4639.3413l-1.0078,1.1704c-.624-.4043-1.1572-.9375-1.5615-1.5615l1.1504-.9917c.4419-.355.5903-.9644.3701-1.46l-.3682-.9033c-.248-.5605-.8589-.8584-1.5024-.6895l-1.1118.3647c-.5444.1792-.9004.6978-.8647,1.2617.2134,3.3813,2.9248,6.0928,6.3062,6.3062.0264.0015.0518.0024.0776.0024.5342,0,1.0142-.3481,1.1841-.8677l.3784-1.1582c.1528-.5928-.1416-1.2046-.7241-1.4648Z"/>
1087          </svg>
1088          <span class="btn__label">Book a call</span>
1089        </button>
1090        <contact-button-group></contact-button-group>
1091      </div>
1092    </header>
1093
1094    <!-- ========== PROJECT CARDS ========== -->
1095    <main class="projects">
1096
1097      <!-- Card 1 — Bella+Duke -->
1098      <div class="card-wrap">
1099      <div class="card-shadow" data-card="bella-duke" aria-hidden="true">
1100        <div class="card-shadow__layer"></div>
1101      </div>
1102      <a class="card" href="./bella-duke">
1103          <div class="card-loader" data-card="bella-duke" aria-hidden="true"></div>
1104          <div class="card-gradient" id="gradient-bella-duke"></div>
1105          <div class="project-image">
1106            <div class="mockup-group">
1107              <div class="mockup">
1108                <span class="thumb-wrap" data-thumbhash="OQgGBYCXeWV8Zod3iDiZX/pXqn+j"><picture><source type="image/avif" srcset="/bella-duke-mockup-768.avif 768w, /bella-duke-mockup-1280.avif 1280w, /bella-duke-mockup-2880.avif 2880w" sizes="(min-width: 1280px) 1216px, 85vw"><source type="image/webp" srcset="/bella-duke-mockup-768.webp 768w, /bella-duke-mockup-1280.webp 1280w, /bella-duke-mockup-2880.webp 2880w" sizes="(min-width: 1280px) 1216px, 85vw"><img src="/bella-duke-mockup-1280.png" srcset="/bella-duke-mockup-768.png 768w, /bella-duke-mockup-1280.png 1280w, /bella-duke-mockup.png 2880w" sizes="(min-width: 1280px) 1216px, 85vw" alt="Bella and Duke project — Orchestrated Clarity in Chaos" width="2880" height="2048" fetchpriority="high" decoding="async"></picture></span>
1109              </div>
1110              <div class="mockup mockup--mobile">
1111                <span class="thumb-wrap" data-thumbhash="OfgFAwCVeQh3fXqVhHwPfIQ="><picture><source type="image/avif" srcset="/bella-duke-mobile-mockup-375.avif 375w, /bella-duke-mobile-mockup-1125.avif 1125w" sizes="(max-width: 809px) 22vw, (max-width: 1199px) 22vw, 324px"><source type="image/webp" srcset="/bella-duke-mobile-mockup-375.webp 375w, /bella-duke-mobile-mockup-1125.webp 1125w" sizes="(max-width: 809px) 22vw, (max-width: 1199px) 22vw, 324px"><img src="/bella-duke-mobile-mockup-375.png" srcset="/bella-duke-mobile-mockup-375.png 375w, /bella-duke-mobile-mockup.png 1125w" sizes="(max-width: 809px) 22vw, (max-width: 1199px) 22vw, 324px" alt="Bella and Duke mobile app home screen" width="1125" height="2446" fetchpriority="low" decoding="async"></picture></span>
1112              </div>
1113              <div class="card-blur" aria-hidden="true"></div>
1114            </div>
1115          </div>
1116          <div class="card-fade" aria-hidden="true"></div>
1117          <div class="card-header">
1118              <div class="card-title-row">
1119                <h2 class="card-title">Turned Subscription Friction Into Trust</h2>
1120                <svg class="card-arrow" viewBox="0 0 18 18" fill="currentColor" aria-hidden="true"><path d="M15,9.75H2.75c-.414,0-.75-.336-.75-.75s.336-.75,.75-.75H15c.414,0,.75,.336,.75,.75s-.336,.75-.75,.75Z"/><path d="M11,14c-.192,0-.384-.073-.53-.22-.293-.293-.293-.768,0-1.061l3.72-3.72-3.72-3.72c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l4.25,4.25c.293,.293,.293,.768,0,1.061l-4.25,4.25c-.146,.146-.338,.22-.53,.22Z"/></svg>
1121              </div>
1122              <div class="card-tags">
1123                <div class="card-tag">
1124                  <svg class="tag-logo" xmlns="http://www.w3.org/2000/svg" width="115.81" height="16" fill="none" viewBox="0 0 130.29 18" aria-label="Bella and Duke">
1125                    <path d="M68.2988 5.34431C68.2988 4.96858 68.6048 4.66396 68.9815 4.66396C69.3587 4.66396 69.6647 4.96858 69.6647 5.34431C69.6647 5.72004 69.3587 5.93463 68.9815 5.93463C68.6048 5.93463 68.2988 5.72004 68.2988 5.34431ZM35.1021 15.8684H39.0295V0.304389H35.1021V15.8684ZM28.184 9.33148C28.184 9.75392 28.1616 10.1319 28.0947 10.5099H18.41C18.8563 11.7773 20.0166 12.6221 21.4671 12.6221C22.4936 12.6221 23.4085 12.133 24.0333 11.3326L27.0459 13.5115C25.5954 15.3125 23.5871 16.1352 21.3779 16.1352C17.4282 16.1352 14.4379 13.1558 14.4379 9.33148C14.4379 5.52946 17.3612 2.52781 21.3109 2.52781C25.2607 2.52781 28.184 5.41828 28.184 9.33148ZM24.4127 7.99743C23.9441 6.68561 22.806 5.88517 21.4002 5.88517C19.9943 5.88517 18.8563 6.70786 18.3877 7.99743H24.4127ZM29.3447 15.8684H33.2721V0.304389H29.3447V15.8684ZM130.197 10.5099H120.512C120.958 11.7773 122.118 12.6221 123.569 12.6221C124.596 12.6221 125.51 12.133 126.135 11.3326L129.148 13.5115C127.697 15.3125 125.689 16.1352 123.48 16.1352C119.53 16.1352 116.54 13.1558 116.54 9.33148C116.54 5.52946 119.463 2.52781 123.413 2.52781C127.362 2.52781 130.286 5.41828 130.286 9.33148C130.286 9.75392 130.263 10.1319 130.197 10.5099ZM126.515 7.99743C126.046 6.68561 124.908 5.88517 123.502 5.88517C122.096 5.88517 120.958 6.70786 120.489 7.99743H126.515ZM69.2897 1.85526C70.2535 2.18618 71.0704 2.83122 71.6185 3.66734C71.6605 3.73177 71.7591 3.71592 71.7801 3.64181C72.1363 2.36841 72.4911 1.10133 72.537 0.936646C72.6154 0.656472 72.7054 0.302297 72.3531 0.0741432C71.7415 -0.235429 70.494 0.446596 69.2533 1.70696C69.2065 1.75441 69.2267 1.83361 69.2897 1.85526ZM13.813 9.33148C13.813 13.2669 10.9343 16.1352 7.18543 16.1352C5.69031 16.1352 4.57457 15.6905 3.70428 14.9567V15.8684H0V0.304389H3.90511V3.50611C4.7754 2.88356 5.86885 2.52781 7.18543 2.52781C10.9343 2.52781 13.813 5.41828 13.813 9.33148ZM9.86322 9.2648C9.86322 7.39713 8.502 6.06307 6.73911 6.06307C4.95394 6.06307 3.5927 7.39713 3.5927 9.2648C3.5927 11.1325 4.95394 12.4665 6.73911 12.4665C8.502 12.4665 9.86322 11.1325 9.86322 9.2648ZM63.2597 14.9468C62.661 14.9468 62.1758 14.9687 62.1758 15.5652C62.1758 16.1617 62.661 16.6452 63.2597 16.6452C63.8584 16.6452 64.3435 16.1617 64.3435 15.5652C64.3435 14.9687 63.8584 14.9468 63.2597 14.9468ZM99.468 10.4877C99.468 11.755 98.7094 12.4888 97.5492 12.4888C96.411 12.4888 95.7192 11.755 95.7192 10.4877V2.79463H91.7918V11.1769C91.7918 14.2453 93.8447 16.1352 96.6788 16.1352C97.884 16.1352 98.955 15.7349 99.6467 14.979V15.8684H103.395V2.79463H99.468V10.4877ZM71.0177 6.96787H74.385V11.032H67.3963V15.9036C67.3963 17.0353 66.4954 17.984 65.3593 17.9996C65.0485 18.0039 64.7528 17.945 64.4867 17.8345C64.422 17.8076 64.4125 17.72 64.4695 17.6792C65.046 17.2663 65.4257 16.5669 65.4257 15.7731C65.4257 14.529 64.8304 13.5599 63.4024 13.5196C63.3531 13.5182 63.3133 13.4792 63.3133 13.4302L63.3128 12.6054C63.3128 11.1378 62.4793 10.4531 61.4541 10.4531C60.9227 10.4531 60.3913 10.6761 60.0527 11.032H56.3194V6.96787H63.3094L63.3098 0.686849C63.3098 0.42002 63.3724 0.195575 63.5648 0.0843497C63.9844 -0.158404 64.7095 0.136024 65.58 0.797682C66.45 1.45934 67.3967 3.02926 67.3967 4.93117C67.3967 6.24453 66.7525 7.37874 65.868 7.37874C65.5843 7.37874 65.4038 7.32195 65.2808 7.2896C65.1047 7.24323 64.9363 7.26491 64.8304 7.41273C64.6808 7.62118 64.7387 7.87828 65.0061 8.00874C65.2774 8.14095 65.6044 8.18527 65.9485 8.18527C66.5198 8.18527 67.0585 7.91655 67.4091 7.49925C67.4443 7.45738 67.5098 7.45738 67.545 7.49925C67.8955 7.91655 68.4343 8.18527 69.0055 8.18527C69.9818 8.18527 70.7511 7.67475 71.0177 6.96787ZM64.6997 5.34761C64.6997 5.75086 65.0275 5.98117 65.4325 5.98117C65.8371 5.98117 66.1654 5.75086 66.1654 5.34761C66.1654 4.94433 65.8371 4.61741 65.4325 4.61741C65.0275 4.61741 64.6997 4.94433 64.6997 5.34761ZM66.1654 12.7316C66.1654 12.3283 65.8371 12.0014 65.4325 12.0014C65.028 12.0014 64.6997 12.3283 64.6997 12.7316C64.6997 13.1348 65.028 13.3651 65.4325 13.3651C65.8371 13.3651 66.1654 13.1348 66.1654 12.7316ZM117.298 2.79463H112.612L109.108 8.1086V0.304389H105.225V15.8684H109.108V9.99851L112.723 15.8684H117.588L112.969 8.95353L117.298 2.79463ZM86.1681 0.304389H90.0732V15.8684H86.3691V14.9567C85.4987 15.6905 84.3831 16.1352 82.8878 16.1352C79.1391 16.1352 76.2604 13.2669 76.2604 9.33148C76.2604 5.41828 79.1391 2.52781 82.8878 2.52781C84.2044 2.52781 85.2981 2.88356 86.1681 3.50611V0.304389ZM86.4805 9.2648C86.4805 7.39713 85.1194 6.06307 83.3344 6.06307C81.5713 6.06307 80.2101 7.39713 80.2101 9.2648C80.2101 11.1325 81.5713 12.4665 83.3344 12.4665C85.1194 
112512.4665 86.4805 11.1325 86.4805 9.2648ZM50.3657 2.79463H54.0698V15.8684H50.3657V14.9345C49.5176 15.6905 48.402 16.1352 46.8844 16.1352C43.1357 16.1352 40.257 13.2669 40.257 9.33148C40.257 5.41828 43.1357 2.52781 46.8844 2.52781C48.2683 2.52781 49.4284 2.9058 50.3657 3.72846V2.79463ZM50.4771 9.2648C50.4771 7.39713 49.116 6.06307 47.331 6.06307C45.5678 6.06307 44.2067 7.39713 44.2067 9.2648C44.2067 11.1325 45.5678 12.4665 47.331 12.4665C49.116 12.4665 50.4771 11.1325 50.4771 9.2648ZM61.2437 14.8319C61.0993 14.8588 60.9497 14.8732 60.7958 14.8732C60.1941 14.8732 59.6507 14.6573 59.2624 14.3102C59.2217 14.2736 59.1578 14.2837 59.127 14.329C58.7575 14.8703 58.2531 15.3989 58.0551 15.8152C57.6844 16.595 58.1005 17.5423 58.9071 17.8772C59.7141 18.2122 60.6818 17.8396 60.9767 17.0282C61.158 16.5301 61.1593 15.6405 61.3465 14.9418C61.3633 14.8788 61.308 14.8199 61.2437 14.8319ZM61.3581 12.0479C60.981 12.0479 60.675 12.3525 60.675 12.7283C60.675 13.104 60.981 13.3186 61.3581 13.3186C61.7353 13.3186 62.0408 13.104 62.0408 12.7283C62.0408 12.3525 61.7353 12.0479 61.3581 12.0479Z" fill="currentColor" />
1126                  </svg>
1127                  <span class="sr-only">Company</span>
1128                </div>
1129                <div class="card-tag">
1130                  <svg class="tag-icon" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 18 18" aria-hidden="true"><path d="M14.25,2H3.75c-1.517,0-2.75,1.233-2.75,2.75V13.25c0,1.517,1.233,2.75,2.75,2.75H14.25c1.517,0,2.75-1.233,2.75-2.75V4.75c0-1.517-1.233-2.75-2.75-2.75ZM4,6c-.552,0-1-.448-1-1s.448-1,1-1,1,.448,1,1-.448,1-1,1Zm3,0c-.552,0-1-.448-1-1s.448-1,1-1,1,.448,1,1-.448,1-1,1Z"/></svg>
1131                  <span class="card-tag-value">Web App</span>
1132                  <span class="sr-only">Project Type</span>
1133                </div>
1134                <div class="card-tag">
1135                  <svg class="tag-icon" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 18 18" aria-hidden="true"><ellipse cx="6.25" cy="4.5" rx="2.25" ry="3"/><ellipse cx="2.75" cy="8.75" rx="1.5" ry="2"/><ellipse cx="11.75" cy="4.5" rx="2.25" ry="3"/><ellipse cx="15.25" cy="8.75" rx="1.5" ry="2"/><path d="m9,8c-2.29,0-5,3.0488-5,5.625,0,.6406.2852,1.2632.7842,1.709.4814.4292,1.1162.666,1.7871.666.8203,0,1.2764-.2812,1.6094-.4868.2656-.1641.4258-.2632.8193-.2632s.5537.0991.8193.2632c.333.2056.7891.4868,1.6094.4868.6709,0,1.3057-.2368,1.7881-.667.498-.4448.7832-1.0674.7832-1.708,0-2.5762-2.71-5.625-5-5.625Z"/></svg>
1136                  <span class="card-tag-value">Petcare</span>
1137                  <span class="sr-only">Industry</span>
1138                </div>
1139              </div>
1140            </div>
1141      </a>
1142      </div>
1143
1144      <!-- Card 2 — Homemove -->
1145      <div class="card-wrap">
1146      <div class="card-shadow" data-card="homemove" aria-hidden="true">
1147        <div class="card-shadow__layer"></div>
1148      </div>
1149      <a class="card" href="./homemove">
1150          <div class="card-loader" data-card="homemove" aria-hidden="true"></div>
1151          <div class="card-gradient" id="gradient-homemove"></div>
1152          <div class="project-image">
1153            <div class="mockup-group">
1154              <div class="mockup">
1155                <span class="thumb-wrap" data-thumbhash="/PcFBYAtqrKXl3gPXJWWwkHPoKcI"><picture><source type="image/avif" srcset="/homemove-mockup-768.avif 768w, /homemove-mockup-1280.avif 1280w, /homemove-mockup-2880.avif 2880w" sizes="(min-width: 1280px) 1216px, 85vw"><source type="image/webp" srcset="/homemove-mockup-768.webp 768w, /homemove-mockup-1280.webp 1280w, /homemove-mockup-2880.webp 2880w" sizes="(min-width: 1280px) 1216px, 85vw"><img src="/homemove-mockup-1280.png" srcset="/homemove-mockup-768.png 768w, /homemove-mockup-1280.png 1280w, /homemove-mockup.png 2880w" sizes="(min-width: 1280px) 1216px, 85vw" alt="Homemove project — Orchestrated Clarity in Chaos" width="2880" height="2048" loading="lazy" decoding="async"></picture></span>
1156              </div>
1157              <div class="mockup mockup--mobile">
1158                <span class="thumb-wrap" data-thumbhash="+RcGDATHtndnh4dZOviXgHYGyA=="><picture><source type="image/avif" srcset="/homemove-mobile-mockup-375.avif 375w, /homemove-mobile-mockup-1125.avif 1125w" sizes="(max-width: 809px) 22vw, (max-width: 1199px) 22vw, 324px"><source type="image/webp" srcset="/homemove-mobile-mockup-375.webp 375w, /homemove-mobile-mockup-1125.webp 1125w" sizes="(max-width: 809px) 22vw, (max-width: 1199px) 22vw, 324px"><img src="/homemove-mobile-mockup-375.png" srcset="/homemove-mobile-mockup-375.png 375w, /homemove-mobile-mockup.png 1125w" sizes="(max-width: 809px) 22vw, (max-width: 1199px) 22vw, 324px" alt="Homemove mobile app welcome screen" width="1125" height="2148" loading="lazy" decoding="async"></picture></span>
1159              </div>
1160              <div class="card-blur" aria-hidden="true"></div>
1161            </div>
1162          </div>
1163          <div class="card-fade" aria-hidden="true"></div>
1164          <div class="card-header">
1165              <div class="card-title-row">
1166                <h2 class="card-title">Orchestrated Clarity In Chaos</h2>
1167                <svg class="card-arrow" viewBox="0 0 18 18" fill="currentColor" aria-hidden="true"><path d="M15,9.75H2.75c-.414,0-.75-.336-.75-.75s.336-.75,.75-.75H15c.414,0,.75,.336,.75,.75s-.336,.75-.75,.75Z"/><path d="M11,14c-.192,0-.384-.073-.53-.22-.293-.293-.293-.768,0-1.061l3.72-3.72-3.72-3.72c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l4.25,4.25c.293,.293,.293,.768,0,1.061l-4.25,4.25c-.146,.146-.338,.22-.53,.22Z"/></svg>
1168              </div>
1169              <div class="card-tags">
1170                <div class="card-tag">
1171                  <svg class="tag-logo" xmlns="http://www.w3.org/2000/svg" width="116" height="18" fill="none" viewBox="0 0 116 18" aria-label="Homemove">
1172                    <path d="M 32.118 8.38 C 31.609 8.095 31.026 7.953 30.37 7.953 C 29.713 7.953 29.179 8.093 28.703 8.372 C 28.408 8.545 28.164 8.767 27.973 9.039 L 27.973 4.652 L 25.822 4.652 L 25.822 17.084 L 27.973 17.084 L 27.973 11.845 C 27.973 11.451 28.049 11.112 28.203 10.827 C 28.356 10.542 28.569 10.321 28.843 10.162 C 29.116 10.003 29.428 9.924 29.778 9.924 C 30.129 9.924 30.454 10.003 30.723 10.162 C 30.991 10.32 31.201 10.542 31.355 10.827 C 31.508 11.112 31.585 11.451 31.585 11.845 L 31.585 17.084 L 33.735 17.084 L 33.735 11.336 C 33.735 10.668 33.593 10.08 33.308 9.57 C 33.024 9.061 32.627 8.665 32.118 8.38 Z M 42.358 8.744 C 41.647 8.339 40.853 8.137 39.978 8.137 C 39.102 8.137 38.292 8.34 37.581 8.744 C 36.869 9.149 36.303 9.703 35.882 10.403 C 35.46 11.104 35.249 11.903 35.249 12.8 C 35.249 13.698 35.46 14.498 35.882 15.198 C 36.303 15.899 36.872 16.452 37.589 16.857 C 38.306 17.262 39.102 17.464 39.978 17.464 C 40.854 17.464 41.631 17.262 42.342 16.857 C 43.053 16.451 43.623 15.899 44.05 15.198 C 44.477 14.498 44.69 13.698 44.69 12.8 C 44.69 11.902 44.479 11.09 44.058 10.395 C 43.636 9.7 43.07 9.149 42.359 8.744 Z M 42.214 14.298 C 42.001 14.708 41.711 15.028 41.344 15.258 C 40.977 15.488 40.553 15.603 40.072 15.603 C 39.59 15.603 39.146 15.488 38.775 15.258 C 38.402 15.028 38.11 14.708 37.896 14.298 C 37.683 13.887 37.576 13.425 37.576 12.91 C 37.576 12.395 37.683 11.922 37.896 11.522 C 38.11 11.122 38.402 10.805 38.775 10.57 C 39.147 10.334 39.579 10.216 40.072 10.216 C 40.564 10.216 40.977 10.334 41.344 10.57 C 41.711 10.805 42.001 11.122 42.214 11.522 C 42.427 11.922 42.534 12.384 42.534 12.91 C 42.534 13.435 42.427 13.887 42.214 14.298 Z M 58.186 8.563 C 57.687 8.279 57.121 8.136 56.487 8.136 C 55.775 8.136 55.146 8.314 54.599 8.67 C 54.246 8.899 53.951 9.203 53.712 9.579 C 53.517 9.242 53.243 8.953 52.891 8.711 C 52.333 8.328 51.687 8.136 50.954 8.136 C 50.287 8.136 49.707 8.295 49.214 8.613 C 48.892 8.82 48.64 9.099 48.459 9.447 L 48.459 8.333 L 46.439 8.333 L 46.439 17.267 L 48.59 17.267 L 48.59 12.028 C 48.59 11.634 48.661 11.295 48.803 11.01 C 48.946 10.726 49.148 10.504 49.411 10.345 C 49.674 10.187 49.975 10.107 50.314 10.107 C 50.653 10.107 50.968 10.187 51.225 10.345 C 51.482 10.504 51.682 10.726 51.824 11.01 C 51.967 11.295 52.038 11.634 52.038 12.028 L 52.038 17.267 L 54.189 17.267 L 54.189 12.028 C 54.189 11.634 54.259 11.295 54.402 11.01 C 54.544 10.726 54.747 10.504 55.009 10.345 C 55.272 10.187 55.573 10.107 55.912 10.107 C 56.274 10.107 56.58 10.187 56.832 10.345 C 57.083 10.504 57.28 10.726 57.423 11.01 C 57.565 11.295 57.636 11.634 57.636 12.028 L 57.636 17.267 L 59.787 17.267 L 59.787 11.519 C 59.787 10.852 59.645 10.263 59.36 9.754 C 59.075 9.245 58.684 8.848 58.186 8.563 Z M 69.164 9.434 C 68.791 9.029 68.34 8.711 67.809 8.481 C 67.278 8.251 66.663 8.136 65.962 8.136 C 65.13 8.136 64.378 8.339 63.705 8.744 C 63.032 9.149 62.501 9.699 62.112 10.394 C 61.724 11.09 61.529 11.886 61.529 12.784 C 61.529 13.681 61.721 14.437 62.104 15.149 C 62.487 15.86 63.023 16.424 63.713 16.84 C 64.402 17.256 65.207 17.464 66.126 17.464 C 66.739 17.464 67.3 17.371 67.809 17.185 C 68.318 16.999 68.758 16.741 69.13 16.413 C 69.503 16.085 69.776 15.707 69.952 15.28 L 68.211 14.426 C 68.014 14.777 67.743 15.056 67.399 15.264 C 67.054 15.472 66.635 15.576 66.143 15.576 C 65.65 15.576 65.215 15.461 64.837 15.231 C 64.46 15.001 64.175 14.675 63.984 14.254 C 63.866 13.996 63.793 13.714 63.764 13.408 L 70.198 13.408 C 70.241 13.288 70.272 13.145 70.288 12.981 C 70.304 12.817 70.313 
117212.659 70.313 12.505 C 70.313 11.903 70.214 11.342 70.017 10.822 C 69.82 10.302 69.535 9.839 69.164 9.434 Z M 64.86 10.345 C 65.21 10.121 65.609 10.009 66.058 10.009 C 66.507 10.009 66.931 10.124 67.265 10.354 C 67.599 10.584 67.84 10.896 67.987 11.29 C 68.055 11.47 68.096 11.661 68.113 11.864 L 63.906 11.864 C 63.943 11.673 63.995 11.495 64.064 11.331 C 64.244 10.898 64.51 10.57 64.86 10.345 Z M 83.781 8.563 C 83.283 8.279 82.717 8.136 82.082 8.136 C 81.371 8.136 80.741 8.314 80.194 8.67 C 79.842 8.899 79.546 9.203 79.308 9.579 C 79.112 9.242 78.839 8.953 78.487 8.711 C 77.929 8.328 77.283 8.136 76.55 8.136 C 75.882 8.136 75.302 8.295 74.81 8.613 C 74.487 8.82 74.236 9.099 74.054 9.447 L 74.054 8.333 L 72.035 8.333 L 72.035 17.267 L 74.186 17.267 L 74.186 12.028 C 74.186 11.634 74.257 11.295 74.399 11.01 C 74.541 10.726 74.744 10.504 75.007 10.345 C 75.269 10.187 75.57 10.107 75.91 10.107 C 76.249 10.107 76.564 10.187 76.821 10.345 C 77.078 10.504 77.278 10.726 77.42 11.01 C 77.562 11.295 77.633 11.634 77.633 12.028 L 77.633 17.267 L 79.784 17.267 L 79.784 12.028 C 79.784 11.634 79.855 11.295 79.998 11.01 C 80.14 10.726 80.342 10.504 80.605 10.345 C 80.868 10.187 81.169 10.107 81.508 10.107 C 81.869 10.107 82.176 10.187 82.427 10.345 C 82.679 10.504 82.876 10.726 83.019 11.01 C 83.161 11.295 83.232 11.634 83.232 12.028 L 83.232 17.267 L 85.383 17.267 L 85.383 11.519 C 85.383 10.852 85.24 10.263 84.956 9.754 C 84.671 9.245 84.28 8.848 83.782 8.563 Z M 94.234 8.744 C 93.523 8.339 92.729 8.137 91.854 8.137 C 90.978 8.137 90.168 8.34 89.457 8.744 C 88.745 9.149 88.179 9.703 87.758 10.403 C 87.336 11.104 87.125 11.903 87.125 12.8 C 87.125 13.698 87.336 14.498 87.758 15.198 C 88.179 15.899 88.748 16.452 89.465 16.857 C 90.182 17.262 90.978 17.464 91.854 17.464 C 92.73 17.464 93.507 17.262 94.218 16.857 C 94.929 16.451 95.499 15.899 95.926 15.198 C 96.353 14.498 96.566 13.698 96.566 12.8 C 96.566 11.902 96.355 11.09 95.934 10.395 C 95.512 9.7 94.946 9.149 94.235 8.744 Z M 94.09 14.298 C 93.877 14.708 93.586 15.028 93.22 15.258 C 92.853 15.488 92.429 15.603 91.947 15.603 C 91.466 15.603 91.022 15.488 90.651 15.258 C 90.278 15.028 89.986 14.708 89.772 14.298 C 89.559 13.887 89.452 13.425 89.452 12.91 C 89.452 12.395 89.559 11.922 89.772 11.522 C 89.986 11.122 90.278 10.805 90.651 10.57 C 91.023 10.334 91.455 10.216 91.947 10.216 C 92.44 10.216 92.853 10.334 93.22 10.57 C 93.586 10.805 93.876 11.122 94.09 11.522 C 94.303 11.922 94.41 12.384 94.41 12.91 C 94.41 13.435 94.303 13.887 94.09 14.298 Z M 101.747 14.175 L 99.636 8.344 L 97.288 8.344 L 100.802 17.278 L 102.706 17.278 L 106.219 8.344 L 103.872 8.344 Z M 115.426 10.822 C 115.229 10.302 114.944 9.839 114.573 9.434 C 114.2 9.029 113.749 8.711 113.218 8.481 C 112.687 8.251 112.071 8.136 111.371 8.136 C 110.539 8.136 109.787 8.339 109.114 8.744 C 108.441 9.149 107.91 9.699 107.521 10.394 C 107.133 11.09 106.938 11.886 106.938 12.784 C 106.938 13.681 107.13 14.437 107.513 15.149 C 107.896 15.86 108.432 16.424 109.122 16.84 C 109.811 17.256 110.616 17.464 111.535 17.464 C 112.148 17.464 112.709 17.371 113.218 17.185 C 113.727 16.999 114.167 16.741 114.539 16.413 C 114.911 16.085 115.185 15.707 115.36 15.28 L 113.62 14.426 C 113.423 14.777 113.152 15.056 112.807 15.264 C 112.463 15.472 112.044 15.576 111.552 15.576 C 111.059 15.576 110.624 15.461 110.246 15.231 C 109.869 15.001 109.584 14.675 109.393 14.254 C 109.275 13.996 109.202 13.714 109.173 13.408 L 115.607 13.408 C 115.651 13.288 115.681 13.145 115.697 12.981 C 115.713 12.817 115.722 12.659 115.722 12.505 C 115.722 11.903 115.624 11.342 115.426 10.822 Z M 110.269 10.345 C 110.619 10.121 111.018 10.009 111.467 10.009 C 111.916 10.009 112.34 10.124 112.674 10.354 C 113.008 10.584 113.248 10.896 113.396 11.29 C 113.464 11.47 113.505 11.661 113.521 11.864 L 109.315 11.864 C 109.351 11.673 109.404 11.495 109.472 11.331 C 109.653 10.898 109.918 10.57 110.269 10.345 Z M 15.148 2.906 L 9.688 0.307 C 8.885 -0.076 7.963 -0.076 7.16 0.307 L 1.7 2.906 C 0.671 3.396 0.006 4.454 0.006 5.6 L 0.006 15.76
11725 C 0.006 16.338 0.471 16.804 1.045 16.804 C 1.618 16.804 2.083 16.338 2.083 15.765 L 2.083 15.208 C 2.264 14.315 2.864 13.479 3.783 12.847 C 4.229 12.54 4.719 12.303 5.227 12.144 C 5.215 12.283 5.207 12.423 5.206 12.565 C 5.19 13.84 5.78 15.174 6.747 16.046 C 7.465 16.693 8.332 17.037 9.223 17.037 C 9.357 17.037 9.492 17.03 9.627 17.014 C 10.649 16.894 11.488 16.323 11.93 15.446 C 12.4 14.51 12.35 13.377 11.796 12.413 C 11.028 11.076 9.538 10.309 8.301 10.006 C 9.006 9.143 9.986 8.489 10.944 8.197 C 12.037 7.864 13.612 8.018 14.755 9.45 C 14.759 9.454 14.762 9.457 14.765 9.461 L 14.765 15.765 C 14.765 16.338 15.23 16.804 15.803 16.804 C 16.377 16.804 16.842 16.338 16.842 15.765 L 16.842 5.6 C 16.842 4.454 16.177 3.396 15.148 2.906 Z M 10.302 14.11 C 10.504 14.461 10.534 14.869 10.381 15.173 C 10.254 15.426 10.022 15.573 9.692 15.612 C 9.142 15.676 8.71 15.404 8.444 15.164 C 7.916 14.688 7.58 13.937 7.589 13.252 C 7.591 13.031 7.615 12.814 7.658 12.601 C 7.749 12.612 7.839 12.627 7.928 12.644 C 8.786 12.811 9.853 13.328 10.302 14.11 Z M 10.425 6.325 C 8.727 6.843 6.946 8.187 5.992 9.997 C 4.831 10.142 3.679 10.572 2.692 11.25 C 2.512 11.374 2.338 11.506 2.17 11.646 L 2.17 5.715 C 2.17 5.366 2.37 5.045 2.68 4.897 L 8.14 2.298 C 8.376 2.186 8.647 2.186 8.883 2.298 L 14.342 4.897 C 14.652 5.045 14.852 5.366 14.852 5.715 L 14.852 6.864 C 13.524 6.082 11.932 5.865 10.425 6.325 Z" fill="currentColor" />
1173                  </svg>
1174                  <span class="sr-only">Company</span>
1175                </div>
1176                <div class="card-tag">
1177                  <svg class="tag-icon" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 18 18" aria-hidden="true"><path d="M14.25,2H3.75c-1.517,0-2.75,1.233-2.75,2.75V13.25c0,1.517,1.233,2.75,2.75,2.75H14.25c1.517,0,2.75-1.233,2.75-2.75V4.75c0-1.517-1.233-2.75-2.75-2.75ZM4,6c-.552,0-1-.448-1-1s.448-1,1-1,1,.448,1,1-.448,1-1,1Zm3,0c-.552,0-1-.448-1-1s.448-1,1-1,1,.448,1,1-.448,1-1,1Z"/></svg>
1178                  <span class="card-tag-value">Web App</span>
1179                  <span class="sr-only">Project Type</span>
1180                </div>
1181                <div class="card-tag">
1182                  <svg class="tag-icon" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 18 18" aria-hidden="true"><circle cx="12" cy="8" r="1" fill="none"/><path d="M17.53,5.792l-.988-.397h-.002s-1.041-.42-1.041-.42v-1.475c0-.414-.336-.75-.75-.75s-.75,.336-.75,.75v.872L7.03,1.566c-.384-.156-.822,.032-.976,.416-.155,.384,.031,.821,.416,.976l.53,.213v12.829h3.75v-3.5c0-.69,.56-1.25,1.25-1.25s1.25,.56,1.25,1.25v3.5h2c.965,0,1.75-.785,1.75-1.75V7.19c.082,.029,.167,.048,.25,.048,.297,0,.579-.177,.696-.47,.155-.384-.031-.821-.416-.976Zm-5.53,3.208c-.552,0-1-.448-1-1s.448-1,1-1,1,.448,1,1-.448,1-1,1Z"/><path d="M5.5,14.5v-1.75c0-.414-.336-.75-.75-.75s-.75,.336-.75,.75v1.75h-1.25c-.138,0-.25-.112-.25-.25v-5.157l4.5-1.812v-1.617L.47,8.292c-.384,.155-.57,.592-.416,.976,.118,.292,.399,.47,.696,.47,.083,0,.167-.019,.25-.048v4.56c0,.965,.785,1.75,1.75,1.75H7v-1.5h-1.5Z"/></svg>
1183                  <span class="card-tag-value">Proptech</span>
1184                  <span class="sr-only">Industry</span>
1185                </div>
1186              </div>
1187            </div>
1188      </a>
1189      </div>
1190
1191      <!-- Card 3 — Pikl Dashboard -->
1192      <div class="card-wrap">
1193      <div class="card-shadow" data-card="pikl-dashboard" aria-hidden="true">
1194        <div class="card-shadow__layer"></div>
1195      </div>
1196      <a class="card" href="./pikl-dashboard">
1197          <div class="card-loader" data-card="pikl-dashboard" aria-hidden="true"></div>
1198          <div class="card-gradient" id="gradient-pikl-dashboard"></div>
1199          <div class="project-image">
1200            <div class="mockup-group">
1201              <div class="mockup">
1202                <span class="thumb-wrap" data-thumbhash="/QcCBYAyqHlwqIpRpzmGdlDDw/f+"><picture><source type="image/avif" srcset="/pikl-dashboard-mockup-768.avif 768w, /pikl-dashboard-mockup-1280.avif 1280w, /pikl-dashboard-mockup-2880.avif 2880w" sizes="(min-width: 1280px) 1216px, 85vw"><source type="image/webp" srcset="/pikl-dashboard-mockup-768.webp 768w, /pikl-dashboard-mockup-1280.webp 1280w, /pikl-dashboard-mockup-2880.webp 2880w" sizes="(min-width: 1280px) 1216px, 85vw"><img src="/pikl-dashboard-mockup-1280.png" srcset="/pikl-dashboard-mockup-768.png 768w, /pikl-dashboard-mockup-1280.png 1280w, /pikl-dashboard-mockup.png 2880w" sizes="(min-width: 1280px) 1216px, 85vw" alt="Pikl Dashboard — Replaced Guesswork With Guided Workflows" width="2880" height="2048" loading="lazy" decoding="async"></picture></span>
1203              </div>
1204              <div class="card-blur" aria-hidden="true"></div>
1205            </div>
1206          </div>
1207          <div class="card-fade" aria-hidden="true"></div>
1208          <div class="card-header">
1209              <div class="card-title-row">
1210                <h2 class="card-title">Replaced Guesswork With Guided Workflows</h2>
1211                <svg class="card-arrow" viewBox="0 0 18 18" fill="currentColor" aria-hidden="true"><path d="M15,9.75H2.75c-.414,0-.75-.336-.75-.75s.336-.75,.75-.75H15c.414,0,.75,.336,.75,.75s-.336,.75-.75,.75Z"/><path d="M11,14c-.192,0-.384-.073-.53-.22-.293-.293-.293-.768,0-1.061l3.72-3.72-3.72-3.72c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l4.25,4.25c.293,.293,.293,.768,0,1.061l-4.25,4.25c-.146,.146-.338,.22-.53,.22Z"/></svg>
1212              </div>
1213              <div class="card-tags">
1214                <div class="card-tag">
1215                  <svg class="tag-logo" xmlns="http://www.w3.org/2000/svg" width="47" height="20" fill="none" viewBox="0 0 47 20" aria-label="Pikl">
1216                    <path d="M 13.559 3.428 C 13.808 3.208 13.946 2.88 13.946 2.523 L 13.946 1.291 C 13.946 0.606 13.366 0.03 12.677 0.03 L 2.716 0.03 C 2.026 0.03 1.446 0.606 1.446 1.291 L 1.446 2.523 C 1.446 2.88 1.584 3.181 1.833 3.428 L 0.094 6.003 C -0.154 6.359 0.122 6.825 0.536 6.825 L 1.446 6.825 L 1.446 12.989 C 1.446 14.167 2.412 15.126 3.598 15.126 L 11.794 15.099 C 12.98 15.099 13.946 14.14 13.946 12.962 L 13.946 6.797 L 14.856 6.797 C 15.27 6.797 15.519 6.332 15.298 5.976 Z M 2.55 1.455 C 2.55 1.263 2.716 1.099 2.909 1.099 L 12.483 1.099 C 12.677 1.099 12.842 1.263 12.842 1.455 L 12.842 2.332 C 12.842 2.523 12.677 2.688 12.483 2.688 L 2.909 2.688 C 2.716 2.688 2.55 2.523 2.55 2.332 Z M 10.938 10.195 L 10.938 13.975 L 6.937 13.975 L 6.937 11.838 L 5.613 11.838 L 5.613 13.975 L 4.454 13.975 L 4.454 10.195 L 3.461 10.195 L 7.682 6.14 L 11.959 10.195 Z M 9.145 4.77 C 8.013 4.77 7.931 5.373 6.579 5.373 C 5.364 5.373 5.226 4.77 4.095 4.77 C 2.964 4.77 2.108 5.702 1.584 5.756 C 2.026 5.154 2.881 3.839 2.881 3.839 L 12.428 3.839 C 12.428 3.839 13.118 4.852 13.725 5.756 C 11.269 5.893 11.021 4.77 9.145 4.77 Z" fill="currentColor" />
1217                    <path d="M 7.324 10.959 C 7.324 11.397 7.82 11.945 8.731 12.438 C 9.586 12.027 10.138 11.37 10.138 10.959 C 10.138 10.521 9.779 10.164 9.338 10.164 C 9.09 10.164 8.869 10.274 8.731 10.438 C 8.593 10.274 8.372 10.164 8.124 10.164 C 7.655 10.164 7.324 10.521 7.324 10.959 Z" fill="currentColor" />
1218                    <path d="M 23.217 2.877 C 26.639 2.877 29.426 5.644 29.426 9.041 C 29.426 12.438 26.722 15.205 23.217 15.205 C 21.424 15.205 19.906 14.521 19.051 13.562 L 19.051 17.973 C 19.051 19.096 18.14 20 17.009 20 L 17.009 9.041 C 17.009 5.644 19.796 2.877 23.217 2.877 Z M 35.965 0.027 L 35.965 7.342 C 35.965 7.342 35.967 7.34 35.97 7.336 L 35.983 7.323 C 36.183 7.135 38.013 5.627 39.966 3.973 C 41.345 2.817 43.044 2.898 43.722 2.989 L 43.789 2.999 C 43.938 3.021 44.022 3.041 44.022 3.041 L 37.234 8.904 L 44.078 15.068 C 44.078 15.068 41.649 15.644 40.021 14.192 C 39.666 13.877 39.26 13.512 38.846 13.136 L 38.699 13.003 C 38.65 12.958 38.601 12.914 38.552 12.869 L 38.405 12.735 C 37.158 11.599 35.965 10.493 35.965 10.493 L 35.965 15.068 L 33.923 15.068 L 33.923 2.082 C 33.923 0.932 34.834 0.027 35.965 0.027 Z M 32.378 3.041 L 32.378 15.068 L 30.336 15.068 L 30.336 3.041 Z M 46.809 0 L 46.837 13.014 C 46.837 14.137 45.926 15.041 44.823 15.041 L 44.795 2.027 C 44.795 0.904 45.706 0 46.809 0 Z M 23.217 4.932 C 20.927 4.932 19.078 6.767 19.078 9.041 C 19.078 11.315 20.927 13.151 23.217 13.151 C 25.507 13.151 27.356 11.315 27.356 9.041 C 27.356 6.767 25.507 4.932 23.217 4.932 Z M 32.351 0 L 32.351 2.027 L 30.309 2.027 C 30.336 0.904 31.247 0 32.351 0 Z" fill="currentColor" />
1219                  </svg>
1220                  <span class="sr-only">Company</span>
1221                </div>
1222                <div class="card-tag">
1223                  <svg class="tag-icon" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 18 18" aria-hidden="true"><path d="M14.25,2H3.75c-1.517,0-2.75,1.233-2.75,2.75V13.25c0,1.517,1.233,2.75,2.75,2.75H14.25c1.517,0,2.75-1.233,2.75-2.75V4.75c0-1.517-1.233-2.75-2.75-2.75ZM4,6c-.552,0-1-.448-1-1s.448-1,1-1,1,.448,1,1-.448,1-1,1Zm3,0c-.552,0-1-.448-1-1s.448-1,1-1,1,.448,1,1-.448,1-1,1Z"/></svg>
1224                  <span class="card-tag-value">Web App</span>
1225                  <span class="sr-only">Project Type</span>
1226                </div>
1227                <div class="card-tag">
1228                  <svg class="tag-icon" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 18 18" aria-hidden="true"><path d="M14.783,2.813l-5.25-1.68c-.349-.112-.718-.111-1.066,0L3.216,2.813c-.728,.233-1.216,.903-1.216,1.667v6.52c0,3.508,4.946,5.379,6.46,5.869,.177,.057,.358,.086,.54,.086s.362-.028,.538-.085c1.516-.49,6.462-2.361,6.462-5.869V4.48c0-.764-.489-1.434-1.217-1.667Z"/></svg>
1229                  <span class="card-tag-value">Insurtech</span>
1230                  <span class="sr-only">Industry</span>
1231                </div>
1232              </div>
1233            </div>
1234      </a>
1235      </div>
1236
1237      <!-- Card 4 — Wren Kitchens -->
1238      <div class="card-wrap">
1239      <div class="card-shadow" data-card="wren" aria-hidden="true">
1240        <div class="card-shadow__layer"></div>
1241      </div>
1242      <a class="card" href="./wren-kitchens">
1243          <div class="card-loader" data-card="wren" aria-hidden="true"></div>
1244          <div class="card-gradient" id="gradient-wren"></div>
1245          <div class="project-image">
1246            <div class="mockup-group">
1247              <div class="mockup mockup--flush">
1248                <span class="thumb-wrap" data-thumbhash="7/cJDICvYzWIiHZspoZvevWV5g=="><picture><source type="image/avif" srcset="/wren-kitchens-mockup-768.avif 768w, /wren-kitchens-mockup-1280.avif 1280w, /wren-kitchens-mockup-2880.avif 2880w" sizes="(min-width: 1280px) 1216px, 85vw"><source type="image/webp" srcset="/wren-kitchens-mockup-768.webp 768w, /wren-kitchens-mockup-1280.webp 1280w, /wren-kitchens-mockup-2880.webp 2880w" sizes="(min-width: 1280px) 1216px, 85vw"><img src="/wren-kitchens-mockup-1280.png" srcset="/wren-kitchens-mockup-768.png 768w, /wren-kitchens-mockup-1280.png 1280w, /wren-kitchens-mockup.png 2880w" sizes="(min-width: 1280px) 1216px, 85vw" alt="Wren Kitchens — Accelerated Time-On-Task By 58%" width="2880" height="1620" loading="lazy" decoding="async"></picture></span>
1249              </div>
1250              <div class="card-blur" aria-hidden="true"></div>
1251            </div>
1252          </div>
1253          <div class="card-fade" aria-hidden="true"></div>
1254          <div class="card-header">
1255              <div class="card-title-row">
1256                <h2 class="card-title">Accelerated Time-On-Task By 58%</h2>
1257                <svg class="card-arrow" viewBox="0 0 18 18" fill="currentColor" aria-hidden="true"><path d="M15,9.75H2.75c-.414,0-.75-.336-.75-.75s.336-.75,.75-.75H15c.414,0,.75,.336,.75,.75s-.336,.75-.75,.75Z"/><path d="M11,14c-.192,0-.384-.073-.53-.22-.293-.293-.293-.768,0-1.061l3.72-3.72-3.72-3.72c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l4.25,4.25c.293,.293,.293,.768,0,1.061l-4.25,4.25c-.146,.146-.338,.22-.53,.22Z"/></svg>
1258              </div>
1259              <div class="card-tags">
1260                <div class="card-tag">
1261                  <svg class="tag-logo" xmlns="http://www.w3.org/2000/svg" width="60" height="20" fill="none" viewBox="0 0 60 20" aria-label="Wren Kitchens">
1262                    <path d="M 27.674 1.911 L 27.537 0.664 L 22.192 0.664 L 22.192 12.038 L 25.337 12.038 C 27.674 12.038 27.674 9.708 27.674 9.708 L 27.674 6.073 C 27.673 5.962 27.682 5.851 27.703 5.741 C 27.804 5.15 28.201 4.652 29.081 4.652 C 30.054 4.652 30.733 4.811 31.367 4.97 L 31.367 0.844 C 30.848 0.57 30.372 0.527 29.917 0.527 C 29.218 0.534 28.467 0.894 27.674 1.911 Z" fill="currentColor" />
1263                    <path d="M 14.952 8.049 L 14.909 8.049 L 13.639 0.664 L 7.566 0.664 L 6.21 8.05 L 6.167 8.05 L 5.258 0.664 L 0 0.664 L 2.402 12.038 L 7.227 12.038 C 8.893 12.031 9.377 10.653 9.55 9.708 C 9.723 8.764 10.473 4.789 10.473 4.789 L 10.517 4.789 L 11.873 12.038 L 16.828 12.038 C 18.35 12.038 18.956 10.48 19.158 9.708 C 19.215 9.492 21.394 0.649 21.394 0.649 L 16.135 0.649 Z" fill="currentColor" />
1264                    <path d="M 38.987 0 C 34.912 0 32.258 2.041 32.258 5.958 C 32.258 10.552 35.244 12.053 38.576 12.053 C 42.839 12.053 44.808 10.646 45.212 8.064 L 40.408 8.064 C 40.249 8.879 39.658 9.24 39.052 9.24 C 37.963 9.24 37.508 8.677 37.508 7.357 L 37.508 7.018 L 45.349 7.018 L 45.349 6.246 C 45.349 2.193 43.293 0 38.987 0 Z M 40.415 4.616 L 37.516 4.616 L 37.516 4.458 C 37.516 3.368 38.107 2.806 38.987 2.806 C 39.845 2.806 40.415 3.375 40.415 4.458 Z" fill="currentColor" />
1265                    <path d="M 55.316 0.325 C 54.025 0.325 52.669 0.757 51.738 2.387 L 51.695 2.387 L 51.695 0.664 L 46.213 0.664 L 46.213 12.038 L 49.365 12.038 C 51.695 12.038 51.695 9.708 51.695 9.708 L 51.695 4.948 C 51.695 4.039 52.171 3.679 52.784 3.679 C 53.441 3.679 53.779 4.155 53.779 4.991 L 53.779 12.038 L 56.931 12.038 C 59.261 12.038 59.261 9.708 59.261 9.708 L 59.261 4.472 C 59.261 1.457 57.61 0.325 55.316 0.325 Z" fill="currentColor" />
1266                    <path d="M 10.754 14.943 L 11.649 14.943 L 9.478 17.316 L 12.183 19.942 L 11.202 19.942 L 8.648 17.475 L 8.648 19.942 L 7.934 19.942 L 7.934 14.943 L 8.648 14.943 L 8.648 17.23 Z" fill="currentColor" />
1267                    <path d="M 14.103 14.943 L 14.817 14.943 L 14.817 19.942 L 14.103 19.942 Z" fill="currentColor" />
1268                    <path d="M 16.939 14.936 L 21.123 14.936 L 21.123 15.571 L 19.377 15.571 L 19.377 19.942 L 18.656 19.942 L 18.656 15.571 L 16.939 15.571 Z" fill="currentColor" />
1269                    <path d="M 27.525 18.781 L 27.525 19.567 C 26.998 19.855 26.392 20 25.707 20 C 25.152 20 24.675 19.884 24.286 19.661 C 23.899 19.441 23.58 19.119 23.363 18.73 C 23.14 18.342 23.023 17.901 23.024 17.454 C 23.017 17.109 23.083 16.768 23.217 16.451 C 23.351 16.134 23.551 15.849 23.803 15.614 C 24.322 15.117 24.964 14.871 25.721 14.871 C 26.248 14.871 26.832 15.009 27.474 15.283 L 27.474 16.054 C 26.89 15.722 26.32 15.556 25.757 15.556 C 25.188 15.556 24.712 15.737 24.329 16.097 C 24.145 16.271 23.999 16.481 23.901 16.715 C 23.803 16.949 23.755 17.2 23.76 17.453 C 23.76 18.002 23.947 18.456 24.315 18.809 C 24.69 19.163 25.166 19.336 25.743 19.336 C 26.356 19.343 26.948 19.156 27.525 18.781" fill="currentColor" />
1270                    <path d="M 33.346 14.943 L 34.06 14.943 L 34.06 19.942 L 33.346 19.942 L 33.346 17.764 L 30.54 17.764 L 30.54 19.942 L 29.819 19.942 L 29.819 14.943 L 30.54 14.943 L 30.54 17.1 L 33.346 17.1 Z" fill="currentColor" />
1271                    <path d="M 36.584 14.943 L 39.419 14.943 L 39.419 15.578 L 37.306 15.578 L 37.306 17.1 L 39.347 17.1 L 39.347 17.742 L 37.306 17.742 L 37.306 19.3 L 39.491 19.3 L 39.491 19.935 L 36.584 19.935 Z" fill="currentColor" />
1272                    <path d="M 45.64 14.943 L 46.325 14.943 L 46.325 19.942 L 45.705 19.942 L 42.365 16.09 L 42.365 19.942 L 41.694 19.942 L 41.694 14.943 L 42.271 14.943 L 45.64 18.824 Z" fill="currentColor" />
1273                    <path d="M 50 17.843 L 49.459 17.511 C 49.12 17.303 48.875 17.1 48.731 16.898 C 48.585 16.694 48.509 16.449 48.514 16.199 C 48.514 15.802 48.651 15.485 48.926 15.239 C 49.2 14.994 49.553 14.872 49.993 14.872 C 50.403 14.868 50.803 14.991 51.14 15.225 L 51.14 16.04 C 50.779 15.694 50.39 15.521 49.979 15.521 C 49.774 15.514 49.572 15.572 49.402 15.687 C 49.332 15.731 49.275 15.793 49.235 15.867 C 49.196 15.94 49.176 16.022 49.178 16.105 C 49.181 16.26 49.24 16.408 49.344 16.523 C 49.498 16.689 49.678 16.828 49.878 16.935 L 50.426 17.259 C 51.039 17.627 51.342 18.088 51.342 18.651 C 51.342 19.055 51.205 19.38 50.938 19.625 C 50.671 19.877 50.318 20 49.892 20 C 49.405 20.002 48.93 19.842 48.543 19.546 L 48.543 18.637 C 48.933 19.127 49.38 19.372 49.885 19.372 C 50.089 19.378 50.288 19.312 50.448 19.185 C 50.519 19.13 50.576 19.058 50.615 18.977 C 50.654 18.896 50.673 18.806 50.671 18.716 C 50.657 18.406 50.44 18.117 50 17.843" fill="currentColor" />
1274                  </svg>
1275                  <span class="sr-only">Company</span>
1276                </div>
1277                <div class="card-tag">
1278                  <svg class="tag-icon" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 18 18" aria-hidden="true"><path d="M14.25,2H3.75c-1.517,0-2.75,1.233-2.75,2.75V13.25c0,1.517,1.233,2.75,2.75,2.75H14.25c1.517,0,2.75-1.233,2.75-2.75V4.75c0-1.517-1.233-2.75-2.75-2.75Zm-7.25,2c.552,0,1,.448,1,1s-.448,1-1,1-1-.448-1-1,.448-1,1-1Zm-4,1c0-.552,.448-1,1-1s1,.448,1,1-.448,1-1,1-1-.448-1-1Zm4.78,6.72c.293,.293,.293,.768,0,1.061-.146,.146-.338,.22-.53,.22s-.384-.073-.53-.22l-2.25-2.25c-.293-.293-.293-.768,0-1.061l2.25-2.25c.293-.293,.768-.293,1.061,0,.293,.293,.293,.768,0,1.061l-1.72,1.72,1.72,1.72h0Zm5.75-1.19l-2.25,2.25c-.146,.146-.338,.22-.53,.22s-.384-.073-.53-.22c-.293-.293-.293-.768,0-1.061l1.72-1.72-1.72-1.72c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l2.25,2.25c.293,.293,.293,.768,0,1.061h-.001Z"/></svg>
1279                  <span class="card-tag-value">Desktop App</span>
1280                  <span class="sr-only">Project Type</span>
1281                </div>
1282                <div class="card-tag">
1283                  <svg class="tag-icon" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 18 18" aria-hidden="true"><path d="M5.75,16.5c-.414,0-.75-.336-.75-.75v-3.155l-2.238-1.92c-.172-.147-.268-.364-.262-.589,.05-1.874,.426-3.693,1.117-5.407,.401-.996,.909-1.954,1.511-2.847,.184-.274,.527-.395,.841-.298,.315,.096,.531,.387,.531,.717V15.75c0,.414-.336,.75-.75,.75Z"/><path d="M11.75,6.5c-.414,0-.75-.336-.75-.75V2.25c0-.414,.336-.75,.75-.75s.75,.336,.75,.75v3.5c0,.414-.336,.75-.75,.75Z"/><path d="M15.144,5.973l-.145-3.752c-.016-.414-.316-.737-.778-.721-.413,.016-.736,.364-.72,.778l.145,3.752c.02,.52-.168,1.013-.529,1.388-.721,.75-2.012,.75-2.732,0-.361-.375-.549-.868-.529-1.388l.145-3.7
128352c.017-.414-.307-.762-.72-.778-.46-.035-.763,.306-.778,.721l-.145,3.752c-.036,.931,.3,1.814,.946,2.485,.469,.487,1.056,.809,1.697,.953v6.339c0,.414,.336,.75,.75,.75s.75-.336,.75-.75v-6.339c.641-.144,1.229-.466,1.697-.953,.646-.671,.982-1.554,.946-2.485Z"/></svg>
1284                  <span class="card-tag-value">Kitchen Retail</span>
1285                  <span class="sr-only">Industry</span>
1286                </div>
1287              </div>
1288            </div>
1289      </a>
1290      </div>
1291
1292      <!-- Card 5 — Pikl Design System -->
1293      <div class="card-wrap">
1294      <div class="card-shadow" data-card="pikl-ds" aria-hidden="true">
1295        <div class="card-shadow__layer"></div>
1296      </div>
1297      <a class="card" href="./pikl-design-system">
1298          <div class="card-loader" data-card="pikl-ds" aria-hidden="true"></div>
1299          <div class="card-gradient" id="gradient-pikl-ds"></div>
1300          <div class="project-image">
1301            <div class="mockup-group">
1302              <div class="mockup mockup--flush">
1303                <span class="thumb-wrap" data-thumbhash="/QeCA4AP5vp3hpiIwPZ4GYeHf4hw9wg="><picture><source type="image/avif" srcset="/pikl-design-system-mockup-768.avif 768w, /pikl-design-system-mockup-1280.avif 1280w, /pikl-design-system-mockup-2880.avif 2880w" sizes="(min-width: 1280px) 1216px, 85vw"><source type="image/webp" srcset="/pikl-design-system-mockup-768.webp 768w, /pikl-design-system-mockup-1280.webp 1280w, /pikl-design-system-mockup-2880.webp 2880w" sizes="(min-width: 1280px) 1216px, 85vw"><img src="/pikl-design-system-mockup-1280.png" srcset="/pikl-design-system-mockup-768.png 768w, /pikl-design-system-mockup-1280.png 1280w, /pikl-design-system-mockup.png 2880w" sizes="(min-width: 1280px) 1216px, 85vw" alt="Pikl Design System — Cut Design &amp; Dev Time By 25%" width="2880" height="1734" loading="lazy" decoding="async"></picture></span>
1304              </div>
1305              <div class="card-blur" aria-hidden="true"></div>
1306            </div>
1307          </div>
1308          <div class="card-fade" aria-hidden="true"></div>
1309          <div class="card-header">
1310              <div class="card-title-row">
1311                <h2 class="card-title">Cut Design &amp; Dev Time By 25%</h2>
1312                <svg class="card-arrow" viewBox="0 0 18 18" fill="currentColor" aria-hidden="true"><path d="M15,9.75H2.75c-.414,0-.75-.336-.75-.75s.336-.75,.75-.75H15c.414,0,.75,.336,.75,.75s-.336,.75-.75,.75Z"/><path d="M11,14c-.192,0-.384-.073-.53-.22-.293-.293-.293-.768,0-1.061l3.72-3.72-3.72-3.72c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l4.25,4.25c.293,.293,.293,.768,0,1.061l-4.25,4.25c-.146,.146-.338,.22-.53,.22Z"/></svg>
1313              </div>
1314              <div class="card-tags">
1315                <div class="card-tag">
1316                  <svg class="tag-logo" xmlns="http://www.w3.org/2000/svg" width="47" height="20" fill="none" viewBox="0 0 47 20" aria-label="Pikl">
1317                    <path d="M 13.559 3.428 C 13.808 3.208 13.946 2.88 13.946 2.523 L 13.946 1.291 C 13.946 0.606 13.366 0.03 12.677 0.03 L 2.716 0.03 C 2.026 0.03 1.446 0.606 1.446 1.291 L 1.446 2.523 C 1.446 2.88 1.584 3.181 1.833 3.428 L 0.094 6.003 C -0.154 6.359 0.122 6.825 0.536 6.825 L 1.446 6.825 L 1.446 12.989 C 1.446 14.167 2.412 15.126 3.598 15.126 L 11.794 15.099 C 12.98 15.099 13.946 14.14 13.946 12.962 L 13.946 6.797 L 14.856 6.797 C 15.27 6.797 15.519 6.332 15.298 5.976 Z M 2.55 1.455 C 2.55 1.263 2.716 1.099 2.909 1.099 L 12.483 1.099 C 12.677 1.099 12.842 1.263 12.842 1.455 L 12.842 2.332 C 12.842 2.523 12.677 2.688 12.483 2.688 L 2.909 2.688 C 2.716 2.688 2.55 2.523 2.55 2.332 Z M 10.938 10.195 L 10.938 13.975 L 6.937 13.975 L 6.937 11.838 L 5.613 11.838 L 5.613 13.975 L 4.454 13.975 L 4.454 10.195 L 3.461 10.195 L 7.682 6.14 L 11.959 10.195 Z M 9.145 4.77 C 8.013 4.77 7.931 5.373 6.579 5.373 C 5.364 5.373 5.226 4.77 4.095 4.77 C 2.964 4.77 2.108 5.702 1.584 5.756 C 2.026 5.154 2.881 3.839 2.881 3.839 L 12.428 3.839 C 12.428 3.839 13.118 4.852 13.725 5.756 C 11.269 5.893 11.021 4.77 9.145 4.77 Z" fill="currentColor" />
1318                    <path d="M 7.324 10.959 C 7.324 11.397 7.82 11.945 8.731 12.438 C 9.586 12.027 10.138 11.37 10.138 10.959 C 10.138 10.521 9.779 10.164 9.338 10.164 C 9.09 10.164 8.869 10.274 8.731 10.438 C 8.593 10.274 8.372 10.164 8.124 10.164 C 7.655 10.164 7.324 10.521 7.324 10.959 Z" fill="currentColor" />
1319                    <path d="M 23.217 2.877 C 26.639 2.877 29.426 5.644 29.426 9.041 C 29.426 12.438 26.722 15.205 23.217 15.205 C 21.424 15.205 19.906 14.521 19.051 13.562 L 19.051 17.973 C 19.051 19.096 18.14 20 17.009 20 L 17.009 9.041 C 17.009 5.644 19.796 2.877 23.217 2.877 Z M 35.965 0.027 L 35.965 7.342 C 35.965 7.342 35.967 7.34 35.97 7.336 L 35.983 7.323 C 36.183 7.135 38.013 5.627 39.966 3.973 C 41.345 2.817 43.044 2.898 43.722 2.989 L 43.789 2.999 C 43.938 3.021 44.022 3.041 44.022 3.041 L 37.234 8.904 L 44.078 15.068 C 44.078 15.068 41.649 15.644 40.021 14.192 C 39.666 13.877 39.26 13.512 38.846 13.136 L 38.699 13.003 C 38.65 12.958 38.601 12.914 38.552 12.869 L 38.405 12.735 C 37.158 11.599 35.965 10.493 35.965 10.493 L 35.965 15.068 L 33.923 15.068 L 33.923 2.082 C 33.923 0.932 34.834 0.027 35.965 0.027 Z M 32.378 3.041 L 32.378 15.068 L 30.336 15.068 L 30.336 3.041 Z M 46.809 0 L 46.837 13.014 C 46.837 14.137 45.926 15.041 44.823 15.041 L 44.795 2.027 C 44.795 0.904 45.706 0 46.809 0 Z M 23.217 4.932 C 20.927 4.932 19.078 6.767 19.078 9.041 C 19.078 11.315 20.927 13.151 23.217 13.151 C 25.507 13.151 27.356 11.315 27.356 9.041 C 27.356 6.767 25.507 4.932 23.217 4.932 Z M 32.351 0 L 32.351 2.027 L 30.309 2.027 C 30.336 0.904 31.247 0 32.351 0 Z" fill="currentColor" />
1320                  </svg>
1321                  <span class="sr-only">Company</span>
1322                </div>
1323                <div class="card-tag">
1324                  <svg class="tag-icon" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 18 18" aria-hidden="true"><path d="M15.94 7.44101L14.878 7.27802C14.754 6.85402 14.586 6.447 14.373 6.06L15.009 5.19299C15.377 4.69199 15.325 4.00801 14.885 3.57001L14.429 3.11401C13.991 2.67501 13.308 2.62299 12.805 2.98999L11.938 3.62701C11.551 3.41401 11.144 3.24601 10.721 3.12201L10.557 2.05899C10.462 1.44499 9.943 0.998993 9.322 0.998993H8.677C8.056 0.998993 7.536 1.445 7.442 2.06L7.279 3.12201C6.855 3.24601 6.448 3.41401 6.061 3.62701L5.194 2.991C4.692 2.624 4.009 2.676 3.571 3.116L3.115 3.57199C2.676 4.01099 2.624 4.69401 2.991 5.19601L3.628 6.06299C3.416 6.44999 3.247 6.857 3.123 7.28L2.06 7.444C1.446 7.539 1 8.05799 1 8.67899V9.32401C1 9.94501 1.446 10.465 2.061 10.559L3.123 10.722C3.247 11.146 3.415 11.553 3.628 11.94L2.992 12.807C2.625 13.308 2.677 13.991 3.117 14.43L3.573 14.886C4.012 15.326 4.696 15.378 5.197 15.01L6.064 14.373C6.451 14.585 6.858 14.754 7.281 14.878L7.445 15.941C7.54 16.555 8.059 17.001 8.68 
132417.001H9.325C9.946 17.001 10.466 16.555 10.56 15.94L10.723 14.878C11.147 14.754 11.554 14.586 11.941 14.373L12.808 15.009C13.309 15.377 13.992 15.324 14.431 14.885L14.887 14.429C15.326 13.99 15.378 13.307 15.011 12.805L14.374 11.938C14.587 11.551 14.755 11.144 14.879 10.721L15.942 10.557C16.556 10.462 17.002 9.94299 17.002 9.32199V8.677C17.002 8.056 16.556 7.53599 15.942 7.44199L15.94 7.44101ZM9 11.5C7.6189 11.5 6.5 10.3811 6.5 9C6.5 7.6189 7.6189 6.5 9 6.5C10.3811 6.5 11.5 7.6189 11.5 9C11.5 10.3811 10.3811 11.5 9 11.5Z"/></svg>
1325                  <span class="card-tag-value">Design System</span>
1326                  <span class="sr-only">Project Type</span>
1327                </div>
1328                <div class="card-tag">
1329                  <svg class="tag-icon" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 18 18" aria-hidden="true"><path d="M14.783,2.813l-5.25-1.68c-.349-.112-.718-.111-1.066,0L3.216,2.813c-.728,.233-1.216,.903-1.216,1.667v6.52c0,3.508,4.946,5.379,6.46,5.869,.177,.057,.358,.086,.54,.086s.362-.028,.538-.085c1.516-.49,6.462-2.361,6.462-5.869V4.48c0-.764-.489-1.434-1.217-1.667Z"/></svg>
1330                  <span class="card-tag-value">Insurtech</span>
1331                  <span class="sr-only">Industry</span>
1332                </div>
1333              </div>
1334            </div>
1335      </a>
1336      </div>
1337
1338    </main>
1339
1340    <!-- ========== FOOTER ========== -->
1341    <!-- Copyright fills the remaining width so the theme switcher sits
1342         flush against the right edge, sharing the footer's padding-inline. -->
1343    <footer class="footer">
1344      <p class="copyright">&copy; 2026 John Miller</p>
1345      <aside class="theme-switcher" aria-label="Theme">
1346        <div class="segmented-control" role="radiogroup" aria-label="Theme">
1347          <span class="segmented-control__pill" aria-hidden="true"></span>
1348          <button type="button" class="segmented-control__btn" role="radio" aria-checked="true" data-theme-value="auto" aria-label="Auto">
1349            <svg viewBox="0 0 18 18" fill="currentColor" aria-hidden="true">
1350              <path d="M9.75 12.75C9.75 12.3358 9.41421 12 9 12C8.58579 12 8.25 12.3358 8.25 12.75V14.5244C7.12269 14.5976 6.19231 14.8241 5.52446 15.0347C5.12941 15.1593 4.91014 15.5805 5.0347 15.9755C5.15926 16.3706 5.58048 16.5898 5.97552 16.4653C6.68077 16.2429 7.72315 16 8.99999 16C9.73958 16 10.8063 16.0819 12.0248 16.4654C12.4199 16.5898 12.841 16.3703 12.9654 15.9752C13.0897 15.5801 12.8703 15.159 12.4752 15.0346C11.446 14.7107 10.511 14.5741 9.75 14.5244V12.75Z"/>
1351              <path fill-rule="evenodd" clip-rule="evenodd" d="M1 4.75C1 3.23122 2.23122 2 3.75 2H14.25C15.7688 2 17 3.23122 17 4.75V10.75C17 12.2688 15.7688 13.5 14.25 13.5H3.75C2.23122 13.5 1 12.2688 1 10.75V4.75ZM3.75 3.5C3.05964 3.5 2.5 4.05964 2.5 4.75V10.75C2.5 11.4404 3.05964 12 3.75 12H14.25C14.9404 12 15.5 11.4404 15.5 10.75V4.75C15.5 4.05964 14.9404 3.5 14.25 3.5H3.75Z"/>
1352            </svg>
1353            <span class="segmented-control__label">Auto</span>
1354          </button>
1355          <button type="button" class="segmented-control__btn" role="radio" aria-checked="false" data-theme-value="light" aria-label="Light">
1356            <svg viewBox="0 0 18 18" fill="currentColor" aria-hidden="true">
1357              <path d="M9,3c.414,0,.75-.336,.75-.75V1.25c0-.414-.336-.75-.75-.75s-.75,.336-.75,.75v1c0,.414,.336,.75,.75,.75Z"/>
1358              <path d="M13.773,4.977c.192,0,.384-.073,.53-.22l.707-.707c.293-.293,.293-.768,0-1.061s-.768-.293-1.061,0l-.707,.707c-.293,.293-.293,.768,0,1.061,.146,.146,.338,.22,.53,.22Z"/>
1359              <path d="M16.75,8.25h-1c-.414,0-.75,.336-.75,.75s.336,.75,.75,.75h1c.414,0,.75-.336,.75-.75s-.336-.75-.75-.75Z"/>
1360              <path d="M14.303,13.243c-.293-.293-.768-.293-1.061,0s-.293,.768,0,1.061l.707,.707c.146,.146,.338,.22,.53,.22s.384-.073,.53-.22c.293-.293,.293-.768,0-1.061l-.707-.707Z"/>
1361              <path d="M9,15c-.414,0-.75,.336-.75,.75v1c0,.414,.336,.75,.75,.75s.75-.336,.75-.75v-1c0-.414-.336-.75-.75-.75Z"/>
1362              <path d="M3.697,13.243l-.707,.707c-.293,.293-.293,.768,0,1.061,.146,.146,.338,.22,.53,.22s.384-.073,.53-.22l.707-.707c.293-.293,.293-.768,0-1.061s-.768-.293-1.061,0Z"/>
1363              <path d="M3,9c0-.414-.336-.75-.75-.75H1.25c-.414,0-.75,.336-.75,.75s.336,.75,.75,.75h1c.414,0,.75-.336,.75-.75Z"/>
1364              <path d="M3.697,4.757c.146,.146,.338,.22,.53,.22s.384-.073,.53-.22c.293-.293,.293-.768,0-1.061l-.707-.707c-.293-.293-.768-.293-1.061,0s-.293,.768,0,1.061l.707,.707Z"/>
1365              <circle cx="9" cy="9" r="5"/>
1366            </svg>
1367            <span class="segmented-control__label">Light</span>
1368          </button>
1369          <button type="button" class="segmented-control__btn" role="radio" aria-checked="false" data-theme-value="dark" aria-label="Dark">
1370            <svg viewBox="0 0 18 18" fill="currentColor" aria-hidden="true">
1371              <path d="M16.705,10.223c-.246-.183-.579-.197-.838-.037-.868,.532-1.859,.813-2.867,.813-3.033,0-5.5-2.467-5.5-5.5,0-1.146,.354-2.247,1.023-3.186,.177-.249,.186-.581,.021-.839-.164-.258-.467-.386-.77-.334C3.994,1.847,1.25,5.152,1.25,9c0,4.411,3.589,8,8,8,3.638,0,6.819-2.461,7.735-5.986,.077-.296-.034-.609-.28-.791Z"/>
1372            </svg>
1373            <span class="segmented-control__label">Dark</span>
1374          </button>
1375        </div>
1376      </aside>
1377    </footer>
1378
1379  </div>
1380
1381  <!-- About-me expandable card (Phase 1 shell; inner content filled by later phases) -->
1382  <div class="about-overlay" id="about-overlay" style="visibility: hidden">
1383    <div class="about-backdrop" id="about-backdrop"></div>
1384    <div class="about-card" id="about-card" role="dialog" aria-modal="true"
1385         aria-labelledby="about-card-title" tabindex="-1">
1386      <!-- Padding lives on this inner body, NOT the card box. The card morphs by
1387           animating its real height down to the pill's (56px); border-box padding
1388           on the card itself can't compress below the padding sum (64px), so it
1389           would settle too tall and snap on exit. With padding here, the card box
1390           shrinks freely while this padded body is simply clipped. Matches Paper,
1391           where the card is padding:0 and the sections hold the inset. -->
1392      <div class="about-card__body">
1393        <!-- Pinned close — lifted out of the (now scrolling) header so it stays
1394             reachable in the card's top-right while the header + banner scroll away. -->
1395        <button type="button" class="about-card__collapse" id="about-collapse" aria-label="Close About me">
1396          <span class="about-card__ci" aria-hidden="true">
1397            <!-- Expand icon (hidden at rest; swapped in at start of open morph) — Nucleo "arrows-expand-diagonal" -->
1398            <svg class="about-card__ci--ex" viewBox="0 0 18 18" fill="currentColor" aria-hidden="true">
1399              <path d="M15.25,2h-4.5c-.414,0-.75,.336-.75,.75s.336,.75,.75,.75h2.689l-3.22,3.22c-.293,.293-.293,.768,0,1.061,.146,.146,.338,.22,.53,.22s.384-.073,.53-.22l3.22-3.22v2.689c0,.414,.336,.75,.75,.75s.75-.336,.75-.75V2.75c0-.414-.336-.75-.75-.75Z"/>
1400              <path d="M7.78,10.22c-.293-.293-.768-.293-1.061,0l-3.22,3.22v-2.689c0-.414-.336-.75-.75-.75s-.75,.336-.75,.75v4.5c0,.414,.336,.75,.75,.75H7.25c.414,0,.75-.336,.75-.75s-.336-.75-.75-.75h-2.689l3.22-3.22c.293-.293,.293-.768,0-1.061Z"/>
1401            </svg>
1402            <!-- Collapse icon (visible at rest) — Nucleo "arrows-reduce-diagonal" -->
1403            <svg class="about-card__ci--cl" viewBox="0 0 18 18" fill="currentColor" aria-hidden="true">
1404              <path d="M15.25,6.5h-2.689l3.22-3.22c.293-.293,.293-.768,0-1.061s-.768-.293-1.061,0l-3.22,3.22V2.75c0-.414-.336-.75-.75-.75s-.75,.336-.75,.75V7.25c0,.414,.336,.75,.75,.75h4.5c.414,0,.75-.336,.75-.75s-.336-.75-.75-.75Z"/>
1405              <path d="M7.25,10H2.75c-.414,0-.75,.336-.75,.75s.336,.75,.75,.75h2.689l-3.22,3.22c-.293,.293-.293,.768,0,1.061,.146,.146,.338,.22,.53,.22s.384-.073,.53-.22l3.22-3.22v2.689c0,.414,.336,.75,.75,.75s.75-.336,.75-.75v-4.5c0-.414-.336-.75-.75-.75Z"/>
1406            </svg>
1407          </span>
1408        </button>
1409        <!-- Single scroll region: header + banner scroll away, the interests bar
1410             sticks to the top, fun fact + rows follow underneath. -->
1411        <div class="about-card__scroll" id="about-card-scroll">
1412        <div class="about-card__group about-card__group--intro">
1413        <div class="about-card__header">
1414          <span class="about-card__avatar-slot" id="about-avatar-slot" aria-hidden="true"></span>
1415          <p class="about-card__title" id="about-card-title">
1416            <span id="about-card-verb">John is</span>
1417            <svg class="about-card__hp-icon" xmlns="http://www.w3.org/2000/svg"
1418                 viewBox="0 0 18 18" aria-hidden="true">
1419              <path d="m15.75,13.5c-.4141,0-.75-.3359-.75-.75v-3.75c0-3.309-2.691-6-6-6s-6,2.691-6,6v3.75c0,.4141-.3359.75-.75.75s-.75-.3359-.75-.75v-3.75C1.5,4.864,4.864,1.5,9,1.5s7.5,3.364,7.5,7.5v3.75c0,.4141-.3359.75-.75.75Z"/>
1420              <path d="m5.25,9c-2.068,0-3.75,1.682-3.75,3.75s1.682,3.75,3.75,3.75c.9648,0,1.75-.7852,1.75-1.75v-4c0-.9648-.7852-1.75-1.75-1.75Z"/>
1421              <path d="m12.75,9c-.9648,0-1.75.785-1.75,1.75v4c0,.9648.785,1.75,1.75,1.75,2.068,0,3.75-1.682,3.75-3.75s-1.682-3.75-3.75-3.75Z"/>
1422            </svg>
1423            <span class="about-card__title-text">listening to&hellip;</span>
1424          </p>
1425        </div>
1426        <div class="about-card__banner" id="about-banner">
1427          <div class="about-card__banner-clip" aria-hidden="true">
1428            <div class="about-card__banner-bg" id="about-banner-bg" aria-hidden="true"></div>
1429            <div class="about-card__banner-tint" id="about-banner-tint" aria-hidden="true"></div>
1430          </div>
1431          <button type="button" class="about-card__banner-play" id="about-banner-play" aria-label="Play preview">
1432            <span class="about-card__bp-icon" aria-hidden="true">
1433              <!-- Play icon (visible at rest) — Nucleo "media-play" -->
1434              <svg class="about-card__bp-icon--play" viewBox="0 0 18 18" fill="currentColor">
1435                <path d="M15.1,7.478L5.608,2.222c-.553-.306-1.206-.297-1.749,.023-.538,.317-.859,.877-.859,1.499V14.256c0,.622,.321,1.182,.859,1.499,.279,.164,.586,.247,.895,.247,.293,0,.586-.075,.854-.223l9.491-5.256c.556-.307,.901-.891,.901-1.522s-.345-1.215-.9-1.522Z"/>
1436              </svg>
1437              <!-- Pause icon (hidden at rest; shown while the preview clip plays) — Nucleo "media-pause" -->
1438              <svg class="about-card__bp-icon--pause" viewBox="0 0 18 18" fill="currentColor">
1439                <rect x="2" y="2" width="5" height="14" rx="1.75" ry="1.75"/>
1440                <rect x="11" y="2" width="5" height="14" rx="1.75" ry="1.75"/>
1441              </svg>
1442              <!-- External-link icon (hidden at rest; shown when no preview clip is available) — Nucleo "open-external" -->
1443              <svg class="about-card__bp-icon--link" viewBox="0 0 18 18" fill="currentColor">
1444                <path d="m12.8828,14.5h-5.1328c-2.3433,0-4.25-1.9067-4.25-4.25v-5.1329c-1.1506.3289-2,1.3781-2,2.6329v6c0,1.5166,1.2334,2.75,2.75,2.75h6c1.2549,0,2.3042-.8494,2.6328-2Z"/>
1445                <path d="m13.75,1.5h-6c-1.5188,0-2.75,1.2312-2.75,2.75v6c0,1.5188,1.2312,2.75,2.75,2.75h6c1.5188,0,2.75-1.2312,2.75-2.75v-6c0-1.5188-1.2312-2.75-2.75-2.75Zm-.25,6.75c0,.4141-.3359.75-.75.75s-.75-.3359-.75-.75v-1.1895l-2.4697,2.4697c-.1465.1465-.3379.2197-.5303.2197s-.3838-.0732-.5303-.2197c-.293-.293-.293-.7676,0-1.0605l2.4697-2.4697h-1.1895c-.4141,0-.75-.3359-.75-.75s.3359-.75.75-.75h3c.4141,0,.75.3359.75.75v3Z"/>
1446              </svg>
1447            </span>
1448          </button>
1449          <div class="about-card__banner-text">
1450            <span class="about-card__banner-title" id="about-banner-title"></span>
1451            <span class="about-card__banner-artist" id="about-banner-artist"></span>
1452          </div>
1453          <div class="about-card__banner-record" id="about-banner-record"></div>
1454        </div>
1455        </div>
1456        <div class="about-card__group about-card__group--content">
1457        <div class="about-interests">
1458          <span class="about-interests__heading">Interests</span>
1459          <div class="about-interests__viewport">
1460            <div class="about-interests__row" role="radiogroup" aria-label="Interests">
1461              <button type="button" class="about-interest" role="radio" aria-checked="true" data-interest="design">
1462                <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true" focusable="false">
1463                  <g fill="currentColor">
1464                    <path d="M14.25 16.5h-0.75c-0.414 0-0.75-0.336-0.75-0.75s0.336-0.75 0.75-0.75h0.75c0.413 0 0.75-0.336 0.75-0.75v-0.75c0-0.414 0.336-0.75 0.75-0.75s0.75 0.336 0.75 0.75v0.75c0 1.241-1.01 2.25-2.25 2.25Z"/>
1465                    <path d="M10.5 16.5h-0.75c-1.24 0-2.25-1.009-2.25-2.25v-0.75c0-0.414 0.336-0.75 0.75-0.75s0.75 0.336 0.75 0.75v0.75c0 0.414 0.337 0.75 0.75 0.75h0.75c0.414 0 0.75 0.336 0.75 0.75s-0.336 0.75-0.75 0.75Z"/>
1466                    <path d="M15.75 11.25c-0.414 0-0.75-0.336-0.75-0.75v-0.75c0-0.414-0.337-0.75-0.75-0.75h-0.75c-0.414 0-0.75-0.336-0.75-0.75s0.336-0.75 0.75-0.75h0.75c1.24 0 2.25 1.009 2.25 2.25v0.75c0 0.414-0.336 0.75-0.75 0.75Z"/>
1467                    <path d="M8.25 11.25c-0.414 0-0.75-0.336-0.75-0.75v-0.75c0-1.241 1.01-2.25 2.25-2.25h0.75c0.414 0 0.75 0.336 0.75 0.75s-0.336 0.75-0.75 0.75h-0.75c-0.413 0-0.75 0.336-0.75 0.75v0.75c0 0.414-0.336 0.75-0.75 0.75Z"/>
1468                    <path d="M3.75 13c-0.414 0-0.75-0.336-0.75-0.75v-6.5c0-0.414 0.336-0.75 0.75-0.75s0.75 0.336 0.75 0.75v6.5c0 0.414-0.336 0.75-0.75 0.75Z"/>
1469                    <path d="M12.25 4.5h-6.5c-0.414 0-0.75-0.336-0.75-0.75s0.336-0.75 0.75-0.75h6.5c0.414 0 0.75 0.336 0.75 0.75s-0.336 0.75-0.75 0.75Z"/>
1470                    <rect x="1.5" y="12" width="4.5" height="4.5" rx="1.432"/>
1471                    <rect x="1.5" y="1.5" width="4.5" height="4.5" rx="1.432"/>
1472                    <rect x="12" y="1.5" width="4.5" height="4.5" rx="1.432"/>
1473                  </g>
1474                </svg>
1475                <span class="about-interest__label" data-text="Design (obviously)"><span class="about-interest__text">Design <span class="about-interest__suffix">(obviously)</span></span></span>
1476              </button>
1477              <button type="button" class="about-interest" role="radio" aria-checked="false" data-interest="tech">
1478                <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true" focusable="false">
1479                  <g fill="currentColor">
1480                    <rect x="6" y="6" width="6" height="6" rx=".75"/>
1481                    <path d="M16.25,9.75c.414,0,.75-.336,.75-.75s-.336-.75-.75-.75h-1.25v-1.75h1.25c.414,0,.75-.336,.75-.75s-.336-.75-.75-.75h-1.367c-.26-.911-.972-1.623-1.883-1.883V1.75c0-.414-.336-.75-.75-.75s-.75,.336-.75,.75v1.25h-1.75V1.75c0-.414-.336-.75-.75-.75s-.75,.336-.75,.75v1.25h-1.75V1.75c0-.414-.336-.75-.75-.75s-.75,.336-.75,.75v1.367c-.911,.26-1.623,.972-1.883,1.883H1.75c-.414,0-.75,.336-.75,.75s.336,.75,.75,.75h1.25v1.75H1.75c-.414,0-.75,.336-.75,.75s.336,.75,.75,.75h1.25v1.75H1.75c-.414,0-.75,.336-.75,.75s.336,.75,.75,.75h1.367c.26,.911,.972,1.623,1.883,1.883v1.367c0,.414,.336,.75,.75,.75s.75-.336,.75-.75v-1.25h1.75v1.25c0,.414,.336,.75,.75,.75s.75-.336,.75-.75v-1.25h1.75v1.25c0,.414,.336,.75,.75,.75s.75-.336,.75-.75v-1.367c.911-.26,1.623-.972,1.883-1.883h1.367c.414,0,.75-.336,.75-.75s-.336-.75-.75-.75h-1.25v-1.75h1.25Zm-4,3.75H5.75c-.689,0-1.25-.561-1.25-1.25V5.75c0-.689,.561-1.25,1.25-1.25h6.5c.689,0,1.25,.561,1.25,1.25v6.5c0,.689-.561,1.25-1.25,1.25Z"/>
1482                  </g>
1483                </svg>
1484                <span class="about-interest__label" data-text="Tech"><span class="about-interest__text">Tech</span></span>
1485              </button>
1486              <button type="button" class="about-interest" role="radio" aria-checked="false" data-interest="photography">
1487                <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true" focusable="false">
1488                  <path fill="currentColor" d="M14.25,3h-1.73l-.324-.864c-.254-.68-.913-1.136-1.639-1.136h-3.114c-.726,0-1.384,.457-1.638,1.136l-.324,.864h-1.73c-1.517,0-2.75,1.233-2.75,2.75v6.5c0,1.517,1.233,2.75,2.75,2.75H14.25c1.517,0,2.75-1.233,2.75-2.75V5.75c0-1.517-1.233-2.75-2.75-2.75ZM4,7c-.552,0-1-.448-1-1s.448-1,1-1,1,.448,1,1-.448,1-1,1Zm5,5c-1.657,0-3-1.343-3-3s1.343-3,3-3,3,1.343,3,3-1.343,3-3,3Z"/>
1489                </svg>
1490                <span class="about-interest__label" data-text="Photography"><span class="about-interest__text">Photography</span></span>
1491              </button>
1492              <button type="button" class="about-interest" role="radio" aria-checked="false" data-interest="gaming">
1493                <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true" focusable="false"><path fill="currentColor" d="M17.632,11.77l-1.99-6.793c-.342-1.164-1.427-1.977-2.64-1.977H4.998c-1.213,0-2.298,.812-2.64,1.977L.37,11.764c-.081,.259-.12,.5-.12,.736,0,1.378,1.121,2.5,2.5,2.5,.797,0,1.553-.388,2.024-1.041l1.048-1.459h6.355l1.049,1.461c.471,.651,1.227,1.039,2.023,1.039,1.379,0,2.5-1.122,2.5-2.5,0-.234-.038-.473-.118-.73Zm-10.132-3.27h-.5v.5c0,.414-.336,.75-.75,.75s-.75-.336-.75-.75v-.5h-.5c-.414,0-.75-.336-.75-.75s.336-.75,.75-.75h.5v-.5c0-.414,.336-.75,.75-.75s.75,.336,.75,.75v.5h.5c.414,0,.75,.336,.75,.75s-.336,.75-.75,.75Zm3.25,1c-.414,0-.75-.336-.75-.75s.336-.75,.75-.75,.75,.336,.75,.75-.336,.75-.75,.75Zm1.5-2c-.414,0-.75-.336-.75-.75s.336-.75,.75-.75,.75,.336,.75,.75-.336,.75-.75,.75Z"/></svg>
1494                <span class="about-interest__label" data-text="Gaming"><span class="about-interest__text">Gaming</span></span>
1495              </button>
1496              <button type="button" class="about-interest" role="radio" aria-checked="false" data-interest="music">
1497                <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true" focusable="false">
1498                  <g fill="currentColor">
1499                    <path d="M13.487,5.861c.862-.118,1.513-.864,1.513-1.734v-1.231c0-.505-.218-.986-.599-1.318-.381-.333-.893-.484-1.387-.416l-3.5,.477h0c-.862,.118-1.513,.864-1.513,1.734v7.262c-.568-.398-1.256-.635-2-.635-1.93,0-3.5,1.57-3.5,3.5s1.57,3.5,3.5,3.5,3.5-1.57,3.5-3.5V6.405l3.987-.543Z"/>
1500                    <path d="M2.875,7.973c.161,0,.305-.104,.355-.256l.316-.947,.946-.315c.153-.051,.257-.194,.257-.356s-.104-.305-.257-.356l-.946-.315-.316-.947c-.102-.306-.609-.306-.711,0l-.316,.947-.946,.315c-.153,.051-.257,.194-.257,.356s.
1500104,.305,.257,.356l.946,.315,.316,.947c.051,.153,.194,.256,.355,.256Z"/>
1501                    <path d="M16.658,10.99l-1.263-.421-.421-1.263c-.137-.408-.812-.408-.949,0l-.421,1.263-1.263,.421c-.204,.068-.342,.259-.342,.474s.138,.406,.342,.474l1.263,.421,.421,1.263c.068,.204,.26,.342,.475,.342s.406-.138,.475-.342l.421-1.263,1.263-.421c.204-.068,.342-.259,.342-.474s-.138-.406-.342-.474Z"/>
1502                    <circle cx="5.25" cy="2.25" r=".75"/>
1503                  </g>
1504                </svg>
1505                <span class="about-interest__label" data-text="Music"><span class="about-interest__text">Music</span></span>
1506              </button>
1507              <button type="button" class="about-interest" role="radio" aria-checked="false" data-interest="film">
1508                <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true" focusable="false">
1509                  <g fill="currentColor">
1510                    <path d="M16.25,17h-7.25c-.414,0-.75-.336-.75-.75s.336-.75,.75-.75h7.25c.414,0,.75,.336,.75,.75s-.336,.75-.75,.75Z"/>
1511                    <path d="M9,1C4.589,1,1,4.589,1,9s3.589,8,8,8,8-3.589,8-8S13.411,1,9,1Zm-3.5,9.5c-.828,0-1.5-.672-1.5-1.5s.672-1.5,1.5-1.5,1.5,.672,1.5,1.5-.672,1.5-1.5,1.5Zm3.5,3.5c-.828,0-1.5-.672-1.5-1.5s.672-1.5,1.5-1.5,1.5,.672,1.5,1.5-.672,1.5-1.5,1.5Zm0-7c-.828,0-1.5-.672-1.5-1.5s.672-1.5,1.5-1.5,1.5,.672,1.5,1.5-.672,1.5-1.5,1.5Zm3.5,3.5c-.828,0-1.5-.672-1.5-1.5s.672-1.5,1.5-1.5,1.5,.672,1.5,1.5-.672,1.5-1.5,1.5Z"/>
1512                  </g>
1513                </svg>
1514                <span class="about-interest__label" data-text="Film"><span class="about-interest__text">Film</span></span>
1515              </button>
1516            </div>
1517          </div>
1518        </div>
1519          <div class="about-funfact">
1520            <span class="about-funfact__heading">Fun fact</span>
1521            <p class="about-funfact__body" id="about-funfact-body">Got into design by Photoshopping my friends into increasingly absurd situations. They stopped volunteering.</p>
1522          </div>
1523          <div class="about-rows" id="about-rows"></div>
1524        </div>
1525        </div>
1526      </div>
1527    </div>
1528    <!-- Custom overlay scrollbar: lives outside the card's rounded clip so it
1529         never gets corner-clipped, and takes no layout width. Driven by
1530         about-card.js. -->
1531    <div class="about-scrollbar" id="about-scrollbar" aria-hidden="true">
1532      <div class="about-scrollbar__thumb" id="about-scrollbar-thumb"></div>
1533    </div>
1534  </div>
1535
1536  <!-- Book-a-call popover: grows out of the "Book a call" button (see
1537       .btn-primary above) into a panel holding Cal.com's inline scheduler
1538       embed. Its markup (overlay/backdrop/panel/embed mount/scrollbar) is
1539       injected by book-call.js itself (ensurePopoverMarkup()) rather than
1540       hand-kept here, so the homepage and every project page share one copy.
1541       book-call.js mounts the embed (via Cal.com's own embed.js SDK, not a
1542       plain iframe) into #book-call-iframe on first hover/focus/open, and
1543       leaves it mounted for the rest of the visit — see mountCal() there for
1544       why it isn't torn down and rebuilt on every close. -->
1545
1546  <audio id="now-playing-audio" preload="none"></audio>
1547  
1547<script src="now-playing.js?v=4"></script>
1547
1548  
1548<script>
1549    (function () {
1550      var scripts = [
1551        'about-interests.js?v=92',
1552        'about-card.js?v=58',
1553        'about-lightbox.js?v=97',
1554        'book-call.js?v=35'
1555      ];
1556      function loadAll() {
1557        scripts.forEach(function (src) {
1558          var s = document.createElement('script');
1559          s.src = src;
1560          s.async = false;
1561          document.head.appendChild(s);
1562        });
1563      }
1564      if ('requestIdleCallback' in window) {
1565        requestIdleCallback(loadAll, { timeout: 3000 });
1566      } else {
1567        window.addEventListener('load', loadAll);
1568      }
1569    })();
1570  </script>
1570
1571
1572</body>
1573</html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.