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">·</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 & 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 & 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">© 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…</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.