/* ============ SELF-HOSTED FONTS ============
   Vendored Aug 8 2026 from Google Fonts, latin subset only, so the site has no
   third-party font host. Inter and JetBrains Mono ship as variable fonts, so one
   file covers every weight the site uses (Inter 400-800, mono 400-600); Instrument
   Serif is static and only its 400 normal and 400 italic are used.
   Characters outside this range (arrows, the CO2 subscript, emoji) already fell back
   to a system font with Google Fonts, so behavior is unchanged.
   To update: refetch from fonts.googleapis.com with a modern UA, keep this header. */
@font-face{font-family:'Inter';font-style:normal;font-weight:100 900;font-display:swap;
  src:url('assets/fonts/inter-latin-var.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}
@font-face{font-family:'JetBrains Mono';font-style:normal;font-weight:100 800;font-display:swap;
  src:url('assets/fonts/jetbrainsmono-latin-var.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}
@font-face{font-family:'Instrument Serif';font-style:normal;font-weight:400;font-display:swap;
  src:url('assets/fonts/instrumentserif-latin-400.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}
@font-face{font-family:'Instrument Serif';font-style:italic;font-weight:400;font-display:swap;
  src:url('assets/fonts/instrumentserif-latin-400i.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}

/* ============ TOKENS ============ */
:root{
  --bg:#F3F2EE;
  --surface:#FFFFFF;
  --surface2:#ECEBE6;
  --ink:#111110;
  --ink-soft:#3a3a37;
  --muted:#6b6b64;
  --border:rgba(17,17,16,0.10);
  --border2:rgba(17,17,16,0.18);
  --accent:#111110;
  /* Text-safe ink for the nav avatar initials. Named rather than inlined so the
     next contrast pass finds it the way this one found --violet-ink, instead of
     rediscovering a bare hex buried in a component rule.
     It sits on .nav-ava's own gradient, #D9D8D2 to #BCBBB4, so the number that
     matters is the DARK end, not the midpoint. The previous #5A5A54 measured
     4.86:1 against the light end and 3.60:1 against the dark end, which is how it
     stayed under the floor while looking fine in a spot check. #45453F is 6.76:1
     and 5.01:1. Measure gradient-backed text at its worst end. */
  --ava-ink:#45453F;
  --sans:'Inter',system-ui,sans-serif;
  --serif:'Instrument Serif',Georgia,serif;
  --mono:'JetBrains Mono',monospace;
  --max:1180px;
  --nav-h:76px;
  --ease:cubic-bezier(.22,.61,.36,1);
  /* One vertical rhythm for the whole site. 5rem is the value the project
     pages already used for .h-sec, so adopting it sitewide unifies index
     with them rather than inventing a third number. One boundary is ONE
     unit: sections carry top padding only, so adjacent sections no longer
     stack 6.5rem + 6.5rem = 208px of dead space between them. */
  --sp-section:5rem;
  /* PREVIEW, design pass item 7. One type scale, ~1.25 between the middle
     steps. The middle of the page currently runs 8 sizes one pixel apart
     (20/19/18/17/16/15/14/13) across 114 elements, which is a lot of sizes
     doing no differentiating work. This collapses that range to five real
     steps. Applied so far to the hero and the Projects section only. */
  --t-1:0.75rem;    /* 12px  eyebrows, mono labels */
  --t-2:0.875rem;   /* 14px  meta, tags, small print */
  --t-3:1rem;       /* 16px  body */
  --t-4:1.125rem;   /* 18px  lead paragraphs */
  --t-5:1.375rem;   /* 22px  card titles, h3 */
  --t-6:2rem;       /* 32px  sub-heads */
  --t-7:3rem;       /* 48px  section titles */
  --t-8:4.8rem;     /* 76.8px hero. Down from 5.2rem: at 5.2 the second line
                       "drives decisions." wraps at >=1300px viewports, making
                       the h1 three lines and 248px tall. 4.8 keeps it to two
                       lines at every width tested, 248px -> 153px. Measured
                       line-height is already 0.98, so there is no slack to
                       reclaim and size is the only lever. */
}

/* Dark-canvas border tokens, P3-1b. These two values are byte-identical in all
   seven dark project themes, so they live here once instead of being repeated
   in seven inline blocks. Nothing else about the dark canvas is shared: --bg,
   --surface, --surface2, --ink, --ink-soft and --muted carry seven distinct
   values, one tint per project, which is the design and not duplication.
   Each page's inline block still comes after this file, so a page can override
   either token by declaring it again. */
body.theme-dark, body.theme-titan, body.theme-aero, body.theme-trade,
body.theme-grid, body.theme-rl, body.theme-dico{
  --border:rgba(255,255,255,0.09);
  --border2:rgba(255,255,255,0.18);
}
*{margin:0;padding:0;box-sizing:border-box}
/* native smooth scrolling, turned off by the reduced-motion block below */
html{scroll-padding-top:calc(var(--nav-h) + 2rem);scroll-behavior:smooth}
body{background:var(--bg);color:var(--ink);font-family:var(--sans);-webkit-font-smoothing:antialiased;overflow-x:hidden;line-height:1.5}
a{color:inherit;text-decoration:none}
img{display:block;max-width:100%}
::selection{background:var(--ink);color:var(--bg)}
.wrap{max-width:var(--max);margin:0 auto;padding:0 2rem}
.eyebrow{font-family:var(--mono);font-size:.68rem;letter-spacing:.18em;text-transform:uppercase;color:var(--muted)}

/* ============ NAV (floating pill) ============ */
/* Hide on scroll down, restore on scroll up.
   Replaces the frosted-glass treatment, which was measured cutting the hero h1
   by 275x58px at scroll 240 and slicing the "1ST PLACE · EUR 3,000" badge at
   834. Blur does not solve chrome-over-text at any alpha: it leaves gray letter
   ghosts inside the pill that read as a rendering fault. The only fix for fixed
   chrome sitting on running text is for it not to be there while the text is
   being read. Full write-up in DESIGN-DIRECTION.md. */
.nav-outer{position:fixed;top:1.1rem;left:0;right:0;z-index:200;display:flex;justify-content:center;pointer-events:none;transition:top .4s var(--ease)}
.nav-outer.nav-hidden{top:-6rem}
/* Never hide it out from under a keyboard user or an open menu.
   Two rules, not one selector list, on purpose: an engine that does not know
   :has() drops the ENTIRE list it appears in, which would silently take the
   :focus-within rescue with it. Split, the worst case is losing the menu
   guard, which main.js also enforces. */
.nav-outer.nav-hidden:focus-within{top:1.1rem}
.nav-outer.nav-hidden:has(.nav.open){top:1.1rem}
.nav{pointer-events:auto;display:flex;align-items:center;gap:1.6rem;background:var(--surface);border:1px solid rgba(17,17,16,.14);border-radius:100px;padding:.5rem .6rem .5rem .7rem;box-shadow:0 6px 30px rgba(17,17,16,.06);transition:box-shadow .4s var(--ease)}
.nav.scrolled{box-shadow:0 10px 40px rgba(17,17,16,.12)}
.nav-brand{display:flex;align-items:center;gap:.6rem;padding-left:.3rem}
.nav-ava{width:34px;height:34px;border-radius:50%;background:linear-gradient(135deg,#d9d8d2,#bcbbb4);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:var(--t-1);color:var(--ava-ink);overflow:hidden}
.nav-ava img{width:100%;height:100%;object-fit:cover}
.nav-name{font-weight:600;font-size:var(--t-2);letter-spacing:-.01em;white-space:nowrap}
.nav-links{display:flex;align-items:center;gap:1.35rem;margin:0 .4rem}
/* 24px minimum target height, WCAG 2.2 AA 2.5.8. P1-9. */
.nav-links a{display:inline-flex;align-items:center;min-height:24px;font-size:var(--t-2);color:var(--ink-soft);font-weight:500;transition:color .2s}
.nav-links a:hover{color:var(--ink)}
.nav-cta{background:var(--ink);color:var(--bg);font-size:var(--t-2);font-weight:600;padding:.6rem 1.15rem;border-radius:100px;transition:transform .2s var(--ease),opacity .2s}
.nav-cta:hover{transform:translateY(-1px);opacity:.9}
.nav-burger{display:none;flex-direction:column;gap:4px;padding:.5rem;cursor:pointer}
.nav-burger span{width:20px;height:2px;background:var(--ink);border-radius:2px}

/* ============ HERO ============ */
/* 75vh, not 100vh: the next section must be visibly cut by the fold so it
   reads as 'there is more' rather than as a layout that happens to end there.
   A sliver reads as a mistake, so this is tuned for a clear partial. */
#hero{position:relative;min-height:68vh;padding:calc(var(--nav-h) + 1.5rem) 0 2rem;overflow:hidden}
.hero-grid{position:relative;max-width:var(--max);margin:0 auto;padding:0 2rem;display:grid;grid-template-columns:1fr 1fr;align-items:center;gap:2rem;min-height:56vh}
.hero-left{position:relative;z-index:3}
.hero-badge{display:inline-flex;align-items:center;gap:.55rem;font-family:var(--mono);font-size:.7rem;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-soft);background:var(--surface);border:1px solid var(--border);padding:.45rem .9rem;border-radius:100px;margin-bottom:1.6rem}
.hero-badge .dot{width:8px;height:8px;border-radius:50%;background:#3fb56b;box-shadow:0 0 0 3px rgba(63,181,107,.18)}
.hero-cred{margin-top:2.4rem;display:flex;align-items:center;gap:.7rem;color:var(--ink-soft);font-size:var(--t-2)}
.hero-cred svg{width:20px;height:20px;fill:none;stroke:var(--ink);stroke-width:1.6}
.hero-cred b{color:var(--ink);font-weight:600}
.hero-h1{font-size:clamp(2.8rem,6.2vw,var(--t-8));line-height:.98;font-weight:700;letter-spacing:-.035em}
.hero-h1 em{font-family:var(--serif);font-style:italic;font-weight:400}
.hero-sub{margin-top:1.5rem;max-width:34rem;font-size:var(--t-4);color:var(--ink-soft);line-height:1.6}
.hero-actions{margin-top:2.2rem;display:flex;gap:.8rem;align-items:center;flex-wrap:wrap}
.btn{font-size:var(--t-2);font-weight:600;padding:.85rem 1.5rem;border-radius:100px;transition:transform .2s var(--ease),background .2s,color .2s;cursor:pointer;border:1px solid var(--ink);display:inline-flex;align-items:center;gap:.5rem}
.btn-primary{background:var(--ink);color:var(--bg)}
.btn-primary:hover{transform:translateY(-2px)}
.btn-ghost{background:transparent;color:var(--ink);border-color:var(--border2)}
.btn-ghost:hover{background:var(--surface);transform:translateY(-2px)}

/* fanning mockup cards.
   The fan is CSS. The scroll morph overwrites these with inline transforms when
   it runs (desktop, motion allowed); everywhere else the cards still fan
   properly instead of stacking invisibly on one spot. */
.hero-cards{position:relative;height:460px}
/* pointer-events:auto here, none on the morph layer that holds these while it
   runs. Same split as .nav-outer / .nav above: the layer must not eat clicks
   meant for the grid behind it, and the cards inside it must still be links.
   Without this pair the four hero cards render perfectly and do nothing. */
.mcard{pointer-events:auto;position:absolute;top:50%;left:50%;width:340px;height:250px;border-radius:14px;overflow:hidden;background:var(--surface);border:1px solid var(--border);box-shadow:0 30px 60px -20px rgba(17,17,16,.28);transform:translate(-50%,-50%) translate(var(--x,0),var(--y,0)) rotate(var(--r,0deg));will-change:transform,opacity}
/* Keyed on data-i, not nth-child: the morph reparents the cards one at a time,
   which would shift every nth-child index as it goes.
   The step is mostly vertical so back cards show their chrome bar, not a clipped
   half-title, and z-order is monotonic so the front card is never buried. */
.mcard[data-i="0"]{--r:-15deg;--x:-6%;--y:-26%;z-index:1}
.mcard[data-i="1"]{--r:-6deg;--x:-2%;--y:-9%;z-index:2}
.mcard[data-i="2"]{--r:6deg;--x:6%;--y:9%;z-index:3}
.mcard[data-i="3"]{--r:15deg;--x:6%;--y:26%;z-index:4}
.mcard .bar{height:26px;background:var(--surface2);display:flex;align-items:center;gap:5px;padding:0 10px;border-bottom:1px solid var(--border)}
.mcard .bar i{width:8px;height:8px;border-radius:50%;background:#cfcec8;display:inline-block}
.mcard .body{height:calc(100% - 26px);display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:.5rem;padding:1.3rem;background:linear-gradient(150deg,var(--c1,#e7e6e0),var(--c2,#d3d2cb))}
.mcard .body .tag{font-family:var(--mono);font-size:.62rem;letter-spacing:.15em;text-transform:uppercase;color:#55554f}
.mcard .body .mock-t{font-size:1.5rem;font-weight:700;letter-spacing:-.02em;color:#2b2b27}

/* ============ generic section ============ */
section{padding:var(--sp-section) 0 0;position:relative}
.sec-head{display:flex;flex-direction:column;gap:.9rem;margin-bottom:3rem}
/* Two ranks, not one.
   Every section title on index computed to exactly 48px, so Work, Skills,
   Experience, How I work, Education and About all carried identical weight and
   a 20-second scan got no signal about which of them sells. Rank A is the two
   that do the selling, Work and Experience. Rank B is everything else, one
   token down.

   The middle term also changed. clamp(2rem,4vw,--t-7) put 834 at 33.36px,
   1.36px above the 390 value, on a column 2.1x wider: the whole 800-1200 band
   was getting mobile type at tablet width. 1.2rem + 2.6vw gives 40.9px at 834
   and still lands exactly on --t-7 at 1107px, so nothing changes at 1440. */
.sec-title{font-size:clamp(2rem,1.2rem + 2.6vw,var(--t-7));font-weight:700;letter-spacing:-.03em;line-height:1.02}
.sec-title.sub{font-size:clamp(1.5rem,0.9rem + 1.7vw,var(--t-6));letter-spacing:-.035em}
.sec-title em{font-family:var(--serif);font-style:italic;font-weight:400}
/* Content ships visible. html.js-anim is added by main.js only once the
   IntersectionObserver is about to attach, so it is the single selector that can
   hide anything pre-reveal. No JS, no observer, or a crawler = full content. */
.reveal{transition:opacity .4s var(--ease),transform .4s var(--ease)}
html.js-anim .reveal{opacity:0;transform:translateY(16px)}
html.js-anim .reveal.in{opacity:1;transform:none}

/* ============ PROJECTS grid ============ */
.proj-top{display:flex;align-items:flex-end;justify-content:space-between;gap:2rem;flex-wrap:wrap;margin-bottom:2.6rem}
.proj-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:1.3rem}
.proj{position:relative;border-radius:14px;overflow:hidden;background:var(--surface);border:1px solid var(--border);aspect-ratio:16/11;cursor:pointer;transition:transform .5s var(--ease),box-shadow .5s var(--ease);display:block}
.proj:hover{transform:translateY(-6px);box-shadow:0 24px 50px -24px rgba(17,17,16,.32)}
.proj-thumb{position:absolute;inset:0;background:linear-gradient(150deg,var(--c1),var(--c2));display:flex;align-items:center;justify-content:center;transition:transform .6s var(--ease)}
.proj-thumb img{width:100%;height:100%;object-fit:cover}
.proj:hover .proj-thumb{transform:scale(1.04)}
.proj-thumb span{font-size:2.4rem;font-weight:800;letter-spacing:-.03em;color:rgba(43,43,39,.28)}
.proj-meta{position:absolute;left:0;right:0;bottom:0;padding:1.3rem 1.4rem;display:flex;align-items:flex-end;justify-content:space-between;background:linear-gradient(to top,rgba(255,255,255,.97) 0%,rgba(255,255,255,.95) 62%,rgba(255,255,255,.55) 84%,rgba(255,255,255,0) 100%);-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px)}
.proj-meta h3{font-size:var(--t-5);font-weight:700;letter-spacing:-.02em}
.proj-meta p{font-size:var(--t-1);color:var(--muted);font-family:var(--mono);text-transform:uppercase;letter-spacing:.08em;margin-top:.15rem}
.proj-arrow{width:38px;height:38px;border-radius:50%;background:var(--ink);color:var(--bg);display:flex;align-items:center;justify-content:center;flex:0 0 auto;transition:transform .3s var(--ease)}
.proj:hover .proj-arrow{transform:rotate(-45deg)}
.proj-extra{display:none}
.proj-grid.show-all .proj-extra{display:block}
.proj-viewall{margin-top:2.4rem;text-align:center}
.proj-viewall .btn{cursor:pointer}

/* ============ QUOTE ============ */
.quote .who .av{width:44px;height:44px;border-radius:50%;background:var(--surface2)}

/* ============ TECH MARQUEE ============ */
/* :focus-within too, since a keyboard user cannot trigger :hover. P1-6. */
.services-tags{display:flex;flex-wrap:wrap;gap:.7rem;margin-top:2.4rem}
.stag{font-size:var(--t-2);font-weight:500;padding:.6rem 1.1rem;border:1px solid var(--border2);border-radius:100px;color:var(--ink-soft);transition:background .25s,color .25s}
.stag:hover{background:var(--ink);color:var(--bg)}

/* ============ ABOUT ============ */
.about-grid{display:grid;grid-template-columns:340px 1fr;gap:3.5rem;align-items:start}
.about-photo{position:relative;border-radius:16px;overflow:hidden;aspect-ratio:4/5;background:var(--surface2);border:1px solid var(--border);display:flex;align-items:center;justify-content:center}
.about-photo .ph{text-align:center;color:var(--muted);font-family:var(--mono);font-size:.72rem;letter-spacing:.1em;padding:1rem;line-height:1.7}
.about-photo img{width:100%;height:100%;object-fit:cover}
.about-name{font-size:1.7rem;font-weight:700;letter-spacing:-.02em;margin-bottom:.2rem}
.about-role{color:var(--muted);font-size:var(--t-3);margin-bottom:1.6rem}
.about-bio p{color:var(--ink-soft);margin-bottom:1rem;max-width:44rem}
.about-stat{margin-top:2rem;display:flex;gap:2.5rem;flex-wrap:wrap}
.about-stat .st b{display:block;font-size:2.4rem;font-weight:800;letter-spacing:-.03em}
.about-stat .st span{color:var(--muted);font-size:var(--t-1);font-family:var(--mono);text-transform:uppercase;letter-spacing:.1em}
.wh{margin-top:2.6rem;border-top:1px solid var(--border)}
.wh-item{border-bottom:1px solid var(--border)}
/* Experience, design pass item 6. No accordion: four roles is not a long list,
   and every panel was aria-expanded="false", so 155 words and five quantified
   outcomes sat behind a click on the section hiring managers read hardest.
   Hierarchy is also inverted from what it was: the ROLE is what someone is
   reading to find out, so it is the loud line now and the company is the
   supporting detail. Figures stay inline in Marian's sentences. */
.wh-item{border-bottom:1px solid var(--border);padding-left:1.6rem;position:relative}
.wh-item::before{content:"";position:absolute;left:0;top:0;bottom:0;width:1px;background:var(--border2)}
.wh-item::after{content:"";position:absolute;left:-3.5px;top:1.6rem;width:8px;height:8px;border-radius:50%;background:var(--ink)}
.wh-head{display:flex;align-items:baseline;justify-content:space-between;gap:1rem;padding:1.2rem 0 .35rem}
.wh-head .l{display:flex;flex-direction:column;gap:.2rem}
.wh-head .ro{font-size:var(--t-5);font-weight:600;letter-spacing:-.015em;color:var(--ink);order:1}
.wh-head .co{font-size:var(--t-3);color:var(--muted);font-weight:500;order:2}
.wh-head .yr{font-family:var(--mono);font-size:var(--t-1);color:var(--muted);white-space:nowrap}
.wh-panel p{padding:.5rem 0 1.4rem;color:var(--ink-soft);max-width:44rem}

/* ============ STEPS ============ */
.steps{display:grid;grid-template-columns:repeat(3,1fr);gap:1.3rem}
.step{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:2rem 1.7rem;transition:transform .4s var(--ease),box-shadow .4s var(--ease)}
.step:hover{transform:translateY(-5px);box-shadow:0 20px 44px -24px rgba(17,17,16,.25)}
.step .n{font-family:var(--mono);font-size:.75rem;color:var(--muted);margin-bottom:1.4rem}
.step h3{font-size:var(--t-5);font-weight:700;margin-bottom:.6rem;letter-spacing:-.01em}
.step p{color:var(--ink-soft);font-size:var(--t-3)}

/* ============ EDUCATION ============ */
.edu-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:1.3rem;align-items:start}
.edu-card{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:2rem 1.9rem;transition:transform .4s var(--ease),box-shadow .4s var(--ease);cursor:pointer}
.edu-card:hover{transform:translateY(-5px);box-shadow:0 20px 44px -24px rgba(17,17,16,.22)}
.edu-card.open{box-shadow:0 20px 44px -24px rgba(17,17,16,.22)}
.edu-top{display:flex;align-items:center;justify-content:space-between;gap:1rem;margin-bottom:1.1rem}
.edu-chev{width:18px;height:18px;flex:0 0 auto;position:relative;transition:transform .35s var(--ease);opacity:.45}
.edu-chev::before{content:'';position:absolute;top:3px;left:4px;width:8px;height:8px;border-right:1.6px solid var(--ink);border-bottom:1.6px solid var(--ink);transform:rotate(45deg)}
.edu-card.open .edu-chev{transform:rotate(180deg);opacity:.75}
.edu-more{max-height:0;overflow:hidden;transition:max-height .5s var(--ease)}
.edu-detail{list-style:none;margin:1.2rem 0 .2rem;padding-top:1.15rem;border-top:1px solid var(--border)}
.edu-detail li{position:relative;padding-left:1.1rem;margin-bottom:.6rem;color:var(--ink-soft);font-size:var(--t-2);line-height:1.5}
.edu-detail li::before{content:'';position:absolute;left:0;top:.62em;width:6px;height:1.5px;background:var(--muted)}
.edu-hint{font-family:var(--mono);font-size:.64rem;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin-top:1rem;opacity:0;transition:opacity .3s}
.edu-card:hover .edu-hint,.edu-card:focus-within .edu-hint{opacity:.65}
.edu-card.open .edu-hint{opacity:0}
.edu-logo{width:46px;height:46px;border-radius:10px;background:var(--surface2);display:flex;align-items:center;justify-content:center;font-weight:800;color:var(--ink-soft);font-size:var(--t-3)}
.edu-date{font-family:var(--mono);font-size:.74rem;color:var(--muted)}
.edu-card h3{font-size:var(--t-4);font-weight:700;letter-spacing:-.01em;margin-bottom:.25rem}
.edu-card .deg{color:var(--ink-soft);font-size:var(--t-3);margin-bottom:.9rem}
.edu-card .note{color:var(--muted);font-size:var(--t-2);line-height:1.55}
.edu-tags{display:flex;flex-wrap:wrap;gap:.4rem;margin-top:1rem}
.edu-tags span{font-family:var(--mono);font-size:.68rem;padding:.3rem .6rem;border:1px solid var(--border);border-radius:100px;color:var(--muted)}

/* ============ ABOUT ME ============ */
/* One column since the pull quote and signature were cut. The two-column grid,
   .aboutme .lead and .aboutme .sign went with them rather than being left as
   dead rules; .aboutme appears on index only, so nothing else consumed them.
   Measure is held by the 40rem cap on the paragraphs, not by a column. */
.aboutme .body p{color:var(--ink-soft);margin-bottom:1.1rem;max-width:40rem}

/* ============ CTA / FOOTER ============ */
#contact{padding-bottom:var(--sp-section)}
.cta{background:var(--ink);color:var(--bg);border-radius:22px;padding:4.5rem 3rem;text-align:center}
.cta h2{font-size:clamp(2rem,4vw,3rem);font-weight:700;letter-spacing:-.03em;line-height:1.05}
.cta h2 em{font-family:var(--serif);font-style:italic;font-weight:400}
.cta p{color:rgba(243,242,238,.7);max-width:34rem;margin:1.2rem auto 2rem}
.cta .btn{border-color:var(--bg);background:var(--bg);color:var(--ink)}
.cta .btn:hover{transform:translateY(-2px)}
footer{padding:3rem 0 2rem;border-top:1px solid var(--border);margin-top:5rem}
.foot{display:flex;align-items:center;justify-content:space-between;gap:1.5rem;flex-wrap:wrap}
.foot .fl{font-weight:600}
.foot .fr{display:flex;gap:1.4rem}
.foot .fr a{display:inline-flex;align-items:center;min-height:24px;font-size:var(--t-2);color:var(--muted);transition:color .2s}
.foot .fr a:hover{color:var(--ink)}
.foot-copy{margin-top:1.5rem;font-size:var(--t-1);color:var(--muted)}
/* Motion pause control, P1-6. The site has several infinite animations (
   ticker, drift, pulses) that previously paused on hover only, which a keyboard or
   screen-reader user cannot trigger. This is the persistent off switch; the class it
   toggles is applied to <html> so it reaches every animation on the page. */
.motion-toggle{display:inline-flex;align-items:center;gap:.5rem;min-height:24px;margin-top:1.1rem;padding:.35rem .8rem;border:1px solid var(--border2);border-radius:100px;background:transparent;color:var(--muted);font-family:var(--mono);font-size:.62rem;letter-spacing:.1em;text-transform:uppercase;cursor:pointer;transition:color .2s,border-color .2s}
.motion-toggle:hover{color:var(--ink);border-color:var(--ink)}
.motion-toggle .mt-dot{width:7px;height:7px;border-radius:50%;background:currentColor;flex:0 0 auto}
html.motion-paused *,html.motion-paused *::before,html.motion-paused *::after{animation-play-state:paused!important}

/* ============ floating contact pill ============ */
/* visibility, not just transform: an off-screen pill must not stay keyboard-focusable. P1-4. */
/* Same treatment as the nav, same reason: opaque, and gone while you scroll
   down. Measured occlusion before this change was 0 at 1440 but 66x30 over a
   section title at 834 and 177x50 over body copy at 390. */
.float-pill{visibility:hidden;position:fixed;bottom:1.4rem;left:50%;transform:translateX(-50%) translateY(120%);z-index:190;display:flex;align-items:center;gap:.9rem;background:var(--surface);border:1px solid rgba(17,17,16,.14);border-radius:100px;padding:.55rem .6rem .55rem 1.2rem;box-shadow:0 12px 40px rgba(17,17,16,.14);transition:transform .5s var(--ease),visibility .5s}
.float-pill.show{visibility:visible;transform:translateX(-50%) translateY(0)}
.float-pill .fp-txt{display:flex;flex-direction:column;line-height:1.2}
.float-pill .fp-txt b{font-size:var(--t-2);font-weight:600}
.float-pill .fp-txt span{font-size:.72rem;color:var(--muted)}
.float-pill .fp-ico{width:40px;height:40px;border-radius:50%;background:var(--ink);color:var(--bg);display:flex;align-items:center;justify-content:center}

/* Pill placement, design pass item 5. Glass fixed the nav on cream but cannot
   fix this: a blur of light body text on a near-black canvas averages to the
   canvas color, so on the dark themes the pill reads as solid at any alpha
   and simply erases the words behind it. The fix is physical, not optical.
   Desktop: move it out of the text column entirely. Content runs to x=1310
   inside the 1180px container at 1440, leaving ~130px of unused right margin.
   A 56px circle at 1.5rem inset sits at x=1360-1416, clearing the content
   edge by 50px, so passive occlusion is zero. It expands to the full label
   only on hover or focus, which is a deliberate reach rather than an
   obstruction.
   Narrow: no margin to hide in, so it is gated on scroll depth in main.js
   instead and stays bottom-center. */
/* Bottom-right at every width. At 390 there is no margin to hide in, so this
   does not reach zero occlusion, but it changes WHERE it overlaps: bottom-center
   covers the middle of a line, which is the worst place to lose text, while
   bottom-right covers the end of a line, which the eye has already left.
   Not chasing zero here; there is no margin and the returns are gone. */
.float-pill{left:auto;right:1rem;transform:translateY(140%)}
.float-pill.show{transform:translateY(0)}

@media(min-width:821px){
  .float-pill{right:1.5rem;bottom:1.5rem;padding:.5rem;gap:0;
    transform:translateY(140%);overflow:hidden}
  .float-pill.show{transform:translateY(0)}
  .float-pill .fp-txt{max-width:0;opacity:0;overflow:hidden;white-space:nowrap;
    transition:max-width .26s var(--ease),opacity .18s var(--ease),margin .26s var(--ease)}
  .float-pill:hover .fp-txt,.float-pill:focus-within .fp-txt{max-width:12rem;opacity:1;margin:0 .35rem 0 .7rem}
  .float-pill .fp-ico{width:56px;height:56px}
}
@media(prefers-reduced-motion:reduce){
  .float-pill .fp-txt{transition:none}
}

/* ============ PROJECT DETAIL PAGE ============ */
.pd-back{display:inline-flex;align-items:center;min-height:24px;gap:.5rem;font-family:var(--mono);font-size:.76rem;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin-bottom:2rem;transition:color .2s}
.pd-back:hover{color:var(--ink)}
.pd-head{padding:calc(var(--nav-h) + 5rem) 0 2.5rem}
.pd-cat{font-family:var(--mono);font-size:var(--t-1);letter-spacing:.14em;text-transform:uppercase;color:var(--muted);margin-bottom:1rem}
.pd-title{font-size:clamp(2.4rem,6vw,4.4rem);font-weight:700;letter-spacing:-.035em;line-height:1}
.pd-sub{margin-top:1.3rem;max-width:40rem;font-size:var(--t-3);color:var(--ink-soft);line-height:1.6}
.pd-facts{display:flex;flex-wrap:wrap;gap:2.2rem;margin-top:2.2rem}
.pd-fact .k{font-family:var(--mono);font-size:.66rem;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);margin-bottom:.3rem}
.pd-fact .v{font-size:var(--t-3);font-weight:600}
.pd-links{display:flex;flex-wrap:wrap;gap:.7rem;margin-top:2rem}
.pd-links a{display:inline-flex;align-items:center;gap:.5rem;font-size:var(--t-2);font-weight:600;padding:.7rem 1.3rem;border-radius:100px;border:1px solid var(--ink);background:var(--ink);color:var(--bg);transition:transform .2s var(--ease),opacity .2s}
.pd-links a.ghost{background:transparent;color:var(--ink);border-color:var(--border2)}
.pd-links a:hover{transform:translateY(-2px)}
.pd-hero{margin:1rem 0 0;border-radius:18px;overflow:hidden;aspect-ratio:16/8;background:linear-gradient(150deg,var(--c1,#e7e6e0),var(--c2,#d0cfc8));display:flex;align-items:center;justify-content:center}
.pd-hero img{width:100%;height:100%;object-fit:cover}
.pd-hero .ph{font-size:4rem;font-weight:800;color:rgba(43,43,39,.2)}
.pd-block{margin-top:1.3rem}
.pd-pair{display:grid;grid-template-columns:1fr 1fr;gap:1.3rem}
.pd-img{border-radius:14px;overflow:hidden;background:linear-gradient(150deg,var(--c1,#e7e6e0),var(--c2,#d0cfc8));display:flex;align-items:center;justify-content:center;min-height:320px;max-width:100%}
.pd-img.tall{aspect-ratio:4/5}
.pd-img.wide{aspect-ratio:16/8}
.pd-img img{width:100%;height:100%;object-fit:cover}
.pd-img.contain{background:#fff;padding:14px}
.pd-img.contain img{object-fit:contain}
.pd-img .ph{font-family:var(--mono);font-size:.72rem;letter-spacing:.1em;color:var(--muted);text-align:center;padding:1rem}
.pd-embed{margin-top:1.3rem;border:1px solid var(--border);border-radius:14px;overflow:hidden;background:var(--surface2)}
.pd-embed iframe{width:100%;height:78vh;border:0;display:block;background:#fff}
.pd-embed-bar{display:flex;align-items:center;justify-content:space-between;gap:1rem;padding:.85rem 1.1rem;border-top:1px solid var(--border);background:var(--surface)}
.pd-embed-bar .lbl{font-family:var(--mono);font-size:.7rem;letter-spacing:.1em;text-transform:uppercase;color:var(--muted)}
.pd-embed-bar a{font-size:var(--t-2);font-weight:600;display:inline-flex;align-items:center;min-height:24px;gap:.4rem}
/* ---- click-to-load PDF facade, P2-3 ----
   The poster takes its own height rather than the iframe's 78vh: forcing a 16:9
   slide into a 78vh box stranded 37px of empty surface above and below it at
   1440x900 and 111px at 920x900, which reads as an image that failed to fill its
   frame. main.js copies the poster's measured height onto the injected iframe, so
   the swap still shifts nothing. Portrait reports hit the 78vh cap and keep the
   side pillarbox every document viewer has.
   Every state here is a base style; nothing depends on an animation to become
   visible, so reduced motion, which kills the transition duration below, still
   leaves the finished appearance. */
.pdf-fac{position:relative;display:block;background:var(--surface2);text-decoration:none;cursor:pointer}
/* object-fit is load-bearing, not belt and braces: max-height clamps the height while
   width:100% keeps the width definite, and a replaced element with both dimensions
   fixed is stretched. Without this the portrait reports render squashed to 1114x702. */
.pdf-fac img{display:block;width:100%;height:auto;max-height:78vh;object-fit:contain;transition:opacity 160ms ease-out}
/* posters are light (a report page) or dark (a title slide), so the hover cue is
   opacity rather than a tint, which would be invisible on one or the other */
.pdf-fac:hover img{opacity:.78}
.pdf-fac:focus-visible{outline:none}
.pdf-fac:focus-visible::after{content:"";position:absolute;inset:0;border:3px solid #fff;outline:3px solid #111110;outline-offset:-6px;pointer-events:none}
/* Top right, not centered: title slides put their headline in the vertical middle,
   and a centered chip covered it on six of the seven posters. The white ring and
   the near-black body are a pair, not decoration: on a dark slide the ring carries
   the edge, on a white report page the body does. */
.pdf-fac-cta{position:absolute;z-index:1;right:1rem;top:1rem;display:inline-flex;align-items:center;padding:.8rem 1.25rem;border-radius:100px;background:#111110;color:#fff;box-shadow:0 0 0 2px rgba(255,255,255,.9),0 12px 30px -14px rgba(17,17,16,.8);font-family:var(--mono);font-size:.7rem;font-weight:500;letter-spacing:.1em;text-transform:uppercase;white-space:nowrap}
.pdf-fac-cta .sz{color:rgba(255,255,255,.66);margin-left:.5rem}
/* on a phone the full-size chip spans two thirds of the poster */
@media(max-width:900px){
  .pdf-fac-cta{right:.7rem;top:.7rem;padding:.55rem .95rem;font-size:.62rem;letter-spacing:.08em}
}
/* framed app screenshot (browser-window chrome that matches dark UI shots) */
.pd-shot{margin-top:1.3rem;border-radius:16px;overflow:hidden;border:1px solid rgba(255,255,255,0.09);background:#0b1121;box-shadow:0 34px 70px -34px rgba(11,17,33,.55)}
.pd-shot.spotlight{box-shadow:0 44px 90px -34px rgba(11,17,33,.6)}
.pd-shot-bar{height:40px;display:flex;align-items:center;gap:8px;padding:0 16px;background:#0e1626;border-bottom:1px solid rgba(255,255,255,0.06)}
.pd-shot-bar i{width:11px;height:11px;border-radius:50%;background:#2a3550;display:inline-block}
.pd-shot-bar .u{margin-left:12px;height:20px;flex:1;max-width:340px;border-radius:6px;background:rgba(255,255,255,0.04)}
.pd-shot img{display:block;width:100%}
.pd-shot-cap{padding:.85rem 1.15rem;font-family:var(--mono);font-size:.7rem;letter-spacing:.09em;text-transform:uppercase;color:rgba(255,255,255,0.55);background:#0b1121;border-top:1px solid rgba(255,255,255,0.06)}
/* light frame variant for light-UI screenshots */
.pd-shot.light{background:#fff;border-color:var(--border);box-shadow:0 30px 60px -34px rgba(17,17,16,.22)}
.pd-shot.light .pd-shot-bar{background:var(--surface2);border-bottom-color:var(--border)}
.pd-shot.light .pd-shot-bar i{background:#cfcec8}
.pd-shot.light .pd-shot-bar .u{background:rgba(0,0,0,0.05)}
.pd-shot.light .pd-shot-cap{background:#fff;border-top-color:var(--border);color:var(--muted)}
.pd-text{max-width:44rem;margin:3.5rem 0}
.pd-text h3{font-size:clamp(1.5rem,3vw,2.1rem);font-weight:700;letter-spacing:-.02em;margin-bottom:1rem;line-height:1.1}
.pd-text p{color:var(--ink-soft);margin-bottom:1rem}
.pd-next{margin-top:5rem;padding-top:2.5rem;border-top:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap}
.pd-next .lbl{font-family:var(--mono);font-size:.72rem;letter-spacing:.1em;text-transform:uppercase;color:var(--muted)}
.pd-next a{font-size:1.4rem;font-weight:700;letter-spacing:-.02em;display:inline-flex;align-items:center;gap:.6rem;transition:gap .25s var(--ease)}
.pd-next a:hover{gap:1rem}

/* ============ RESPONSIVE ============ */
@media(max-width:900px){
  .hero-grid{grid-template-columns:1fr}
  /* Cap the portrait when the grid collapses. Unconstrained it took the full
     column: 768x961 rendered from a 656x820 source at 834, a 117% upscale that
     is visibly soft and cost 961px, 12% of the whole page, for one photo. The
     340px cap is the same width the two-column layout gives it at 1440, so the
     portrait is one size everywhere instead of two.
     UPDATED Aug 8 2026. This note used to say the cap moves with the portrait
     if the portrait goes to section 06. There is no longer a slot there:
     section 06 became 05 and lost its left column when the pull quote was cut,
     so it is now a single text column. The portrait stays in .about-grid where
     it has always been, this cap is styling a live container, and any future
     move needs a new home rather than that one. */
  .about-grid{grid-template-columns:1fr;gap:2rem}
  .about-photo{max-width:340px}
  .steps{grid-template-columns:1fr}
  .edu-grid{grid-template-columns:1fr}
  .pd-pair{grid-template-columns:1fr}
  .pd-img{min-height:240px}
}
@media(max-width:680px){
  .wrap,.hero-grid{padding:0 1.2rem}
  .nav-links{display:none}
  .nav-burger{display:flex}
  .proj-grid{grid-template-columns:1fr}
  .cta{padding:3rem 1.4rem}
  /* three short values fit across; flex-wrap orphaned the third onto its own row */
  .about-stat{display:grid;grid-template-columns:repeat(3,1fr);gap:1.2rem}
  .about-stat .st b{font-size:2rem}
}

/* ============ ACCESSIBILITY (Phase 1) ============ */
/* text alternative for graphics whose meaning lives only in geometry or color. P1-5. */
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}
.skip-link{position:absolute;left:-9999px;top:1rem;z-index:500;background:var(--ink);color:var(--bg);padding:.8rem 1.3rem;border-radius:100px;font-weight:600;font-size:var(--t-2)}
.skip-link:focus{left:1rem}
:focus-visible{outline:2px solid var(--ink);outline-offset:3px;border-radius:4px;box-shadow:0 0 0 6px var(--bg)}
.nav-cta:focus-visible,.btn-primary:focus-visible,.cta .btn:focus-visible,.fp-ico:focus-visible{outline-color:var(--bg);box-shadow:0 0 0 6px var(--ink)}
.nav-burger{background:none;border:none}

/* mobile nav dropdown (burger opens the real menu, not a jump-to-contact) */
@media(max-width:680px){
  .nav{position:relative}
  .nav.open .nav-links{display:flex;flex-direction:column;align-items:stretch;gap:.2rem;position:absolute;top:calc(100% + .6rem);left:0;right:0;background:var(--surface);border:1px solid var(--border);border-radius:20px;padding:.8rem;box-shadow:0 16px 44px rgba(0,0,0,.18)}
  .nav.open .nav-links a{padding:.7rem .9rem;border-radius:12px;color:var(--ink)}
  .nav.open .nav-links a:hover{background:var(--surface2)}
}

/* reduced motion: kill animation & transitions, show revealed states */
@media (prefers-reduced-motion: reduce){
  /* NOTE: this does not freeze elements at their final keyframe. The animation
     completes instantly and, with the default animation-fill-mode:none, each
     element reverts to its BASE style. So a keyframe ending at opacity:0 is
     harmless, but any element whose BASE style is opacity:0 and which relies on
     an animation to become visible will be invisible here, permanently.
     Such elements must declare their own reduced-motion resting state
     (animation:none + a visible opacity). See hospital.html .fp / .fp-label. */
  *,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important;scroll-behavior:auto!important}
  .reveal,html.js-anim .reveal{opacity:1;transform:none}
}

.edu-btn{all:unset;cursor:pointer;font:inherit;color:inherit}
/* all:unset also drops the outline from the global :focus-visible rule. P1-2. */
.edu-btn:focus-visible{outline:2px solid var(--ink);outline-offset:3px;border-radius:4px}

/* ============ hero cards: snap row below the fan's minimum width ============ */
@media(max-width:1099px){
  /* stack the hero here too, so the row gets the full column width instead of
     being squeezed into half of it and bleeding off the right edge */
  .hero-grid{grid-template-columns:1fr}
  /* The row scrolls, so say so. Without the fade the third card just stops at
     the viewport edge mid-word ("Predictive RU" at 834, "Aviat" at 390) and
     reads as a layout bug rather than as a carousel. The mask sits on the
     padding box, so it stays pinned to the right edge while the content moves
     under it, and it costs no markup. */
  .hero-cards{height:auto;margin:1.2rem -2rem 0;padding:.4rem 2rem 1.4rem;display:flex;gap:.9rem;overflow-x:auto;scroll-snap-type:x proximity;overscroll-behavior-x:contain;
    -webkit-mask-image:linear-gradient(to right,#000 0,#000 calc(100% - 3.5rem),transparent 100%);
    mask-image:linear-gradient(to right,#000 0,#000 calc(100% - 3.5rem),transparent 100%)}
  .mcard{position:relative;top:auto;left:auto;flex:0 0 300px;width:auto;height:200px;transform:none;scroll-snap-align:center;box-shadow:0 14px 30px -16px rgba(17,17,16,.3)}
}
@media(max-width:680px){
  .hero-cards{margin:1.2rem -1.2rem 0;padding:.4rem 1.2rem 1.4rem}
  .mcard{flex:0 0 80%;height:186px}
}


/* ===================================================================
   P3-2. Rules that were byte-identical across the project pages, lifted
   out of their inline <style> blocks and stated once here.
   Scoping rule: a selector is promoted BARE only when every page that uses
   the class also declared the rule. .two, .card and .card p are scoped to
   the project themes because index, hospital, madrid and trading use those
   classes without ever declaring a rule for them, so a bare promotion would
   newly style pages that had no such styling.
   Page blocks still load after this file, so any page can still override.
   =================================================================== */
.h-sec{margin-top:var(--sp-section)}
.h-sec h2{font-size:clamp(1.6rem,3.2vw,2.3rem);font-weight:700;letter-spacing:-.03em;margin-top:.7rem}
.h-sec h2 em{font-family:var(--serif);font-style:italic;font-weight:400}
.h-sec .lead{color:var(--ink-soft);max-width:46rem;margin-top:1rem}
/* ============ KPI CARD ============
   One card, ten pages. Each page swaps three tokens in its own theme block and
   changes nothing else: --kpi-accent (mono label + hover rim), --kpi-accent-rgb
   (the same hue for the rgba glow, since rgba() cannot take a hex token) and
   --kpi-v2 (the far stop of the value gradient). Every one of those is measured
   at 4.5:1 or better against the composited card surface, not against --bg.
   .kc is the default card. .kpicard is the same behavior with no substrate of
   its own, for the two KPI blocks that sit on real imagery and already carry a
   glass pane (hospital's band, datathon's hero strip). Their glass stays: the
   blur there has a photo and a video behind it to refract. */
:root{
  --kpi-bg:var(--surface);
  --kpi-edge:linear-gradient(90deg,transparent,rgba(17,17,16,.16),transparent);
  --kpi-shadow:0 10px 30px rgba(17,17,16,.07);
  --kpi-accent:var(--muted);
  --kpi-accent-rgb:107,107,100;
  --kpi-v2:var(--ink);
}
body.theme-dark, body.theme-titan, body.theme-aero, body.theme-trade,
body.theme-grid, body.theme-rl, body.theme-dico, body.theme-cinema{
  /* the cards read as panes over the page's radial canvas rather than as opaque
     boxes sitting on it; no backdrop-filter, because there is nothing behind
     them to blur but a gradient */
  --kpi-bg:color-mix(in srgb,var(--surface) 62%,transparent);
  --kpi-edge:linear-gradient(90deg,transparent,rgba(255,255,255,.42),transparent);
  --kpi-shadow:0 10px 30px rgba(0,0,0,.34);
}
.k4{display:grid;grid-template-columns:repeat(4,1fr);gap:1.25rem;margin-top:1.3rem}
.kc,.kpicard{position:relative;overflow:hidden;
  transition:transform .35s var(--ease),border-color .35s ease,box-shadow .35s ease}
.kc{background:var(--surface);background:var(--kpi-bg);
    border:1px solid var(--border);border-radius:18px;padding:1.7rem 1.5rem;
    box-shadow:var(--kpi-shadow)}
/* specular top edge: the light catching the lip of the card */
.kc::after,.kpicard::after{content:'';position:absolute;top:0;left:0;right:0;height:1px;
  z-index:2;pointer-events:none;background:var(--kpi-edge)}
/* cursor spotlight. Position comes from main.js as two percentages; with no JS,
   no hover, or reduced motion it never paints, and the card is complete without
   it. z-index 1 puts it over the glass pane on .kpicard but under the text,
   which is painted after it in tree order at the same level. */
.kc::before,.kpicard::before{content:'';position:absolute;inset:0;z-index:1;
  pointer-events:none;opacity:0;transition:opacity .3s ease;
  background:radial-gradient(280px circle at var(--kpi-mx,50%) var(--kpi-my,50%),
    rgba(var(--kpi-accent-rgb),.26),transparent 65%)}
.kc>*{position:relative;z-index:1}
@media(hover:hover){
  .kc:hover::before,.kpicard:hover::before{opacity:1}
  .kc:hover,.kpicard:hover{transform:translateY(-5px)}
  .kc:hover{
    border-color:rgba(var(--kpi-accent-rgb),.5);
    box-shadow:0 18px 40px -10px rgba(var(--kpi-accent-rgb),.32)}
}
.kc .k,.kpicard .k{font-family:var(--mono);font-size:.62rem;letter-spacing:.12em;
  text-transform:uppercase;color:var(--kpi-accent);line-height:1.35}
.kc .v,.kpicard .v{font-size:2.2rem;font-weight:800;letter-spacing:-.03em;
  margin-top:.55rem;line-height:1;color:var(--ink);width:fit-content}
/* the gradient is clipped to the glyphs, so the box has to hug them: on a
   full-width block the number would sample only the first few percent of the
   ramp and read as flat --ink. Hence width:fit-content above. */
/* color stays set to --kpi-v2, the FAR stop, and only the fill is made
   transparent. Two reasons, both load-bearing. If the clip is supported but the
   fill property is not, the number renders in a color that passes rather than
   vanishing. And scripts/a11y-check.mjs reads computed `color` as the foreground
   it measures: color:transparent made it measure black on every card and report
   28 failures that were not on screen. --kpi-v2 is the darker end of every ramp
   on every page, so what the gate now measures is the worst pixel in the glyph. */
@supports ((-webkit-background-clip:text) or (background-clip:text)){
  .kc .v,.kpicard .v{
    background-image:linear-gradient(135deg,var(--ink) 38%,var(--kpi-v2) 100%);
    -webkit-background-clip:text;background-clip:text;
    color:var(--kpi-v2);-webkit-text-fill-color:transparent}
}
.kc .s,.kpicard .s{font-size:var(--t-1);color:var(--muted);margin-top:.45rem}
@media(prefers-reduced-motion:reduce){
  .kc,.kpicard{transition:none}
  .kc:hover,.kpicard:hover{transform:none}
  .kc::before,.kpicard::before{display:none}
}
@media(max-width:820px){.k4{grid-template-columns:1fr 1fr}}
@media(max-width:520px){.kc{padding:1.35rem 1.25rem}}

body.theme-titan .two,
body.theme-aero .two,
body.theme-trade .two,
body.theme-grid .two,
body.theme-rl .two,
body.theme-dico .two,
body.theme-datathon .two{display:grid;grid-template-columns:1fr 1fr;gap:1.3rem;margin-top:1.3rem}
body.theme-titan .card,
body.theme-aero .card,
body.theme-trade .card,
body.theme-grid .card,
body.theme-rl .card,
body.theme-dico .card,
body.theme-datathon .card{background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:1.7rem}
body.theme-titan .card p,
body.theme-aero .card p,
body.theme-rl .card p,
body.theme-dico .card p,
body.theme-datathon .card p{color:var(--ink-soft);font-size:var(--t-2);line-height:1.55}
@media(max-width:820px){
body.theme-titan .two,
body.theme-aero .two,
body.theme-trade .two,
body.theme-grid .two,
body.theme-rl .two,
body.theme-dico .two,
body.theme-datathon .two{grid-template-columns:1fr}
}


/* The @supports backdrop-filter fallback that stood here is gone with the glass
   it backed. Its body, `.nav,.nav.scrolled,.float-pill{background:var(--surface)}`,
   is now the unconditional rule, so every browser gets what only the
   non-blurring ones used to get. That fallback was also the proof this
   rendering is sound: it has been shipping to any engine without
   backdrop-filter since P1. */


/* ============ P5-1 cross-document view transitions ============
   Each project card title carries the same view-transition-name as the
   destination page's h1, so the card title morphs into the page title
   instead of the pages hard-cutting. Names are per destination and unique
   within each document. The hero mockup cards deliberately carry no name:
   duplicating a name inside one document disables the transition entirely. */
@view-transition{navigation:auto}
::view-transition-group(*){animation-duration:320ms;animation-timing-function:var(--ease)}
@media(prefers-reduced-motion:reduce){
  @view-transition{navigation:none}
  ::view-transition-group(*),::view-transition-old(*),::view-transition-new(*){animation:none}
}

/* ============ P5-4 text-wrap ============
   balance on headings so short headings do not leave one orphaned word on a
   line; pretty on body copy so paragraphs do not end on a single short word.
   Both degrade to normal wrapping where unsupported. */
h1,h2,h3,.sec-title,.pd-title,.hero-h1{text-wrap:balance}
p,li,figcaption,.hero-sub,.lead,.pd-sub{text-wrap:pretty}


/* ============ P4-2 card level: badge, proof figure, direct links ============
   The cards went from carrying two things to five. Sizing is deliberate: the
   proof figure is the second-loudest thing after the title because it is the
   only claim on the card, and the links are quiet because they are for people
   who already decided. Nested <a> is invalid, so the links sit inside the
   card anchor but stop its navigation on click. */
.proj-badge{position:absolute;top:.9rem;left:.9rem;z-index:3;font-family:var(--mono);
  font-size:var(--t-1);letter-spacing:.08em;text-transform:uppercase;font-weight:600;
  color:var(--ink);background:rgba(255,255,255,.92);border:1px solid var(--border2);
  padding:.3rem .6rem;border-radius:100px}
.proj-main{position:absolute;inset:0;z-index:1;display:block}
.proj-foot{position:absolute;left:0;right:0;bottom:0;z-index:2;padding:0 1.4rem 1.1rem;pointer-events:none}
.proj-links{pointer-events:auto}
.proj-proof{font-family:var(--mono);font-size:var(--t-2);color:var(--ink);font-weight:600;
  letter-spacing:-.01em}
.proj-links{display:flex;gap:.9rem;margin-top:.45rem}
.proj-link{font-size:var(--t-2);font-weight:600;color:var(--ink);
  display:inline-flex;align-items:center;min-height:24px;
  box-shadow:inset 0 -1px 0 0 var(--border2);
  transition:box-shadow .2s var(--ease)}
.proj-link:hover{box-shadow:inset 0 -1px 0 0 var(--ink)}
.proj-link:hover{border-color:var(--ink)}
/* the meta block sits above the foot now, so lift it clear */
.proj-meta{bottom:auto;top:auto;position:absolute;left:0;right:0;bottom:0;padding-bottom:5.2rem}
@media(max-width:820px){
  /* At 390 the card holds five things in the space that held two. The two that
     go are the ones a tap already provides: the links, since the whole card is
     a link to the page that carries them, and the category line, which the
     proof figure says better. Nothing shrinks. */
  .proj-links{display:none}
  .proj-meta{padding-bottom:3.4rem}
}


/* ============================================================================
   LIQUID GLASS + VIDEO HERO  (reusable, added Aug 2026)
   ----------------------------------------------------------------------------
   THE RECIPE. Four things make glass read as glass. Miss any one and you get a
   frosted card instead:
     1. Something worth seeing through it. A flat or near-flat backdrop cannot
        look like glass at any tint. Video or a high-structure photo only.
     2. A low tint. 20-45%. Above that the tint dominates and it reads solid.
     3. Edge refraction. The four .lg-edge strips each run their own backdrop
        pass through #distortion, so the backdrop visibly bends at the rim.
        This is the part most glassmorphism tutorials omit and it is the part
        that makes the panel read as a THICK pane.
     4. Directional specular. A bright rim, brighter top-left, plus a soft
        highlight, so light appears to come from somewhere.

   THE CONSTRAINT. Tint is the contrast control and it fights transparency.
   Dark glass + light text over dark footage is the combination that lets the
   tint go low, because light text on a dark backdrop keeps contrast for free.
   Light glass + dark text tops out around 55-60% tint and will never look as
   glassy. Pick the text color from the FOOTAGE, not from the page.

   NEVER measure this by eye. Sample composited pixels behind the text at
   several points in the loop and check the worst one against 4.5:1.

   NEVER put this behind a chart. Blur behind data is noise behind signal, and
   each pass is a GPU cost. Glass belongs on heroes, rails and artifact cards.

   MARKUP
     <section class="vhero">
       <div class="vhero-bg" aria-hidden="true">
         <video autoplay muted loop playsinline preload="metadata" poster="...">
           <source src="....webm" type="video/webm"><source src="....mp4" type="video/mp4">
         </video>
         <img class="vhero-still" src="...poster.webp" alt="" hidden>
         <div class="vhero-veil"></div>
       </div>
       <div class="vhero-in">
         <div class="lg">
           <div class="lg-fx" aria-hidden="true">
             <span class="lg-face"></span>
             <span class="lg-edge t"></span><span class="lg-edge b"></span>
             <span class="lg-edge l"></span><span class="lg-edge r"></span>
           </div>
           ...content...
         </div>
       </div>
     </section>
   main.js injects the #distortion filter once per page. Without JS the edges
   simply blur slightly instead of refracting: degraded, not broken.
   ========================================================================== */
:root{
  --lg-tint-1:rgba(20,22,26,.30);   /* upper-left, lighter                     */
  --lg-tint-2:rgba(10,12,15,.42);   /* lower-right, heavier: THE contrast knob */
  --lg-blur:16px;
  --lg-sat:155%;
  --lg-radius:28px;
  --lg-edge:14px;                   /* rim strip thickness                      */
}
.vhero{position:relative;margin-inline:calc(50% - 50vw);width:100vw;max-width:100vw;
       min-height:clamp(560px,82vh,780px);display:flex;align-items:center;
       overflow:hidden;isolation:isolate;background:#0c0e11}
.vhero-bg{position:absolute;inset:0;z-index:0;overflow:hidden}
.vhero-bg video,.vhero-bg img{width:100%;height:100%;object-fit:cover;display:block}
.vhero-veil{position:absolute;inset:0;pointer-events:none;
  background:
    linear-gradient(100deg,rgba(6,8,10,.46) 0%,rgba(6,8,10,.32) 42%,
                    rgba(6,8,10,.10) 74%,rgba(6,8,10,.04) 100%),
    linear-gradient(180deg,rgba(6,8,10,.34) 0%,rgba(6,8,10,0) 34%,
                    rgba(6,8,10,.38) 100%)}
.vhero-in{position:relative;z-index:1;width:100%;max-width:var(--max);
          margin:0 auto;padding:5.5rem 2rem 2.75rem}
@media(prefers-reduced-motion:reduce){ .vhero-bg video{display:none} }

/* ===== SECTION TABS =====
   Shared by every project page. Panels are CSS-hidden, never removed from the
   DOM, so the whole case study is still indexed, still found by in-page search,
   and still prints in full (see the print rule at the end of this block).
   Per-page appearance is five custom properties, set on .secnav in the page's
   own style block. Defaults here are the light-page values. */
.secnav{display:flex;gap:.4rem;margin:var(--secnav-mt,0) 0 0;padding:0 0 .9rem;
        overflow-x:auto;scrollbar-width:none;scroll-snap-type:x proximity;
        border-bottom:1px solid var(--border)}
.secnav::-webkit-scrollbar{display:none}
.secnav button{flex:none;scroll-snap-align:start;appearance:none;cursor:pointer;
  font:inherit;font-size:.8rem;line-height:1;white-space:nowrap;
  padding:.55rem .9rem;border-radius:100px;border:1px solid transparent;
  background:transparent;color:var(--ink-soft);
  transition:background .18s var(--ease),color .18s var(--ease),
             border-color .18s var(--ease)}
.secnav button:hover{background:var(--secnav-hover,rgba(17,17,16,.05));
  border-color:var(--border)}
.secnav button:focus-visible{outline:2px solid var(--secnav-focus,var(--ink));
  outline-offset:2px}
.secnav button[aria-selected="true"]{background:var(--secnav-on-bg,var(--ink));
  color:var(--secnav-on-ink,#fff);border-color:var(--secnav-on-bg,var(--ink))}

.tabpanel[hidden]{display:none}
/* the section rhythm assumes a scrolling page; at the top of a panel it is dead air */
.tabpanel > :first-child{margin-top:1.75rem}
/* the entrance only exists under no-preference, so the panel's base style stays
   visible and reduced motion never leaves a panel stuck at opacity:0 */
@media(prefers-reduced-motion:no-preference){
  .tabpanel:not([hidden]){animation:tabin .28s var(--ease) both}
  @keyframes tabin{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
}
@media(max-width:720px){ .secnav button{font-size:.76rem;padding:.5rem .75rem} }
@media print{
  .secnav{display:none}
  .tabpanel[hidden]{display:block !important}
}

.lg{position:relative;border-radius:var(--lg-radius);border:0;background:none;
    isolation:isolate;
    box-shadow:0 3px 6px rgba(0,0,0,.16),0 36px 80px -30px rgba(0,0,0,.55)}
.lg > *:not(.lg-fx){position:relative;z-index:1}
.lg-fx{position:absolute;inset:0;border-radius:inherit;overflow:hidden;z-index:0;
       pointer-events:none}
.lg-face{position:absolute;inset:0;
  background:linear-gradient(148deg,var(--lg-tint-1),var(--lg-tint-2));
  -webkit-backdrop-filter:blur(var(--lg-blur)) saturate(var(--lg-sat));
          backdrop-filter:blur(var(--lg-blur)) saturate(var(--lg-sat))}
.lg-edge{position:absolute;
  -webkit-backdrop-filter:url(#distortion) blur(1px);
          backdrop-filter:url(#distortion) blur(1px)}
.lg-edge.t{top:0;left:0;right:0;height:var(--lg-edge)}
.lg-edge.b{bottom:0;left:0;right:0;height:var(--lg-edge)}
.lg-edge.l{left:0;top:0;bottom:0;width:var(--lg-edge)}
.lg-edge.r{right:0;top:0;bottom:0;width:var(--lg-edge)}
.lg-fx::after{content:'';position:absolute;inset:0;border-radius:inherit;
  background:radial-gradient(130% 90% at 6% -8%,rgba(255,255,255,.22),rgba(255,255,255,0) 44%);
  box-shadow:inset 0 1px 0 rgba(255,255,255,.42),
             inset 1px 0 0 rgba(255,255,255,.20),
             inset -1px 0 0 rgba(255,255,255,.13),
             inset 0 -1px 0 rgba(255,255,255,.22)}
@supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))){
  .lg-face{background:rgba(10,12,15,.74)}
}
/* rim refraction is four live backdrop passes: real GPU cost, invisible small */
@media(max-width:720px){
  .lg{--lg-radius:22px}
  .lg-edge{display:none}
  .lg-face{-webkit-backdrop-filter:blur(12px) saturate(140%);
                   backdrop-filter:blur(12px) saturate(140%);
           background:rgba(10,12,15,.56)}
}


/* ============================================================================
   TOOL MARQUEE
   ----------------------------------------------------------------------------
   TO ADD OR REMOVE A TOOL, EDIT ONLY THE <li> LIST IN THE MARKUP.
   The second copy needed for a seamless loop is cloned by main.js and marked
   aria-hidden, so screen readers and find-in-page see each tool exactly once.
   An <li> with no <img> is fine: text-only tools line up with the rest.

   Deliberately not a client-logo wall: logos render as monochrome silhouettes,
   each one is captioned, and the speed is slow. Under prefers-reduced-motion it
   does not move at all and becomes a normal horizontally scrollable list.

   Icons stay silhouettes on hover too, they only brighten. Most of these SVGs
   are single-path marks with no fill attribute, so dropping the filter would
   paint them currentColor black and they would disappear on a dark canvas.
   Mixed sources also means half the row would light up in brand color and
   half would not, which looks like a bug rather than a effect.
   ========================================================================== */
.marq{position:relative;overflow:hidden;
  -webkit-mask-image:linear-gradient(90deg,transparent,#000 8%,#000 92%,transparent);
          mask-image:linear-gradient(90deg,transparent,#000 8%,#000 92%,transparent)}
.marq-track{display:flex;width:max-content;gap:0;
  animation:marq var(--marq-dur,46s) linear infinite}
.marq:hover .marq-track,.marq:focus-within .marq-track{animation-play-state:paused}
@keyframes marq{from{transform:translate3d(0,0,0)}to{transform:translate3d(-50%,0,0)}}
.marq ul{display:flex;align-items:center;gap:2.6rem;list-style:none;margin:0;
         padding:0 1.3rem;flex:none}
.marq li{display:flex;align-items:center;gap:.6rem;flex:none;
         color:var(--muted);transition:color .2s var(--ease)}
.marq li:hover{color:var(--ink)}
.marq img{width:26px;height:26px;object-fit:contain;flex:none;
  /* one silhouette color so eight unrelated brand palettes read as one row */
  filter:brightness(0) invert(1) opacity(.55);
  transition:filter .25s var(--ease)}
.marq li:hover img{filter:brightness(0) invert(1) opacity(1)}
.marq .tname{font-family:var(--mono);font-size:.72rem;letter-spacing:.08em;
             text-transform:uppercase;white-space:nowrap}
@media(prefers-reduced-motion:reduce){
  .marq{-webkit-mask-image:none;mask-image:none;overflow-x:auto;scrollbar-width:none}
  .marq::-webkit-scrollbar{display:none}
  .marq-track{animation:none;width:auto}
  .marq-track > ul[aria-hidden="true"]{display:none}
}
@media(max-width:720px){ .marq ul{gap:1.9rem} .marq img{width:22px;height:22px} }


/* ============================================================================
   PHOTO CAROUSEL (auto)
   ----------------------------------------------------------------------------
   TO ADD OR REMOVE A PHOTO, EDIT ONLY THE <figure> LIST. Dots are generated by
   main.js from the slide count, so the two can never drift apart.

   Crossfade rather than slide: no layout thrash and no horizontal overflow.
   It pauses on hover, on keyboard focus, when the tab is hidden, and whenever
   it scrolls out of view, so it is not burning frames off-screen.
   Under prefers-reduced-motion it never advances by itself and the dots become
   the only control, which is the point of the setting.
   ========================================================================== */
.pcar{position:relative;--pcar-ar:4/5}
.pcar-frame{position:relative;aspect-ratio:var(--pcar-ar);border-radius:18px;overflow:hidden;
  background:var(--surface2);border:1px solid var(--border)}
.pslide{position:absolute;inset:0;margin:0;opacity:0;visibility:hidden;
  transition:opacity .55s var(--ease),visibility 0s linear .55s}
.pslide.is-on{opacity:1;visibility:visible;transition:opacity .55s var(--ease)}
.pslide img{width:100%;height:100%;object-fit:cover;display:block}
.pcar-dots{display:flex;gap:.45rem;justify-content:center;margin-top:.9rem}
.pcar-dots button{appearance:none;border:0;padding:0;cursor:pointer;
  width:7px;height:7px;border-radius:50%;background:var(--border2);
  transition:background .25s var(--ease),transform .25s var(--ease)}
.pcar-dots button[aria-current="true"]{background:var(--accent,var(--ink));transform:scale(1.45)}
.pcar-dots button:focus-visible{outline:2px solid var(--accent,var(--ink));outline-offset:3px}
@media(prefers-reduced-motion:reduce){ .pslide,.pslide.is-on{transition:none} }

/* ============ PIXEL SNOW BACKGROUND ============
   Shared by index and the project pages since Aug 17 2026. The full write-up,
   the props and the shader are in snow-bg.js. A page opts in with a
   <canvas id="snowBg"> and that script; a page without them is untouched.

   Fixed, z-index -1: above the page background, below everything in flow. That
   is what keeps it off the project heroes, which paint their own opaque .vhero
   background and run a video over it.

   Decoration with a permanent animation, so it answers to both switches: the
   OS setting and the site's own footer pause button. Removed, not stilled: a
   frozen frame of snow is a static speckle over the whole page. The script
   also refuses to create a GL context at all under reduced motion. */
#snowBg{position:fixed;inset:0;width:100%;height:100%;z-index:-1;display:block;
  pointer-events:none;opacity:0;transition:opacity .5s var(--ease)}
#snowBg.on{opacity:1}
@media(prefers-reduced-motion:reduce){ #snowBg{display:none} }
html.motion-paused #snowBg{display:none}
