*{box-sizing:border-box}
html{scroll-behavior:auto}
/* ================= THEME ==================================================
   Every colour on this page is one of two channels at some alpha: INK on
   PAPER. Tokenising the CHANNEL rather than each colour means the whole site
   inverts by swapping two RGB triplets — `rgba(0,0,0,.45)` becomes
   `rgb(var(--ink) / .45)` and is correct in both themes without a second rule.

   The dark palette is NOT invented. #contact is already a dark section, and
   has been since before dark mode was a thought: its ground is rgba(10,9,7)
   and its text is #f4f2ee. Those are this site's dark end, already designed
   and already living next to the Monet. Extending them to the whole page
   makes the dark theme coherent by construction rather than by taste.

   SHADOWS DO NOT INVERT. Shadow is absence of light, not foreground, so it
   stays on its own --shade channel (black in both themes). Dark mode gets its
   lift from the hairline borders instead, which DO invert for free: a
   `rgb(var(--ink) / .1)` border is a faint dark line on white and a faint
   light line on black, which is exactly the right treatment at both ends.

   --chrome sits DARKER than --surface in light and LIGHTER in dark, preserving
   the relationship a window's title bar has to its body rather than the
   literal value. ========================================================= */
:root{
  color-scheme:light;
  --ink:10 10 10;          /* foreground channel */
  --paper:255 255 255;     /* page ground        */
  --surface:255 255 255;   /* window / card body */
  --chrome:244 244 242;    /* window title bar   */
  --mock-a:245 245 243;    /* placeholder hatch  */
  --mock-b:238 238 236;
  --ink-dim:90 90 90;      /* link hover         */
  --shade:0 0 0;           /* shadows — see above, never themed */
  /* The pull chain is BLACK in both themes — but it cannot be black the same
     way in each, and that is the whole trick. Three stops of a sphere:
     catch, body, terminator.
     In light the SILHOUETTE does the work; the catch is only a cool grey room
     reflection, so it reads as blackened steel rather than chrome. */
  --cord-hi:#8e8e8a; --cord-mid:#2b2b29; --cord-lo:#0d0d0c;
}
/* ---------- the pull chain ----------
   It hangs from the TOP EDGE OF THE VIEWPORT and belongs to nothing. An earlier
   version anchored it under the nav bar and faded it with the nav, on the
   theory that the bar was the ceiling of the room. That was wrong in practice:
   it made the chain read as a piece of UI, and an object that blinks in and out
   because a toolbar did is not an object at all. */
#cord{position:fixed;top:0;left:clamp(20px,4vw,64px);z-index:45;
  width:156px;height:286px;pointer-events:none;transition:opacity .3s ease}
#cord canvas{display:block;pointer-events:auto;touch-action:none;cursor:grab}
#cord canvas:active{cursor:grabbing}
/* the accessible control. It tracks the handle so focus lands where the object
   is, and it is what makes this a real button rather than a canvas you have to
   guess at: tap, Enter and Space all yank the chain. */
.cord-btn{position:absolute;top:0;left:0;width:34px;height:34px;padding:0;border:0;
  background:none;border-radius:50%;pointer-events:auto;cursor:pointer}
.cord-btn:focus-visible{outline:2px solid rgb(var(--ink) / .6);outline-offset:4px}
/* --cord-lift is scroll-driven: the chain rises out of frame as you leave the
   hero, leaving only the knob peeking below the top edge — still there, still
   grabbable, taking almost no room. A pure translate, so unlike shortening the
   chain (the idea this replaces) there is no physics running during the scroll.
   It used to be tied to the nav's visibility, which was wrong twice over: it
   made the chain part of the chrome, and it meant an OBJECT blinked in and out
   because a toolbar did. */
#cord{transform:translateY(var(--cord-lift,0px))}

/* ---------- the switch ----------
   A light switch has no crossfade. It has a DISCONTINUITY: the room is dark for
   a moment, and then you can see again. So the theme swap happens underneath a
   true blackout rather than as a transition between two palettes.

   What comes after the blackout is the actual idea. Things do not all return at
   once — the copy and the chrome arrive as the black lifts, and the paintings
   arrive last and slowest, because they are the dimmest things on the page and
   that is what eyes adjusting to a dark room actually do. --adapt is registered
   with @property purely so it can be ANIMATED; a plain custom property cannot
   be, and multiplying the plate's existing opacity calc is the only way to fade
   a layer whose opacity is already scroll-driven. */
@property --adapt{syntax:'<number>';inherits:false;initial-value:1}
#blackout{position:fixed;inset:0;z-index:200;background:#000;opacity:0;pointer-events:none;
  transition:opacity .34s cubic-bezier(.22,1,.36,1)}
#blackout.on{opacity:1;transition:opacity .09s ease-in}
/* THERE IS NO FLICKER HERE AND THERE MUST NOT BE ONE. A fluorescent stutter was
   built and removed: three full-viewport black-to-transparent flashes in 460ms
   is ~6.5Hz, which sits inside the 3-50Hz band WCAG 2.3.1 exists to prevent,
   and full-screen high contrast is the worst case for it. It was reported as
   hurting to look at, which is exactly the symptom. Characterful is not worth
   a photosensitivity risk, and the staggered return below was always the part
   carrying this moment anyway. */
@keyframes adapt{from{--adapt:0}to{--adapt:1}}
html.theme-settling .art-plate,html.theme-settling .contact-art{
  animation:adapt 760ms cubic-bezier(.22,1,.36,1) 200ms both}
@media (prefers-reduced-motion: reduce){
  #blackout{display:none}
  html.theme-settling .art-plate,html.theme-settling .contact-art{animation:none}
}

:root[data-theme="dark"]{
  color-scheme:dark;
  --ink:244 242 238;       /* #f4f2ee, as #contact has always used */
  --paper:14 13 11;        /* lifted a touch off the contact veil's 10 9 7 */
  --surface:26 25 22;
  --chrome:34 33 29;
  --mock-a:30 29 26;
  --mock-b:24 23 20;
  --ink-dim:165 162 155;
  --shade:0 0 0;
  /* Inverted to a pale matte chain in dark, not a black one with a lit catch —
     requested over the warm-gold/near-black version above. The three stops sit
     close together (light grey range, no near-black terminator, no bright
     specular catch) so the sphere shading stays soft instead of reading as a
     polished/shiny highlight. */
  --cord-hi:#e9e7e1; --cord-mid:#c9c7c0; --cord-lo:#8d8b85;
}
/* `scroll-snap-type: y proximity` used to live on the html rule above and is
   gone deliberately. It never held the hero -> Experience boundary (that is the
   JS settle in script.js -- snap-stop only governs a snap ANIMATION, and a
   trackpad drag is not one), but it DID tug at .projects-pin, a 340vh element
   carrying scroll-snap-align:start, the whole time you scroll through it. That
   tug reads as "laggy" when the frames are actually fine. */
body{margin:0;background:rgb(var(--paper));color:rgb(var(--ink));font-family:"Instrument Sans",Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased;overflow-x:hidden}
a{color:rgb(var(--ink));text-decoration:none}
a:hover{color:rgb(var(--ink-dim))}
::selection{background:rgb(var(--ink));color:rgb(var(--paper))}

/* ---------- nav ---------- */
/* backdrop-filter on a FIXED, full-width bar is re-composited on every scroll
   frame for the whole page. The nav is collapsed and invisible past 60% of the
   first viewport, so .bare drops the blur (and the translucent fill with it)
   while it is hidden — the only thing still painting there is the 1px progress
   hairline, which needs no backdrop at all. */
/* THERE IS NO BAR. Not a transparent one, not a glass one — no container at
   all, just four words sitting at the top of the home screen. A full-width
   fixed element with a backdrop blur was the single most generic-website thing
   on a page where everything else is an object or typography, and it was also
   what made the pull chain read as chrome rather than as something hanging in
   a room.
   It belongs to the HERO, not to the site: --nav-fade is scroll-driven, so the
   words go with the first screen and do not come back until you return to it.
   No peek, no reappearing toolbar. The brand wordmark is gone too — the only
   screen these words appear on is the one with the name set at 160px. */
#nav{position:fixed;top:0;left:0;right:0;z-index:50;pointer-events:none;
  opacity:var(--nav-fade,1)}
/* Words RIGHT, chain LEFT. They were the other way round and the composition
   read wrong: the chain landed on the same side as the "AI Engineer" tag, which
   already anchors the lower right, so the top right was crowded and the top
   left empty. Swapped, the two objects sit on opposite diagonals and the hero
   is balanced. Text one side, object the other — just the other way. */
#nav-inner{height:72px;display:flex;align-items:center;justify-content:flex-end;padding:0 56px;position:relative}
/* Bigger and further apart. At 12.5px with a 26px gap these read as the
   footnotes of a toolbar that no longer exists; at 15px with a 46px gap they
   read as four words placed on the page, which is what they now are. The gap is
   doing real work — it is what stops them re-forming into a "bar" in the eye. */
#nav .nav-links{display:flex;gap:46px;font-size:15px;letter-spacing:-.01em;
  color:rgb(var(--ink) / .5);pointer-events:auto}
#nav .nav-links a{color:inherit;display:inline-block;position:relative;
  transition:color .25s cubic-bezier(.22,1,.36,1),transform .28s cubic-bezier(.22,1,.36,1)}
#nav .nav-links a:hover{color:rgb(var(--ink))}
/* the underline grows from the left on hover — the same gesture .exp-term uses,
   so the two hover affordances on this page are one idea rather than two */
#nav .nav-links a::after{content:'';position:absolute;left:0;right:0;bottom:-5px;height:1px;
  background:rgb(var(--ink) / .75);transform:scaleX(0);transform-origin:0 50%;
  transition:transform .34s cubic-bezier(.22,1,.36,1)}
#nav .nav-links a:hover::after{transform:scaleX(1)}
/* entrance: they arrive after the name has landed, staggered left to right */
@keyframes navIn{from{opacity:0;transform:translateY(-7px)}to{opacity:1;transform:none}}
#nav .nav-links a{animation:navIn .7s cubic-bezier(.16,1,.3,1) backwards}
#nav .nav-links a:nth-child(1){animation-delay:.62s}
#nav .nav-links a:nth-child(2){animation-delay:.70s}
#nav .nav-links a:nth-child(3){animation-delay:.78s}
#nav .nav-links a:nth-child(4){animation-delay:.86s}
@media (prefers-reduced-motion: reduce){
  #nav .nav-links a{animation:none}
  #nav .nav-links a::after{transition:none}
}
#progress{position:absolute;left:0;top:0;height:1px;width:0%;background:rgb(var(--ink))}

/* ---------- case overlay ---------- */
.case-overlay{position:fixed;inset:0;z-index:60;background:rgb(var(--paper));overflow-y:auto}
.case-topbar{height:56px;display:flex;align-items:center;justify-content:space-between;padding:0 56px;border-bottom:1px solid rgb(var(--ink) / .09);position:sticky;top:0;background:rgb(var(--paper) / .92);backdrop-filter:blur(14px);z-index:2}
.case-topbar .brand{font-size:13.5px;font-weight:600;letter-spacing:-.02em}
.case-close{font-size:12.5px;color:rgb(var(--ink) / .55);cursor:pointer;transition:color .2s}
.case-close:hover{color:rgb(var(--ink))}
.case-body{margin:0;padding:64px 56px 120px;gap:40px}
.case-rail{align-self:start;display:grid;gap:22px}
.label-sm{font-size:11.5px;color:rgb(var(--ink) / .62);margin-bottom:7px}
.case-title{font-size:15px;font-weight:600;letter-spacing:-.02em;line-height:1.3}
.val-13{font-size:13px;color:rgb(var(--ink) / .7)}
.val-13.lh{line-height:1.6}
.case-gh{font-size:12.5px;color:rgb(var(--ink) / .62);border-bottom:1px solid rgb(var(--ink) / .2);padding-bottom:2px;justify-self:start}
.case-gh-disabled{color:rgb(var(--ink) / .35);border-bottom-color:rgb(var(--ink) / .1);pointer-events:none}
.case-main{display:grid;gap:52px;min-width:0}
.mock{background:repeating-linear-gradient(135deg,rgb(var(--mock-a)) 0 8px,rgb(var(--mock-b)) 8px 16px);border:1px solid rgb(var(--ink) / .09);display:flex;align-items:center;justify-content:center}
.mock-16-9{aspect-ratio:16/9}
.case-h{border-top:1px solid rgb(var(--ink) / .16);padding-top:12px;font-size:13px;font-weight:500;margin-bottom:20px}
.case-p{font-size:16px;line-height:1.65;color:rgb(var(--ink) / .72);max-width:70ch;text-wrap:pretty}
.case-next{border-top:1px solid rgb(var(--ink) / .16);padding-top:16px;display:flex;justify-content:space-between;align-items:baseline;cursor:pointer}
.case-next-title{font-size:30px;font-weight:600;letter-spacing:-.035em}
.case-next-cta{font-size:13px;color:rgb(var(--ink) / .62)}

/* ---------- hero ---------- */
.hero-fixed{position:fixed;inset:0;z-index:0;overflow:hidden}
/* .gone is set once .sheet has fully covered the hero (see onScroll). Both the
   visibility and the will-change go together: this layer contains the WebGL
   canvas, so leaving it promoted while parked behind the page is the most
   expensive idle layer on the site. */
.hero-fixed.gone{visibility:hidden}
.hero-fixed:not(.gone) .hero-inner{will-change:transform,opacity}
.hero-inner{position:absolute;inset:0;transform-origin:50% 50%;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:0 24px;text-align:center}
.hero-content{width:100%;display:flex;flex-direction:column;align-items:center}
.hero-name-wrap{position:relative;display:inline-block}
/* invisible but still laid out: it fixes the type metrics, gives the WebGL
   pass a DOM rect to rasterize the name into, and anchors .hero-tag */
.hero-name-measure{position:relative;display:block;margin:0;color:transparent;user-select:none;-webkit-user-select:none;font-weight:600;letter-spacing:-.07em;line-height:1;white-space:nowrap;font-size:clamp(72px,19vw,320px)}
/* the bulge plane spans the whole hero, so the dome can reach past the
   glyph edges and the cursor drives it from anywhere on screen */
.hero-gl{position:absolute;inset:0;z-index:0;pointer-events:none}
.hero-content{position:relative;z-index:1}
/* staggered to land just after the name's letters converge (see hero-bulge.js).
   It used to be visible from first paint, which left it hanging alone in an
   empty hero while the name was still being rasterised and animated in. */
.hero-tag{position:absolute;top:100%;right:0;margin-top:clamp(8px,1.6vw,18px);display:flex;align-items:center;gap:7px;font-size:clamp(14px,1.5vw,19px);font-weight:500;letter-spacing:-.005em;color:rgb(var(--ink) / .6);white-space:nowrap;
  opacity:0;transform:translateY(10px);transition:opacity .7s cubic-bezier(.16,1,.3,1),transform .7s cubic-bezier(.16,1,.3,1)}
body.hero-in .hero-tag{opacity:1;transform:none}
.hero-tag-arrow{color:rgb(var(--ink) / .48);flex-shrink:0}
.hero-spacer{height:100vh;min-height:660px}

.sheet{position:relative;z-index:1;background:rgb(var(--paper));border-radius:20px 20px 0 0;box-shadow:0 -30px 60px -24px rgb(var(--shade) / .26),0 -1px 0 rgb(var(--shade) / .06)}

/* ---------- shared rail grid (case overlay only) ---------- */
.rail-grid{margin:0;display:grid;grid-template-columns:minmax(0,1fr);gap:40px}
.label-rail{border-top:1px solid rgb(var(--ink) / .16);padding-top:12px;font-size:13px;font-weight:500}
@media (min-width:1100px){
  .rail-grid{grid-template-columns:clamp(120px,13vw,190px) minmax(0,1fr);gap:clamp(40px,4vw,72px)}
  .rail-grid > div:first-child .label-rail,
  .rail-grid > div:first-child{position:sticky;top:104px}
  .case-grid .case-rail{grid-template-columns:unset}
}

/* ---------- shared top-label / centered-content section (Experience, Projects, Stack) ---------- */
.section-stage{position:relative;min-height:100vh;margin:0;padding:0 56px;display:flex;align-items:center;justify-content:center;scroll-snap-align:start}
#experience{scroll-snap-stop:always}
.rail-col-only{position:absolute;top:clamp(40px,7vh,96px);left:56px;width:auto}

[data-reveal],.reveal{opacity:0;transform:translateY(28px);transition:opacity .8s cubic-bezier(.16,1,.3,1),transform .8s cubic-bezier(.16,1,.3,1)}
[data-reveal].in,.reveal.in{opacity:1;transform:none}

/* ---------- experience ---------- */
/* only plays once the hero sheet has actually dragged fully open (gated by
   scroll position in JS, not just "a sliver is visible"), with a heavier
   curtain-style wipe instead of the generic fade+rise used elsewhere */
.exp-curtain{opacity:0;transform:translateY(50px) scale(.97);clip-path:inset(0 0 100% 0);transition:opacity 1s cubic-bezier(.16,1,.3,1),transform 1s cubic-bezier(.16,1,.3,1),clip-path 1.1s cubic-bezier(.16,1,.3,1)}
.exp-curtain.in{opacity:1;transform:none;clip-path:inset(0)}
/* only while the entrance is actually running — see setupExpCurtain() */
.exp-curtain.landing{will-change:clip-path,opacity,transform}
.exp-stage{display:flex;justify-content:center;align-self:center;width:100%;min-width:0}
.exp-block{width:100%;max-width:clamp(1080px,80vw,1400px);display:grid;grid-template-columns:minmax(0,1fr);gap:32px}
.exp-divider{display:none}
.exp-copy{font-size:clamp(22px,2.6vw,36px);line-height:1.48;letter-spacing:-.03em;color:rgb(var(--ink) / .45);text-wrap:pretty;max-width:62ch}
.exp-term{
  color:rgb(var(--ink) / .78);cursor:default;padding-bottom:2px;
  background-repeat:no-repeat,no-repeat;
  background-position:0 100%,0 100%;
  background-image:linear-gradient(rgb(var(--ink)),rgb(var(--ink))),linear-gradient(rgb(var(--ink) / .18),rgb(var(--ink) / .18));
  background-size:0% 1px,100% 1px;
  transition:color .35s cubic-bezier(.16,1,.3,1),background-size .45s cubic-bezier(.16,1,.3,1)
}
.exp-term.active{color:rgb(var(--ink));background-size:100% 1px,100% 1px}

/* a small mac-window card, reusing the same visual language as the Projects
   carousel window (border, radius, traffic-light dots, shadow) so this reads
   as a deliberate reuse of a pattern the page already established */
.exp-win{display:flex;flex-direction:column;border-radius:10px;overflow:hidden;border:1px solid rgb(var(--ink) / .1);background:rgb(var(--surface));box-shadow:0 26px 54px -32px rgb(var(--shade) / .32),0 6px 16px -12px rgb(var(--shade) / .22);transition:transform .5s cubic-bezier(.16,1,.3,1);align-self:stretch}
.exp-win-bar{position:relative;flex:none;height:34px;background:rgb(var(--chrome));border-bottom:1px solid rgb(var(--ink) / .07);display:flex;align-items:center;gap:7px;padding:0 14px}
.exp-win-bar .dot{width:9px;height:9px}
/* absolutely positioned across the whole bar: as a flex sibling it could
   only centre inside the space left after the dots, which pushed it ~24px
   right of the bar's real centre */
.exp-win-title{position:absolute;left:0;right:0;text-align:center;pointer-events:none;font-size:11px;color:rgb(var(--ink) / .4);letter-spacing:.02em}
@keyframes expWinPulse{0%{transform:scale(1)}40%{transform:scale(.966)}100%{transform:scale(1)}}
.exp-win.pulse{animation:expWinPulse .55s cubic-bezier(.34,1.56,.64,1)}

/* THE WINDOW ARRIVES AFTER THE SENTENCE, NOT WITH IT.
   Reading the copy is the point of this section and the window is the footnote
   that answers it, so a card of this weight landing on the same beat pulled the
   eye off the line mid-clause. It gets its own delayed, slightly slower
   entrance inside the curtain instead of riding the block's.
   The delay is safe only because .exp-win.handoff hard-resets transform AND
   transition with !important — this rule is more specific, so without that the
   match cut's snap-to-resting-box would animate over .85s instead of snapping,
   which is the exact bug that put two windows on screen. */
.exp-curtain .exp-win{opacity:0;transform:translateY(24px)}
.exp-curtain.in .exp-win{opacity:1;transform:none;
  transition:opacity .8s cubic-bezier(.16,1,.3,1) .5s,transform .9s cubic-bezier(.16,1,.3,1) .5s}

.exp-meta-box{flex:1;display:flex;flex-direction:column;padding:30px 34px 34px;min-height:190px;clip-path:inset(0);will-change:opacity,clip-path}
.exp-meta-line{font-size:13.5px;color:rgb(var(--ink) / .55);margin-bottom:14px}
.exp-meta-text{font-size:17px;line-height:1.72;color:rgb(var(--ink) / .62);text-wrap:pretty}
.exp-meta-note{font-size:17.5px;font-weight:400;letter-spacing:-.005em;margin-top:auto;padding-top:22px;color:rgb(var(--ink))}

@media (min-width:800px){
  .exp-block{grid-template-columns:minmax(0,1fr) 1px minmax(370px,.92fr);column-gap:clamp(40px,5vw,76px);align-items:stretch}
  .exp-divider{display:block;width:1px;align-self:stretch;background:rgb(var(--ink) / .12)}
}

/* ---------- projects ---------- */
.projects-pin{position:relative;height:340vh;margin:0;padding:0 56px;scroll-snap-align:start;scroll-snap-stop:always}
.projects-sticky{position:sticky;top:0;height:100vh;display:flex;align-items:center;justify-content:center}
.projects-stage{display:flex;flex-direction:column;align-items:center;justify-content:center;width:100%;min-width:0;
  perspective:1400px}

/* ---------- Projects exit: the window shuts ----------
   Experience's window travelled here and grew into this one (see the match cut
   above). This is the other end of that story: on the way out it folds shut
   like a lid, hinged along its bottom edge — and the section it folds down onto
   is Stack, which is a keyboard. A screen closing onto a keyboard, so the
   boundary joins the two sections instead of merely ending one.

   WHY A WRAPPER AND NOT #macwin ITSELF: setMacwin() in script.js writes an
   INLINE transform (it is the single writer for the window's arrival, because
   the match cut lands it), and an inline style outranks any stylesheet rule.
   Folding a parent keeps the two transforms on separate elements and leaves
   that contract untouched. The match cut is unaffected: it reads .macwin's
   LAYOUT box via offsetWithin(), which walks the offsetParent chain and
   already had to cope with .projects-stage being a transformed containing
   block — one more link is summed the same way. --exit is 0 during the cut
   anyway, so this is identity while the ghost is flying.

   rotateX is positive with the origin at the bottom edge: positive rotation
   about +X takes +Y (down) toward the viewer, so with the bottom pinned the
   TOP swings away and down. That is the lid direction. */
.macwin-hinge{width:100%;min-width:0;
  transform-origin:50% 100%;
  transform:rotateX(calc(72deg * var(--exit,0)))}
/* promoted only while it is actually folding — never permanently (that mistake
   is documented in the perf notes) */
.projects-stage.exiting .macwin-hinge{will-change:transform}
/* a surface turning away from the light. A flat fold with no shading reads as
   a CSS trick; this is what sells it as an object. Solid fill, opacity only. */
.macwin-shot::after{content:'';position:absolute;inset:0;background:#0a0a0a;--dim:0;opacity:var(--dim);pointer-events:none}
/* ...and the SAME veil earns a second job in dark mode. The project shots are
   real app screenshots and two of the three are light UIs, so on a #0e0d0b page
   they blaze — a 680x380 white block that pulls every bit of attention off the
   rest of the section. A mac window showing a light app on a dark desktop is
   realistic, but realism is not the brief. Knocking it back ~18% leaves it far
   and away the brightest thing on the page while stopping the glare.
   It is a CONSTANT overlay opacity, not a filter: no per-frame cost, and the
   full-brightness shot is one click away in the case overlay. */
:root[data-theme="dark"] .macwin-shot::after{--dim:.18}
/* composed from --dim so the fold starts wherever the theme left it */
.projects-stage.exiting .macwin-shot::after{
  opacity:calc(var(--dim) + (.45 - var(--dim)) * var(--exit));will-change:opacity}
/* .sec-exit's generic fade would be finished before the fold had read, so this
   section holds its opacity until the lid is most of the way down and then
   goes in the last 40%. Everything else about .sec-exit is left alone. */
.projects-stage.sec-exit{opacity:clamp(0,calc(1 - (var(--exit) - .6) * 2.5),1)}
@media (prefers-reduced-motion:reduce){
  .macwin-hinge{transform:none}
  .macwin-shot::after{display:none}
  .projects-stage.sec-exit{opacity:calc(1 - var(--exit))}
}
/* the vertical budget (100vh - 300px) reserves the title bar, the indicator
   foot and the section's breathing room; the 1080px cap is what the 16:9
   screenshots can carry before the UI inside them starts looking upscaled.
   Three stacked shadows: a hairline contact edge, a mid drop, and a wide,
   very faint ambient pool that lifts the window off the painting behind it */
/* Three layers: a contact shadow, a mid lift, and a wide soft pool. The near
   two were raised (.05 -> .06 at 2px, and .18 -> .26 at 36px) because this card
   lands on a pale Monet under a 72%-white veil, which washed the old values out
   — the window read as printed on the page rather than sitting above it. The
   far pool is unchanged: it is what gives the depth, and deepening it as well
   would have turned into a grey halo against that backdrop.
   NOTE: .matchcut carries this class too, so the travelling ghost gets the same
   shadow by construction — which is the point, since it has to hand over to
   this window without the shadow popping. */
.macwin{width:100%;max-width:min(100%,1080px,max(360px,calc((100vh - 300px) * 16 / 9)));margin-inline:auto;border-radius:12px;overflow:hidden;border:1px solid rgb(var(--ink) / .1);box-shadow:0 2px 4px rgb(var(--shade) / .06),0 18px 36px -16px rgb(var(--shade) / .26),0 60px 110px -45px rgb(var(--shade) / .34);background:rgb(var(--surface));opacity:0;transform:scale(.92) translateY(26px);transform-origin:50% 62%}
.macwin-bar{height:36px;background:rgb(var(--chrome));border-bottom:1px solid rgb(var(--ink) / .07);display:flex;align-items:center;gap:8px;padding:0 14px}
.dot{width:11px;height:11px;border-radius:50%;cursor:pointer;opacity:.92;transition:opacity .2s}
.dot:hover{opacity:1}
.dot-red{background:#ff5f57}
.dot-yellow{background:#febc2e}
.dot-green{background:#28c840}
.macwin-slug{flex:1;text-align:center;font-size:11px;color:rgb(var(--ink) / .45);letter-spacing:.01em}
.macwin-spacer{width:44px}
.macwin-shot{position:relative;aspect-ratio:16/9;overflow:hidden;background:rgb(var(--surface));cursor:pointer}

/* no will-change: three panels x a 3420px-wide screenshot is a lot of texture
   to pin permanently, and the clip-path/opacity transition here runs only on a
   discrete slide change (not per scroll frame), which the browser promotes for
   the duration on its own */
.panel{position:absolute;inset:0;background:repeating-linear-gradient(135deg,rgb(var(--mock-a)) 0 8px,rgb(var(--mock-b)) 8px 16px);display:flex;align-items:center;justify-content:center;opacity:0;clip-path:inset(0 0 0 100%);transition:opacity .7s cubic-bezier(.16,1,.3,1),clip-path .9s cubic-bezier(.16,1,.3,1)}
.panel[data-panel="0"]{opacity:1;clip-path:inset(0)}

/* real project screenshots, top-anchored so the page header survives the 16:9
   crop (the captures are ~1.74-1.80 aspect, so it costs only a few percent).
   Written as .panel.shot / #case-hero.shot rather than a bare .shot because
   .panel and .mock both use the `background` shorthand, which would otherwise
   reset background-size and background-position out from under it. */
.panel.shot,#case-hero.shot{background-image:var(--shot);background-color:rgb(var(--surface));
  background-size:cover;background-position:50% 0;background-repeat:no-repeat}
#case-hero.shot{border:1px solid rgb(var(--ink) / .09)}
.projects-foot{width:100%;max-width:min(100%,940px,max(360px,calc((100vh - 340px) * 16 / 9)));margin-inline:auto;padding-top:20px;text-align:center}
.ind{display:flex;align-items:center;justify-content:center;gap:5px;opacity:.28;transition:opacity .7s cubic-bezier(.16,1,.3,1)}
.track-track{width:18px;height:1px;background:rgb(var(--ink) / .09);cursor:pointer;overflow:hidden}
.track-fill{height:100%;width:100%;background:rgb(var(--ink) / .5);transform-origin:left;transform:scaleX(0)}
.track-fill[data-track="0"]{transform:scaleX(1)}
.caption{height:54px;display:flex;align-items:center;justify-content:center}
.active-title{font-size:clamp(18px,1.5vw,23px);font-weight:400;letter-spacing:-.022em;line-height:1.24;max-width:34ch;color:rgb(var(--ink) / .7);clip-path:inset(0);will-change:clip-path,opacity}

/* ---------- match cut: Experience window -> Projects window ----------
   Both sections show the same object — a mac window with three dots. Until now
   one faded out and an unrelated one scaled in, so the page had two windows
   that merely resembled each other. This makes it ONE window that travels and
   grows across the section boundary: the cut becomes a camera move.

   It is a real FLIP, but driven by scroll position rather than time, so it
   scrubs and reverses. A GHOST does the travelling instead of either real
   window, for two reasons: #experience carries a transform (.sec-exit), and a
   transformed ancestor becomes the containing block for position:fixed, so the
   real .exp-win cannot be pinned to the viewport without being reparented;
   and the two windows have different aspect ratios (1.29 vs 1.67), so a single
   scaled element would distort its own chrome. The ghost animates width and
   height instead — it is position:fixed and therefore out of flow, so resizing
   it dirties nothing but itself.

   It reuses .macwin verbatim, so the frame it travels with is the Projects
   window's frame by construction and cannot drift out of sync with it. */
.matchcut{position:fixed;z-index:3;top:0;left:0;display:none;flex-direction:column;
  opacity:0;pointer-events:none;contain:layout paint}
.matchcut.live{display:flex}
.matchcut-body{position:relative;flex:1;min-height:0;overflow:hidden;background:rgb(var(--surface))}
.matchcut-to{position:absolute;inset:0;background-image:var(--shot);background-size:cover;
  background-position:50% 0;opacity:0}
/* The real windows stand down while the ghost carries the move. .exp-win's
   opacity is driven from JS as the exact complement of the ghost's, so the two
   cross-fade against each other instead of blinking at the handover.

   THE TRANSFORM RESET IS LORE-BEARING. The ghost starts from this window's
   RESTING box — a layout measurement, taken with offsetTop/offsetWidth
   precisely so that no transform can corrupt it. The flip side of that choice
   is that the real window must actually BE at its resting box while the two
   are dissolving into each other, or you see both of them, a few percent
   apart, at half opacity each. .exp-win has two transform sources and BOTH
   fire during normal reading of the section:
     - `.pulse` (@keyframes expWinPulse, scale .966) on every .exp-term hover,
     - the .5s transform transition on its own base rule.
   Hovering a term is HOW the section is read, so a pulse still in flight when
   the handoff opens is the normal case, not an edge case — it is exactly what
   produced the reported duplicate. Snapping to the resting box is the right
   trade: worst case the window settles 3.4% in one frame, onto the very spot
   the ghost is already standing on. */
.exp-win.handoff{pointer-events:none;animation:none!important;transform:none!important;transition:none!important}
/* ...and the same contract has to hold for the ANCESTOR that actually carries
   the section's entrance. This is the one that produced the reported duplicate.
   .exp-curtain starts at `translateY(50px) scale(.97)` and takes a full second
   to resolve, and setupExpCurtain() fires it at 0.92vh — while the match-cut
   band opens at ~0.95vh. Three hundredths of a viewport, about 26px of scroll,
   between the start of a 1s entrance and the start of the exit that assumes it
   has finished. So on a normal scroll-through the real window sat 50px low and
   3% small while the ghost stood at the resting box, and you saw both.
   Snapping the entrance to its end state the moment the handoff opens makes
   the invariant true by construction: a section that is leaving has arrived.
   Anyone who outruns the 1s entrance never saw it anyway — and the trigger has
   been moved earlier (script.js) so that almost nobody does. */
.exp-curtain.handoff{transform:none!important;opacity:1!important;clip-path:inset(0)!important;transition:none!important}
/* .macwin's own opacity/transform are owned by setMacwin() in script.js — the
   ghost lands it, so it has no entrance of its own to play. */

/* ---------- stack ---------- */
.stack-wrap{display:flex;flex-direction:column;align-items:center;justify-content:center;align-self:center;width:100%;padding:0}
.tech-keyboard-el{width:min(920px,88vw);height:min(54vh,540px);display:block;opacity:0;transition:opacity 1s cubic-bezier(.16,1,.3,1)}
.stack-wrap.in .tech-keyboard-el{opacity:1}
.stack-caption{margin-top:40px;text-align:center;min-height:52px;opacity:0;transition:opacity .8s cubic-bezier(.16,1,.3,1) .5s}
.stack-wrap.in .stack-caption{opacity:1}
.key-label{font-size:clamp(18px,2vw,26px);font-weight:600;letter-spacing:-.03em;display:inline-block;transform-origin:50% 60%}
@keyframes keyClick{0%{transform:scale(1)}35%{transform:scale(.9) translateY(2px)}100%{transform:scale(1) translateY(0)}}
.key-label.click{animation:keyClick .22s cubic-bezier(.34,1.56,.64,1)}
.key-note{font-size:13px;color:rgb(var(--ink) / .62);margin-top:7px}

/* ---------- section exits ---------- */
/* Every section animated IN and then simply scrolled away. This gives each a
   symmetric exit so its content dissolves upward as the next arrives, matching
   what the paintings do underneath it.
   Applied to the SECTION (or, for the pinned carousel, to its stage) and never
   to an element that already carries .reveal or .exp-curtain: those set
   `transform:none` at a higher specificity when they land, which would win. */
.sec-exit{--exit:0;opacity:calc(1 - var(--exit));
  transform:translateY(calc(-46px * var(--exit))) scale(calc(1 - .035 * var(--exit)))}
/* The exit blur is GONE (it was `filter:blur(5px * var(--eb))`). Same verdict
   as the hero's: a filter on a live, scrolling element re-rasterises the whole
   layer for every distinct radius, and .projects-stage wears this class while
   holding three full-size screenshots. Quantising only reduced the count of
   re-rasters, never the cost of one. Nothing is lost visually — it was fighting
   an opacity fade to zero and a scale-down at the same time. */
.sec-exit.exiting{will-change:opacity,transform}

/* ---------- painting backdrops ---------- */
/* FIXED, so a plate always covers the viewport: clipping a dissolving layer to
   its own section box puts a razor edge along the section border while it is
   only partly on screen. z-index 0 keeps it under the section content, which
   is lifted to 1 below. Each plate supplies its own --img and --peak. */
/* --dy is a slow counter-drift: the painting travels a fraction of the distance
   the section's content does, which is what actually makes a backdrop read as
   being BEHIND something rather than printed on the same plane. Kept inside
   +/-34px, which the ::before's -60px overhang covers, so the plate can never
   drag its own edge into view.
   --ps is --p quantised, and drives the SCALE only. --p itself stays smooth for
   the opacity: a scale change re-rasterises a blurred multi-megapixel image,
   an opacity change does not, so the two must not share a value. */
.art-plate{position:fixed;inset:0;z-index:0;pointer-events:none;--p:0;--f:0;--ps:0;--dy:0;--peak:.62;--pos:50% 44%;
  opacity:calc(var(--p) * var(--peak) * var(--adapt));
  transform:translate3d(0,calc(var(--dy) * 1px),0) scale(calc(1.06 - .04*var(--ps)));
  visibility:hidden}
/* only composited while on screen: fixed, blurred, full-viewport layers left
   live for the whole page would cost memory behind the hero's WebGL for nothing */
/* will-change lives HERE, not on the base rule: a plate that is off screen has
   no business holding a promoted full-viewport layer for a 9-15MP painting */
.art-plate.on{visibility:visible;will-change:opacity,transform}
/* the painting carries the filter; the veil above it must NOT, or contrast(.9)
   would tint the veil's white to ~#f2f2f2 and cost it a chunk of its job */
/* inset is NEGATIVE: a blur samples past its own layer box, so at the start of
   the focus-pull (18px, --f:0) each plate was fading out its own four edges
   and reading as a soft-cornered rectangle rather than a full-bleed image.
   Overhanging the viewport by more than the largest radius keeps them solid. */
/* --blur-min is a floor the focus-pull never resolves below, for a plate meant
   to stay soft once it has bloomed. Whether a painting can take one depends
   entirely on the painting — see the per-plate notes below. */
/* THE FOCUS-PULL IS A CROSS-FADE, NOT AN ANIMATED RADIUS.
   This used to be one layer with `blur(18px * (1 - var(--f)))`, which means the
   browser re-runs an 18px Gaussian over a full-viewport painting for every
   distinct value of --f — and at a section boundary TWO plates are doing it at
   once. Quantising --f to ten steps only bought ten re-rasters instead of sixty.
   Now there are two layers, each with a CONSTANT filter, so each rasterises
   exactly once for the life of the page, and the pull is an opacity cross-fade
   between them. Opacity on a promoted layer is a compositor-only operation: it
   costs the same whether the layer is 100px or 9 megapixels. --f can therefore
   go back to being perfectly smooth, which it now is. */
/* NO `filter` ON THIS PAGE'S BACKDROPS AT ALL ANY MORE — see scratchpad/bake.py.
   Once the cross-fade made every radius and every grade CONSTANT, the whole
   chain was work the GPU redid for a result identical on every frame of the
   page's life, so it was baked into the files instead (`-graded` / `-soft`).
   The grade is the spec's own maths in source order; `-graded` also carries
   that plate's --blur-min.
   The soft copy is 240px wide and ~5KB. An 18px Gaussian over a 2400px painting
   IS a 240px thumbnail scaled up — the browser's upscale does the blur for
   free. Checked against a real Gaussian at display size: mean difference 0.08
   to 0.15 standard deviations, i.e. nothing, at 30-62% opacity under a veil. */
.art-plate::before,.art-soft{content:'';position:absolute;inset:-60px;
  background-size:cover;background-position:var(--pos)}
.art-plate::before{background-image:var(--img)}
/* the soft copy sits ON TOP of the sharp one and fades away as focus resolves */
.art-soft{background-image:var(--soft);opacity:calc(1 - var(--f))}
.art-plate.on .art-soft{will-change:opacity}
/* A vertical BAND, not a radial pool. Section copy runs nearly the full width,
   so a centred ellipse faded out exactly where its two ends sit — measured
   worst contrast 3.05 against a 3.35 white-page baseline. Washing a full-width
   band across the middle instead measures 3.32, i.e. the painting costs the
   copy essentially nothing, while still reading strongly top and bottom. */
.art-plate::after{content:'';position:absolute;inset:0;background:linear-gradient(180deg,
  rgb(var(--paper) / 0) 0%,rgb(var(--paper) / .65) 12%,rgb(var(--paper) / .92) 26%,
  rgb(var(--paper) / .92) 76%,rgb(var(--paper) / .55) 90%,rgb(var(--paper) / 0) 100%)}

/* .62 -> .46. The Magpie is the palest of the three paintings, which is why it
   carried the highest peak, but Experience is also the most READING-heavy
   section on the page — a long sentence at 45% black — and the backdrop was
   competing with it. Still above Projects (.32) and Stack (.30), because a
   high-key painting needs more opacity than they do to register at all. */
.art-plate[data-art-for="experience"]{--img:url('uploads/monet-the-magpie-graded.jpg');--soft:url('uploads/monet-the-magpie-soft.jpg');--pos:50% 44%;--peak:.46}
/* the keyboard is a bright 3D render floating on white and sits dead centre,
   so this one is held back harder and its veil band is widened below */
/* Projects is the busiest section on the page and the mockups have to stay
   the subject, so its veil keeps a floor at both edges instead of clearing
   to nothing rather than leaning on a stronger peak/saturation to hold it back */
/* Monet's Grainstacks: the series is the same subject painted over and over
   under changing light, which is what a projects section is. Warm gold fills
   the tonal gap between the white Magpie and the blue Sunrise. No figures. */
/* Grainstacks DOES take a blur floor where Poplars does not: the haystacks are
   big soft masses, so their silhouettes still read at 5px, where the Poplars'
   fine verticals are gone by 3px. Held right back (.62 -> .32) so the mockups
   are unambiguously the subject. */
.art-plate[data-art-for="projects"]{--img:url('uploads/monet-grainstacks-graded.jpg');--soft:url('uploads/monet-grainstacks-soft.jpg');--pos:50% 52%;
  --peak:.32}
.art-plate[data-art-for="projects"]::after{background:linear-gradient(180deg,
  rgb(var(--paper) / .16) 0%,rgb(var(--paper) / .46) 6%,rgb(var(--paper) / .72) 16%,
  rgb(var(--paper) / .72) 84%,rgb(var(--paper) / .44) 93%,rgb(var(--paper) / .18) 100%)}
/* Monet's Poplars on the Epte: a row of identical verticals repeating into the
   distance — a literal stack — and its trunks frame the keyboard sitting centre
   screen instead of clashing with it. No figures. The canvas is square, so a
   16:9 viewport keeps the full width and crops to the tree line and the water. */
/* both of these are HIGH-KEY paintings — far paler than the Caillebotte they
   replaced — so their veils lean on the gradient shape (a floor at both
   edges, not clearing to nothing) rather than a raised peak/saturation to
   stay legible; 2026-09-11: peak brought down to match Experience's .62
   and the saturation/brightness/contrast boosts removed — the boosted values
   read as "too much color" once all four backdrops were live together */
/* 2026-09-11 (later): Poplars was reading as the SUBJECT rather than the
   backdrop, competing with the keyboard that sits dead centre over it.
   A residual blur was tried first and is the WRONG tool for this one: the
   painting is very high-key, so its trunks and tree line are the only things
   carrying it, and they are gone by ~3px — what is left is grey fog, which
   reads busier, not calmer, than the sharp image. Opacity alone is what works.
   Taken further down to .30 to match Projects; still sharp, because at this
   opacity a blur floor leaves literally nothing but a grey cast. */
.art-plate[data-art-for="stack"]{--img:url('uploads/monet-poplars-graded.jpg');--soft:url('uploads/monet-poplars-soft.jpg');--pos:50% 30%;
  --peak:.30}
.art-plate[data-art-for="stack"]::after{background:linear-gradient(180deg,
  rgb(var(--paper) / 0) 0%,rgb(var(--paper) / .42) 9%,rgb(var(--paper) / .72) 20%,
  rgb(var(--paper) / .72) 82%,rgb(var(--paper) / .38) 93%,rgb(var(--paper) / 0) 100%)}

/* The plates' dark variants. Only --img/--soft swap: the veil, the peak, the
   parallax and the focus cross-fade are all theme-agnostic because the veil is
   `rgb(var(--paper) / a)` and therefore inverts on its own. See uploads/_bake.py
   for how the dark grade was derived. #contact has no dark variant — it has
   always been a dark section. */
:root[data-theme="dark"] .art-plate[data-art-for="experience"]{
  --img:url('uploads/monet-the-magpie-graded-dark.jpg');--soft:url('uploads/monet-the-magpie-soft-dark.jpg')}
:root[data-theme="dark"] .art-plate[data-art-for="projects"]{
  --img:url('uploads/monet-grainstacks-graded-dark.jpg');--soft:url('uploads/monet-grainstacks-soft-dark.jpg')}
:root[data-theme="dark"] .art-plate[data-art-for="stack"]{
  --img:url('uploads/monet-poplars-graded-dark.jpg');--soft:url('uploads/monet-poplars-soft-dark.jpg')}

#experience,#projects,#stack{position:relative;z-index:1}

/* ---------- contact ---------- */
/* 250vh of runway, pulled up 100vh so it starts while Stack still fills the
   screen: the scene pins immediately and holds for the remaining 150vh.
   z-index 2 puts it above the sheet's other sections so it dissolves over
   them; pointer-events are handed back in JS once it is actually legible */
.contact-runway{position:relative;z-index:2;height:200vh;margin-top:-100vh;pointer-events:none}
.contact-scene{position:sticky;top:0;height:100vh;display:flex;flex-direction:column;justify-content:flex-end;margin:0;padding:0;overflow:hidden;color:#f4f2ee}
.contact-scene.live{pointer-events:auto}
/* promoted only while the dissolve is actually scrubbing (see .warm below):
   otherwise this is a filtered, full-viewport layer pinned for the whole page */
/* This was the single most expensive thing on the page, and the quantisation
   that was supposed to protect it did not work: --f was quantised, but
   grayscale/brightness/contrast/saturate in the SAME filter chain all read --p,
   which is continuous — so the chain got a brand new value every frame anyway.
   And `transition:filter` on top of it meant the browser interpolated a 24px
   full-viewport Gaussian at 60fps in BETWEEN scroll frames.
   Two constant-filter layers cross-faded on --f instead. The element itself now
   carries no filter at all, which also means its ::after veil is finally clean
   (it was being run through the parent's grade). */
.contact-art{position:absolute;inset:0;--p:0;--f:0;--ps:0;opacity:calc(var(--p) * var(--adapt));
  transform:scale(calc(1.1 - .04*var(--ps)));transition:opacity .12s linear}
.contact-art::before,.contact-soft{content:'';position:absolute;inset:0;
  background-size:cover;background-position:32% 42%}
/* resolved: the grade the old chain reached at --p 1, baked in */
.contact-art::before{background-image:var(--sunrise,url('uploads/impression-sunrise-graded.jpg'))}
/* unresolved: the grade the old chain started from, plus the full 24px. Same
   inset:0 the single-layer version had, so the crop is identical at both ends
   of the pull — .contact-scene clips, and .contact-art is scaled past the
   viewport anyway, so the blur's own edge falloff stays off screen. */
.contact-soft{background-image:var(--sunrise-soft,url('uploads/impression-sunrise-soft.jpg'));
  opacity:calc(1 - var(--f))}
.contact-art::after{content:'';position:absolute;inset:0;z-index:1;background:linear-gradient(0deg,rgba(10,9,7,.62) 0%,rgba(10,9,7,.22) 42%,rgba(10,9,7,0) 68%)}
/* Contact was always a dark section — but "dark" was relative to a WHITE site.
   At opacity 1 and full bleed, the sunrise becomes the brightest thing on a
   #0e0d0b page, which is the screenshot-glare problem again at full size. So
   the section does get a dark variant after all: a darker, more saturated grade
   (see uploads/_bake.py) and a deeper veil under the copy. */
:root[data-theme="dark"] .contact-art{
  --sunrise:url('uploads/impression-sunrise-graded-dark.jpg');
  --sunrise-soft:url('uploads/impression-sunrise-soft-dark.jpg')}
:root[data-theme="dark"] .contact-art::after{
  background:linear-gradient(0deg,rgba(10,9,7,.80) 0%,rgba(10,9,7,.42) 42%,rgba(10,9,7,.10) 68%)}
/* no `filter` in this list any more — there is none left to promote for */
.contact-art.warm{will-change:opacity,transform}
.contact-art.warm .contact-soft{will-change:opacity}
.contact-art.settled{animation:contactDrift 26s ease-in-out .3s infinite alternate}
@keyframes contactDrift{from{transform:scale(1.06) translate(0,0)}to{transform:scale(1.12) translate(-1%,-1%)}}
.contact-floor{position:relative;z-index:1;display:flex;justify-content:space-between;align-items:flex-end;gap:32px;padding:0 56px 64px;flex-wrap:wrap}
/* --pb is --p quantised (see script.js) and drives the blur ALONE. The blur
   stays because a display line this size genuinely reads as coming into focus,
   but two things about it were expensive: it was driven by the smooth --p, so
   every frame was a fresh radius, and `transition:filter` had the browser
   generating radii between scroll frames as well. Ten steps, no transition. */
.contact-title{font-size:clamp(56px,10vw,168px);font-weight:400;letter-spacing:-.025em;line-height:.88;--p:0;--pb:0;opacity:var(--p);transform:translateY(calc(84px * (1 - var(--p)))) scale(calc(1 + .05*(1 - var(--p))));filter:blur(calc(16px * (1 - var(--pb))));transition:opacity .12s linear,transform .12s linear}
.contact-links{display:flex;flex-direction:column;align-items:flex-end;gap:20px}
.contact-link{display:flex;flex-direction:column;align-items:flex-end;text-align:right;--p:0;opacity:var(--p);transform:translateY(calc(30px * (1 - var(--p))));transition:opacity .12s linear,transform .12s linear}
.contact-link-name{font-size:clamp(17px,1.7vw,22px);letter-spacing:-.02em;color:#f4f2ee;background-image:linear-gradient(#f4f2ee,#f4f2ee);background-size:0 1px;background-repeat:no-repeat;background-position:100% 100%;padding-bottom:3px;transition:background-size .38s cubic-bezier(.16,1,.3,1),opacity .38s}
.contact-link:hover .contact-link-name{background-size:100% 1px}
.contact-link-note{font-size:12px;letter-spacing:.04em;color:rgba(244,242,238,.58);margin-top:3px;transition:opacity .38s}
.footer-row{position:relative;z-index:1;display:flex;justify-content:space-between;align-items:baseline;border-top:1px solid rgba(244,242,238,.22);padding:16px 56px 22px;gap:24px;--p:0;opacity:var(--p);transition:opacity .12s linear}
.footer-note{font-size:12.5px;color:rgba(244,242,238,.68)}

@media (max-width:640px){
  #nav-inner,.section-stage,.projects-pin,.case-body,.case-topbar{padding-left:24px;padding-right:24px}
  .contact-floor{flex-direction:column;align-items:flex-start;padding:0 24px 40px;gap:28px}
  .contact-links{align-items:flex-start}
  .contact-link{align-items:flex-start;text-align:left}
  .footer-row{padding:14px 24px 20px}
}
