@font-face {
  font-family: "Hardshock";
  src: url("fonts/Hardshock.otf") format("opentype");
  font-weight: normal;
  font-style: normal;
}

:root {
  --bg: #1b1b1b;
  --purple: #d2c0f9;
  --white: #ffffff;
  /* Customizy's card pink. */
  --pink: #b86287;
  /* Minimum space between page content and the viewport edges. Full-bleed
     section backgrounds ignore this and still run edge to edge. */
  --gutter: 100px;
  --progress-h: 3px;
}

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

/* Hold the scrollbar's column open even while something has locked scrolling.
   Every scroll lock on this site — the intro overlay, the resume viewer, the
   mobile menu — sets `overflow: hidden`, which takes the scrollbar away and
   widens the layout by its width. Measured on a work<->about jump: the moment
   the intro unlocked, clientWidth went 1600 -> 1585 and everything anchored to
   the right edge snapped across 15px. Reserving the gutter permanently means
   locking and unlocking no longer changes the available width at all. */
html {
  scrollbar-gutter: stable;
}

/* Nothing on this site scrolls sideways, and on a phone that is not a
   cosmetic point: a document even a few pixels wider than the screen becomes
   pannable, and once the visual viewport can move away from the layout
   viewport every `position: fixed` overlay — the focused clip, the back-to-top
   button — is drawn somewhere the finger is not. It also eats the edge swipe
   the system back gesture wants.

   `clip`, not `hidden`: `hidden` would turn <html> into a scroll container and
   take every `position: sticky` on the page with it — the hero, the project
   deck and the pinned panels all depend on it. `clip` refuses the scroll
   without creating one. The overflow it catches is a safety net; the elements
   that caused it are sized to the viewport where they are declared.

   Deliberately no `overscroll-behavior-x` here. It would stop the sideways
   overscroll, but browsers read it as "this page handles the horizontal
   gesture itself" and withdraw swipe-to-go-back along with it — which is the
   gesture this is all meant to protect. With nothing to scroll sideways there
   is no overscroll left to suppress anyway. */
html,
body {
  overflow-x: clip;
}

/* <picture> is only there to pick a source. Taking it out of the box tree
   means every existing rule that positions or sizes the <img> — and several
   assume it is a direct child of its frame — keeps working unchanged.

   `display: contents` promotes the picture's children to items of whatever
   contains it, and <source> is one of those children. In a flex column it
   became a zero-height flex item and bought itself a full gap: every section
   with a wrapped heading grew by exactly one `gap`, +101px down the work page
   and +91 down the about page, with the desktop artwork untouched. <source>
   renders nothing by definition, so say so. */
picture {
  display: contents;
}

picture > source {
  display: none;
}

/* Built for the mobile frames and shown only there. Declared off here rather
   than inside the breakpoint so a future desktop rule cannot reveal them by
   accident. */
.works__brand,
.works__dots,
.experience__dots,
.knowmore {
  display: none;
}

body {
  background: var(--bg);
  font-family: "Hardshock", sans-serif;
}

body.about-page {
  background: #5c4c7f;
}

/* Sticks to the top of the viewport. It stays in flow, so nothing shifts when
   it engages — and the resume viewer can still swap it to fixed without the
   page jumping, because leaving flow costs the same either way. */
.nav {
  position: sticky;
  top: 0;
  z-index: 210;
  width: 100%;
  /* 18 + 79 + 3 = 100px of header. It used to stand 140px tall, which ate
     enough of a laptop viewport that the hero opened already half-scrolled. */
  padding-top: 18px;
  background: var(--bg);
}

body.about-page .nav {
  background: #5c4c7f;
}

/* Scroll progress, pinned to the bottom edge of the sticky header. */
.nav__progress {
  width: 100%;
  height: var(--progress-h);
  /* Faint enough that the empty track reads as a hairline rule rather than a
     grey band across the page, but still there so the fill has something to
     travel along. */
  background: rgba(255, 255, 255, 0.07);
  overflow: hidden;
}

/* The same light purple on both pages — it reads against the work page's dark
   and the about page's deeper purple alike. */
.nav__progress-bar {
  display: block;
  width: 100%;
  height: 100%;
  background: var(--purple);
  /* A thin bar loses the weight that made it visible, so the glow carries the
     read instead of thickness. */
  box-shadow: 0 0 6px rgba(210, 192, 249, 0.75);
  /* scaleX rather than width: it animates on the compositor, so scrolling
     stays smooth. */
  transform: scaleX(0);
  transform-origin: 0 50%;
}

.nav__inner {
  width: 100%;
  padding: 0 var(--gutter);
  height: 79px;
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.nav__logo {
  display: block;
  /* Keeps the mark's 80:107 crop as the bar comes down. */
  height: 79px;
  width: 59px;
  flex-shrink: 0;
}

.nav__logo img {
  display: block;
  height: 100%;
  width: 100%;
  object-fit: cover;
}

.nav__links {
  display: flex;
  align-items: center;
  height: 56px;
  gap: 50px;
}

.nav__link {
  position: relative;
  font-family: "Hardshock", sans-serif;
  font-size: 16px;
  line-height: normal;
  color: var(--white);
  text-decoration: none;
  white-space: nowrap;
}

.nav__link--active {
  color: var(--purple);
  display: inline-flex;
  flex-direction: column;
  align-items: flex-start;
}

/* Tracks .nav__link's size — 48/60 is the same 0.8 ratio as 16/20, so the
   stroke stays the same length relative to the word it sits under. */
.nav__underline {
  display: block;
  width: 48px;
  height: auto;
  margin-top: -3px;
}

/* Scaled with the bar so the button keeps the same optical weight inside it;
   the image keeps its own aspect ratio. */
.nav__contact {
  display: block;
  height: 46px;
  flex-shrink: 0;
}

.nav__contact img {
  display: block;
  height: 100%;
  width: auto;
}

/* ===== Hero ===== */

/* The hero pins to the top and the rest of the page scrolls up over it.
   top is normally 0, but on a viewport shorter than the hero it goes negative
   so the hero can still travel far enough to reveal its bottom (photo and
   badges) before the panel covers it. */
.hero {
  position: sticky;
  top: min(0px, 100vh - 850px);
  z-index: 0;
  width: 100%;
  /* Was 40px under a 140px header. With the header at 100px this sat as a
     visible hole between the bar and the greeting, so it comes in to match. */
  margin: 20px 0 0;
  height: 850px;
}

.hero__greeting {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  font-family: "Urbanist", sans-serif;
  font-weight: 500;
  font-size: 36px;
  color: var(--white);
  text-align: center;
}

.hero__name {
  font-family: "Hardshock", sans-serif;
  font-size: 40px;
  color: var(--purple);
}

.hero__title {
  position: absolute;
  font-family: Impact, Haettenschweiler, "Arial Narrow Bold", sans-serif;
  font-size: 135px;
  text-transform: uppercase;
  line-height: 1;
  white-space: nowrap;
}

.hero__title--1 {
  left: 0;
  right: 0;
  top: 91px;
  color: rgba(255, 255, 255, 0.9);
  text-align: center;
}

.hero__title--2 {
  left: 0;
  right: 0;
  top: 260px;
  color: #695397;
  text-align: center;
}

.hero__photo-wrap {
  position: absolute;
  left: 50%;
  margin-left: -239.81px;
  top: 291.05px;
  width: 479.61px;
  height: 541.28px;
}

/* Holds the photo and its gradient together so the entrance can move them as
   one. Box-for-box the same as .hero__photo-wrap, so every offset inside it —
   including the gradient's deliberate overflow — resolves exactly as before. */
.hero__photo-group {
  position: absolute;
  inset: 0;
}

/* Owns the hover scale and nothing else — see the note in index.html. */
.hero__photo-zoom {
  display: block;
  width: 100%;
  height: 100%;
  transform-origin: 50% 50%;
  transition: transform 0.55s cubic-bezier(0.22, 0.75, 0.24, 1);
}

/* Hover-capable pointers only. A tap on a touch screen leaves the hover
   behind, so the photo grew and the rest of the hero dimmed and both stayed
   that way until something else was touched — a reaction to a tap on
   something that is not a control, which reads as the page having done
   something you cannot undo. On a phone the photo is a photograph. */
@media (hover: hover) and (pointer: fine) {
  .hero__photo-wrap:hover .hero__photo-zoom {
    transform: scale(1.06);
  }
}

/* The rest of the hero pulls back so the photo is unambiguously the subject.
   brightness, not opacity: heroEntrance() leaves an inline `opacity: 1` on the
   greeting, the badges and the stickers, which would outrank any opacity rule
   here. GSAP writes `filter` on .hero itself but on none of these children, so
   this is the one property that is free to use. Declared on hover only — CSS
   interpolates a filter out of `none` (GSAP notably cannot), so there is no
   need for an always-on brightness(1) that would give every badge a stacking
   context it does not otherwise have. */
.hero__greeting,
.hero__title,
.hero__badge {
  transition: filter 0.45s ease;
}

@media (hover: hover) and (pointer: fine) {
  .hero:has(.hero__photo-wrap:hover) .hero__greeting,
  .hero:has(.hero__photo-wrap:hover) .hero__title,
  .hero:has(.hero__photo-wrap:hover) .hero__badge {
    filter: brightness(0.62);
  }
}

.hero__photo {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.hero__photo-fade {
  position: absolute;
  left: -83.39px;
  top: 192.95px;
  width: 652px;
  height: 366px;
  background: linear-gradient(to bottom, rgba(27, 27, 27, 0) 40%, rgba(27, 27, 27, 1) 85%);
  pointer-events: none;
}

.hero__badge {
  position: absolute;
}

/* Both badges flank the hero photo, so they hang off the centre line like the
   photo does. Pinning them to the gutters throws them into the far corners on
   a wide viewport, away from the composition they belong to. */
/* Box-for-box the badge it sits in; owns the mobile tilt and nothing else. */
.hero__badge-tilt {
  position: absolute;
  inset: 0;
}

.hero__badge--collab {
  left: 50%;
  margin-left: 193px;
  top: 543.99px;
  width: 283px;
  height: 110px;
}

.hero__collab-text {
  position: absolute;
  left: 4px;
  top: 4px;
  width: 188px;
  font-family: "Hardshock", sans-serif;
  font-size: 19.5px;
  color: var(--purple);
}

.hero__collab-logo {
  position: absolute;
  display: block;
}

.hero__collab-logo--1 {
  left: 67.93px;
  top: 62.58px;
  width: 107.59px;
  height: 41.79px;
}

.hero__collab-logo--2 {
  left: 185.69px;
  top: 62.58px;
  width: 91.55px;
  height: 41.71px;
}

.hero__collab-arrow {
  position: absolute;
  left: 192px;
  top: 19.01px;
  width: 33px;
  height: 34px;
}

.hero__badge--illustrator {
  left: 50%;
  margin-left: -476px;
  top: 643.99px;
  width: 244px;
  height: 89px;
}

.hero__illustrator-text {
  position: absolute;
  left: 3px;
  top: 1.76px;
  width: 313.28px;
  font-family: "Hardshock", sans-serif;
  font-size: 26.4px;
  color: rgba(210, 192, 249, 0.7);
  line-height: 1.2;
}

.hero__illustrator-text span {
  font-family: Impact, Haettenschweiler, "Arial Narrow Bold", sans-serif;
  font-size: 28.16px;
  color: rgba(255, 255, 255, 0.9);
  text-transform: uppercase;
}

.hero__sticker {
  position: absolute;
  display: block;
}

.hero__sticker--1 {
  left: 157px;
  top: 16.72px;
  width: 54.69px;
  height: 56.79px;
}

.hero__sticker--2 {
  left: 193.43px;
  top: 21.31px;
  width: 47.92px;
  height: 50.58px;
}

/* The star doodle exists only in the phone frame. */
.hero__star {
  display: none;
}

/* Everything after .animate is one opaque layer. It has to be opaque and above
   the hero, or the pinned hero shows through the sections that have no
   background of their own (works, design stack, footer).
   flow-root keeps .projects' top margin inside the panel — left to collapse
   through, it would open a transparent strip revealing the hero photo. */
.scroll-panel {
  position: relative;
  z-index: 1;
  display: flow-root;
  background: var(--bg);
}

/* ===== Animate ===== */

/* Sits outside .scroll-panel so its own artwork is what rises over the hero.
   The PNG is transparent for its top ~93px (the cloud bumps) and opaque below,
   so the clouds read as a shaped reveal edge instead of a straight cut. */
/* Sits outside .scroll-panel so its own artwork is what rises over the hero.
   The PNG is transparent for its top ~93px (the cloud bumps) and opaque below,
   so the clouds read as a shaped reveal edge instead of a straight cut.

   Because the art is sized with `cover`, that opaque line lands at a fixed
   fraction of the viewport width — 93 of the source's 1680px, i.e. 5.54vw — so
   it slides further down as the window widens. The top padding has to track it
   or the heading collides with the clouds on wider screens; the -11px lands the
   heading a constant ~40px clear of the cloud line at any width. */
.animate {
  position: relative;
  z-index: 1;
  width: 100%;
  margin-top: 100px;
  padding: calc(5.54vw - 11px) 0 80px;
  background: url("assets/animate-bg.png") center top / cover no-repeat;
}

.animate__inner {
  position: relative;
  width: 100%;
  padding: 240px var(--gutter) 0;
}

.animate__heading {
  position: absolute;
  left: 50%;
  margin-left: -172.69px;
  top: 30px;
  width: 345.38px;
  height: auto;
  z-index: 2;
}

.animate__carousel {
  position: relative;
  width: 100%;
  overflow: hidden;
  -webkit-mask-image: linear-gradient(to right, transparent 0, black 60px, black calc(100% - 60px), transparent 100%);
  mask-image: linear-gradient(to right, transparent 0, black 60px, black calc(100% - 60px), transparent 100%);
}

.animate__track {
  display: flex;
  /* Figma's 14px was too tight once these were real clips rather than grey
     boxes — neighbouring screenshots ran together. The marquee reads this
     value back off the computed style, so its loop stays seamless. */
  gap: 32px;
  width: max-content;
  animation: animate-scroll 28s linear infinite;
}

.animate__carousel:hover .animate__track {
  animation-play-state: paused;
}

/* The row's whole rule: every clip is the same height and its width falls out
   of its own aspect ratio, so a 2.05:1 screen recording sits next to a 1.52:1
   one without either being cropped or letterboxed. --ratio is written into the
   markup from the encoded file, so the box can never disagree with the video
   inside it. */
/* Every clip is 271px tall and exactly half its video's pixel width, so each
   one draws at a clean 2:1 downscale from its 542px-tall source.

   This used to be 271.44px tall with the width left to aspect-ratio, and it
   shimmered. Three things stacked up: the fractional height made 542 scale by
   1.9967 instead of 2; box-sizing: border-box meant the 1px border ate into
   that, taking the content box to 269.44 and the scale to 2.0116; and the
   fractional widths that fell out of aspect-ratio put every edge on a
   sub-pixel boundary that the marquee moved a little further every frame. So
   the browser re-sampled the outermost pixel column on each frame and the
   #d9d9d9 behind it flashed through — the pale flicker along the edges.

   Whole numbers throughout fix it. The border is gone rather than compensated
   for: it was black on a near-black page, so it was invisible anyway, and it
   is what pushed the height off an exact factor of two. */
.animate__clip {
  flex: 0 0 auto;
  height: 271px;
  width: calc(var(--w) * 0.5px);
  /* Dark, so a clip that has not loaded yet — or any hairline the compositor
     leaves at an edge — reads as page rather than as a pale seam. */
  background: #141414;
  overflow: hidden;
  /* Its own compositor layer: rasterised once, then only translated by the
     marquee, instead of being re-rasterised as the track moves. */
  transform: translateZ(0);
  backface-visibility: hidden;
}

.animate__video {
  display: block;
  width: 100%;
  height: 100%;
  /* The box is cut to the clip's own ratio, so this only ever trims the
     sub-pixel rounding rather than any actual picture. */
  object-fit: cover;
  /* Edge floats its own toolbar over any <video> the pointer is on — a
     picture-in-picture button and a translate button. The markup's
     disablepictureinpicture drops the first; refusing the pointer here means
     the browser never registers a hover on the video at all, so none of that
     furniture ever appears. Events fall through to .animate__clip, which is
     what owns the lift-and-dim anyway, so nothing is lost. */
  pointer-events: none;
}

@keyframes animate-scroll {
  from {
    transform: translateX(0);
  }

  to {
    transform: translateX(-50%);
  }
}

/* ===== My Projects ===== */

.projects {
  position: relative;
  width: 100%;
  margin-top: 100px;
}

.projects__heading {
  display: block;
  width: 348.77px;
  margin: 0 auto 11px;
  position: relative;
  z-index: 10;
}

.projects__stack {
  position: relative;
}

/* Sticks to the bottom edge of the header rather than to the viewport top —
   at top: 0 the card slid under the sticky nav and the nav ate its title.
   100px is the header's own total (18 padding + 79 inner + 3 progress). */
.project-card {
  position: sticky;
  top: 100px;
  width: 100%;
  height: 777px;
  overflow: hidden;
}

.project-card__inner {
  position: relative;
  width: 100%;
  height: 100%;
  box-sizing: border-box;
}

/* Each card's panel colour. These were flat-colour JPEGs before — nothing an
   image was buying, so they are plain fills now. */
.project-card:nth-of-type(1) {
  z-index: 1;
  background: var(--pink);
}

.project-card:nth-of-type(2) {
  z-index: 2;
  background: #43700b;
}

.project-card:nth-of-type(3) {
  z-index: 3;
  background: #7a78b9;
}

.project-card:nth-of-type(4) {
  z-index: 4;
  background: #7d7d7d;
}

.project-card__title {
  position: absolute;
  left: 0;
  top: 18px;
  width: 100%;
  text-align: center;
  font-family: Impact, Haettenschweiler, "Arial Narrow Bold", sans-serif;
  font-size: 112px;
  color: var(--white);
  text-transform: uppercase;
  line-height: 1;
}

.project-card__desc {
  position: absolute;
  left: 50%;
  top: 203px;
  width: 800px;
  max-width: calc(100% - var(--gutter) * 2);
  transform: translateX(-50%);
  text-align: center;
  font-family: "Urbanist", sans-serif;
  font-weight: 500;
  font-size: 20px;
  color: var(--white);
}

/* Thumbs stay at their design size and centre themselves. translateX(-50%)
   keeps them centred even when max-width clamps them inside the gutter. */
.project-card__thumb {
  position: absolute;
  display: block;
  object-fit: cover;
  left: 50%;
  transform: translateX(-50%);
  max-width: calc(100% - var(--gutter) * 2);
}

.project-card__thumb--1 {
  top: 274.37px;
  width: 958.89px;
  height: 506.47px;
}

/* Height is the width divided by the picture's own 957:504 ratio. It used to
   be 637.77px, which is a 1.498 box holding a 1.899 image: `cover` then scaled
   the shot up to fill that height and chopped 127.7px — 10.5% — off each side,
   which is what cut the Hausbot mockup. The card is `overflow: hidden` at
   777px, so only 503px of that 638 ever showed anyway; matching the box to the
   art shows the whole screenshot in exactly the same on-screen rectangle. */
.project-card__thumb--2 {
  top: 273.63px;
  width: 956.31px;
  height: 503.64px;
}

.project-card__thumb--3 {
  top: 274.09px;
  width: 958.23px;
  height: 546.09px;
}

/* Lie & Spy has no screenshot yet, so its panel is the flat placeholder the
   wordmark and the spy artwork sit on. */
.project-card__thumb--4 {
  top: 326px;
  width: 955px;
  height: 456px;
  background: #4d4d4d;
}

/* Tags hang off the centre line so they keep their position relative to the
   title and thumb rather than drifting with the viewport edge. */
.project-card__tag {
  position: absolute;
  display: block;
  left: 50%;
}

.project-card__tag--1 {
  margin-left: 240.53px;
  top: 87.79px;
  width: 168.45px;
  height: 91.51px;
}

.project-card__tag--2 {
  margin-left: 205.92px;
  top: 89.9px;
  width: 151.47px;
  height: 88.96px;
}

.project-card__tag--3 {
  margin-left: 102.1px;
  top: 92.35px;
  width: 132.06px;
  height: 85.39px;
}

.project-card__tag--4 {
  margin-left: 199.04px;
  top: 94.44px;
  width: 129.82px;
  height: 85.73px;
}

/* "Coming" and "Soon" straddle the artwork, which is layered on top of them. */
.project-card__soon {
  position: absolute;
  left: 50%;
  font-family: "Urbanist", sans-serif;
  font-weight: 900;
  font-size: 84px;
  line-height: 101px;
  color: var(--white);
  text-transform: uppercase;
  white-space: nowrap;
}

/* Pulled left of the artwork so the word reads whole rather than as "CO".
   The card is 955px wide, so its left edge sits at -477.5px from centre and
   this leaves a 65px inset — the same gap SOON keeps from the right edge. */
.project-card__soon--left {
  margin-left: -412.5px;
  top: 461px;
  width: auto;
  text-align: left;
}

.project-card__soon--right {
  margin-left: 175.5px;
  top: 551px;
  width: 237px;
}

.project-card__art {
  position: absolute;
  display: block;
  left: 50%;
  margin-left: -223.73px;
  top: 277.68px;
  width: 487.45px;
  height: 499.32px;
}

.projects__view-all {
  display: block;
  position: relative;
  z-index: 11;
  width: 459px;
  margin: 60px auto 0;
}

.projects__view-all-bar {
  display: block;
  width: 100%;
}

/* The mockups' pocket. Clipped at 41.81% — y=120 of the 287px artwork, which
   is where the dark bar's top edge runs (it is dead flat, measured). Anything
   the mockups layer pushes below that line is the bar's territory and gets
   cut, which is what lets them hide completely without a second asset. */
.projects__view-all-pocket {
  position: absolute;
  inset: 0;
  clip-path: inset(0 0 58.19% 0);
  pointer-events: none;
}

/* Tucked in at rest, sliding back to the artwork's own position on hover.
   41.81% of the layer's height is the same 120px, so the mockups clear the
   clip line entirely and nothing peeks out. Pure CSS on purpose: this is a
   resting design state, not an entrance, so it must not depend on motion.js. */
.projects__view-all-pop {
  display: block;
  width: 100%;
  transform: translateY(41.81%);
  transition: transform 0.55s cubic-bezier(0.22, 0.75, 0.24, 1);
}

.projects__view-all:hover .projects__view-all-pop,
.projects__view-all:focus-visible .projects__view-all-pop {
  transform: translateY(0);
}

/* ===== My Works ===== */

.works {
  width: 100%;
  margin: 100px 0 0;
  padding: 0 var(--gutter);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 40px;
}

.works__heading {
  display: block;
  width: 268.85px;
}

/* Fixed width so the note + screenshot composition stays centred by the
   parent's align-items, rather than stretching to the gutters.

   Below 999.29 + gutters it has to *scale* rather than crop: the note and the
   screenshot inside it used to be fixed pixels (`left: 266px; width: 733px`)
   while this box shrank under max-width, so between 1024 and 1179px the shot
   hung 97px past the viewport and the page scrolled sideways. Everything
   inside is now a percentage of this box with type in `cqw`, and the
   aspect-ratio keeps the height in step — the same container-query treatment
   `.exp-card` uses. At 1200px and up the box is its full 999.29px and the
   composition is identical to what it always was. */
.works__content {
  container-type: inline-size;
  position: relative;
  width: 999.29px;
  max-width: 100%;
  aspect-ratio: 999.29 / 538;
}

.work-item {
  position: absolute;
  inset: 0;
}

.works__note {
  position: absolute;
  left: 0;
  top: 0;
  width: 30.221%;
  height: 100%;
  background: url("assets/works-note-bg.png") center / cover no-repeat;
}

/* Percentages here resolve against .works__note's own 302x538 box; the cqw
   type sizes resolve against .works__content, the declared container. */
.works__note-date {
  position: absolute;
  left: 37.371%;
  top: 4.487%;
  width: 25.45%;
  font-family: "Hardshock", sans-serif;
  font-size: 1.801cqw;
  color: #000;
  text-align: center;
}

.works__note-desc {
  position: absolute;
  left: 12.252%;
  top: 41.738%;
  width: 75.593%;
  font-family: "Urbanist", sans-serif;
  font-weight: 500;
  font-size: 2.201cqw;
  color: #000;
  text-align: center;
}

.works__note-title {
  position: absolute;
  left: 13.576%;
  top: 88.363%;
  width: 73.179%;
  font-family: "Hardshock", sans-serif;
  font-size: 1.801cqw;
  color: #000;
  text-align: left;
}

.works__note-desc--2 {
  top: 43.69%;
}

.works__note-title--2 {
  left: 21.523%;
  top: 88.642%;
  width: 56.954%;
}

/* The frame owns the layout and clips; the image inside it drifts. Splitting
   them is what lets the slide transition animate the frame while a separate
   loop animates the picture within it — one element could not do both,
   because both need the transform. */
.works__shot {
  position: absolute;
  left: 26.619%;
  top: 5.834%;
  width: 73.352%;
  height: 88.29%;
  overflow: hidden;
}

/* contain, not cover: the mockup is hand-drawn artwork whose purple frame runs
   right to the edge of the PNG with barely any margin, so cropping even a few
   pixels cuts the frame off. contain guarantees the whole drawing is always
   inside the box; the couple of pixels of letterbox it leaves fall on the
   page background, which the artwork's own transparent margin already shows. */
.works__screenshot {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
}

.works__arrows {
  display: flex;
  gap: 17px;
}

.works__arrow {
  width: 67px;
  height: 67px;
  border: none;
  background: transparent;
  padding: 0;
  cursor: pointer;
  /* Springs back with a slight overshoot, so the release reads as a bounce
     rather than a linear return. */
  transition: transform 0.19s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.2s ease;
}

/* Presses in quickly, then the rule above carries it back out. */
.works__arrow:active:not(:disabled) {
  transform: scale(0.85);
  transition-duration: 0.07s;
}

/* At either end of the list there is nothing to move to. `disabled` on a
   <button> already blocks clicks and keyboard activation, so this only has to
   say so visually. */
.works__arrow:disabled {
  opacity: 0.3;
  cursor: default;
}

.works__arrow img {
  display: block;
  width: 100%;
  height: 100%;
}

.works__arrow--prev img {
  transform: rotate(180deg);
}

/* ===== Brainstorms ===== */

.brainstorms {
  width: 100%;
  margin-top: 100px;
}

.brainstorms__heading {
  display: block;
  width: 494px;
  margin: 0 auto;
  position: relative;
  z-index: 2;
}

.brainstorms__main {
  position: relative;
  width: 100%;
  background: #b6a284;
  margin-top: 30px;
  overflow: hidden;
}

.brainstorms__stack {
  position: relative;
  width: 100%;
  height: 845px;
  touch-action: pan-y;
  cursor: grab;
}

.brainstorms__stack.is-dragging {
  cursor: grabbing;
}

.brainstorms__title {
  position: absolute;
  left: 0;
  top: 301px;
  width: 100%;
  height: 244px;
  margin: 0;
  text-align: center;
  user-select: none;
  font-family: Impact, Haettenschweiler, "Arial Narrow Bold", sans-serif;
  font-size: 200px;
  line-height: 244px;
  color: rgba(255, 255, 255, 0.2);
  text-transform: uppercase;
}

/* Offsets hang off the centre line via margin-left, not transform — the swipe
   handler writes to transform and would otherwise wipe the positioning out. */
/* The cards stay hit-testable so the "Drag" cursor label can key off the card
   rather than the whole 845px band. mousedown/touchstart are bound on the
   stack, so events from a card still bubble up to the drag handler and the
   throw works exactly as before. */
.brainstorm-note {
  position: absolute;
  left: 50%;
  display: block;
  user-select: none;
}

.brainstorm-note--1 {
  margin-left: -241.69px;
  top: 118.83px;
  width: 437.37px;
  height: 528.24px;
}

.brainstorm-note--2 {
  margin-left: -216.71px;
  top: 151.45px;
  width: 451.33px;
  height: 536.69px;
}

.brainstorm-note--3 {
  margin-left: -229.75px;
  top: 169.7px;
  width: 477.02px;
  height: 557.43px;
}

.brainstorm-note--4 {
  margin-left: -182px;
  top: 161.45px;
  width: 414.35px;
  height: 510.36px;
}

.brainstorm-note--5 {
  margin-left: -248px;
  top: 174.45px;
  width: 460.58px;
  height: 545.47px;
}

/* ===== My Design Stack ===== */

.design-stack {
  width: 100%;
  margin: 100px 0 0;
  padding: 0 var(--gutter);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 61px;
}

.design-stack__heading {
  display: block;
  width: 478.85px;
}

.design-stack__scroll {
  width: 100%;
  overflow: hidden;
  -webkit-mask-image: linear-gradient(to right, transparent 0, black 40px, black calc(100% - 40px), transparent 100%);
  mask-image: linear-gradient(to right, transparent 0, black 40px, black calc(100% - 40px), transparent 100%);
}

.design-stack__track {
  --stack-gap: 22px;
  display: flex;
  gap: var(--stack-gap);
  width: max-content;
  padding: 0 2px;
  animation: design-stack-scroll 30s linear infinite;
}

/* Pause on hover only where a pointer can actually hover. On touch a tap
   leaves a sticky :hover behind, which froze the marquee until you tapped
   somewhere else. */
@media (hover: hover) and (pointer: fine) {
  .design-stack__scroll:hover .design-stack__track {
    animation-play-state: paused;
  }
}

/* One loop is half the items plus half a gap: the track is 2n items with
   2n-1 gaps, so a flat -50% lands half a gap short and the seam visibly
   jumps every cycle. */
@keyframes design-stack-scroll {
  from {
    transform: translateX(0);
  }

  to {
    transform: translateX(calc(-50% - var(--stack-gap) / 2));
  }
}

.design-stack__icon {
  flex: 0 0 165px;
  width: 165px;
  height: 165px;
  display: block;
  user-select: none;
  -webkit-user-drag: none;
  -webkit-touch-callout: none;
  pointer-events: none;
}

/* ===== Connect / Footer ===== */

.connect {
  position: relative;
  width: 100%;
  margin-top: 100px;
  background: url("assets/ending-bg.jpg") center / cover no-repeat;
}

/* About page: Figma applies this texture with a Luminosity blend mode at 50%
   opacity over the purple page background, instead of showing it at full
   opacity like the main page does. */
body.about-page .connect {
  background: #5c4c7f;
}

body.about-page .connect::before {
  content: "";
  position: absolute;
  inset: 0;
  background: url("assets/ending-bg.jpg") center / cover no-repeat;
  mix-blend-mode: luminosity;
  opacity: 0.5;
  pointer-events: none;
}

.connect__inner {
  position: relative;
  width: 100%;
  height: 402px;
}

/* Photo hugs the left gutter, contact icons the right, with the row stretching
   between them. */
.connect__row {
  position: absolute;
  left: var(--gutter);
  right: var(--gutter);
  top: 90.5px;
  height: 222.02px;
}

.connect__photo {
  position: absolute;
  left: 0;
  top: 0;
  width: 276.35px;
  height: 222.02px;
  display: block;
}

.connect__contacts {
  position: absolute;
  right: 0;
  top: 67.5px;
  width: 530.7px;
  height: 87px;
  display: flex;
  gap: 23.925px;
}

.connect__contact-icon {
  display: block;
  width: 87px;
  height: 87px;
  /* Contains the blend below so it composites against the icon art only,
     rather than punching through to the section background. */
  position: relative;
  isolation: isolate;
  /* The phone tile is a <button> — it copies the number rather than going
     anywhere — so the UA's chrome has to come off for it to match its four
     neighbouring links. */
  padding: 0;
  border: 0;
  background: transparent;
  -webkit-appearance: none;
  appearance: none;
}

/* Brand colour on hover. The artwork is a dark tile with a white glyph, so a
   multiply blend does the two-tone for free: brand x white = brand (the glyph
   lights up), brand x near-black = near-black (the tile stays dark). Masking
   with the same PNG keeps the hand-drawn edge instead of squaring it off. */
.connect__contact-icon::after {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--brand, var(--purple));
  mix-blend-mode: multiply;
  -webkit-mask-image: var(--glyph);
  mask-image: var(--glyph);
  -webkit-mask-size: 100% 100%;
  mask-size: 100% 100%;
  opacity: 0;
  transition: opacity 0.35s ease;
  pointer-events: none;
}

.connect__contact-icon:hover::after {
  opacity: 1;
}

.connect__contact-icon--phone {
  --brand: #34c759;
  --glyph: url("assets/contact-phone.png");
}

.connect__contact-icon--mail {
  --brand: #ffc93c;
  --glyph: url("assets/contact-mail.png");
}

/* Deliberately a lighter sky blue than LinkedIn's own #0a66c2: sat next to
   Behance's #1769ff the two read as the same colour on hover. */
.connect__contact-icon--linkedin {
  --brand: #38bdf8;
  --glyph: url("assets/contact-linkedin.png");
}

.connect__contact-icon--behance {
  --brand: #1769ff;
  --glyph: url("assets/contact-behance.png");
}

.connect__contact-icon--dribbble {
  --brand: #ea4c89;
  --glyph: url("assets/contact-dribbble.png");
}

.connect__contact-icon img {
  display: block;
  width: 100%;
  height: 100%;
}

.connect__footer {
  margin: 82px auto 60px;
  width: 432.62px;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
}

.connect__footer-text {
  font-family: "Hardshock", sans-serif;
  font-size: 30px;
  color: var(--white);
  white-space: nowrap;
}

.connect__footer-text--accent {
  color: #f773b5;
}

.connect__footer-heart {
  width: 36.62px;
  height: 36.59px;
  display: block;
}

/* ===== About: bio + photos ===== */

.about-intro {
  width: 100%;
  margin: 20px 0 0;
  padding: 0 var(--gutter);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 51px;
}

.about-intro__heading {
  display: block;
  width: 438.12px;
}

/* Held to the About frame's 1000px grid and centred. The cells keep the
   frame's 239:255 proportions via aspect-ratio below rather than a fixed row
   height, so they stay the designed shape as the grid narrows — a fixed height
   was what cut the tops off the photos. */
.about-intro__grid {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 14.5px;
  width: 100%;
  max-width: 1000px;
}

/* Rows come from this ratio rather than a fixed height, so the cells stay the
   designed shape at any width instead of only at 1000px. */
.about-intro__photo {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 239 / 255;
  object-fit: cover;
}

/* Two rows plus the gap between them (255 * 2 + 14.5). Stating the ratio
   explicitly keeps it from stretching the rows it spans, which would leave its
   single-row neighbours short of the row edge. */
.about-intro__photo--tall {
  grid-row: span 2;
  aspect-ratio: 239 / 524.5;
}

/* ===== About: experience carousel ===== */

/* Three 1000x736 cards from the Figma frame, shown one at a time. Everything
   inside a card is positioned as a percentage of the card and sized in cqw, so
   the whole composition scales with the card instead of only matching the
   design at exactly 1000px. */
/* 100px above, 40px below — the same rhythm the work page uses. At 60/50 the
   heading sat almost exactly halfway between the photo grid and the first
   experience card and stopped reading as this section's title. */
.experience {
  width: 100%;
  margin: 100px 0 0;
  padding: 0 var(--gutter);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 40px;
}

.experience__heading {
  display: block;
  width: 436.63px;
}

.experience__viewport {
  /* Slide step lives here so the CSS and the carousel script agree on it. */
  --exp-gap: 55px;
  position: relative;
  width: 100%;
  max-width: 1000px;
  overflow: hidden;
}

.experience__track {
  display: flex;
  gap: var(--exp-gap);
  transition: transform 0.45s cubic-bezier(0.4, 0, 0.2, 1);
}

/* Same hand-drawn brush stroke the resume panels use, redrawn heavier and in
   the card's own purple — the resume's copy is a hairline and would read as a
   thin outline at the 8px weight Figma gives these cards. A 9px slice into an
   8px border keeps the stroke near its drawn thickness, and the middle slices
   are squeezed rather than stretched across the card, so the wobble survives. */
.exp-card {
  --exp-ghost-op: 0.05;
  container-type: inline-size;
  position: relative;
  flex: 0 0 100%;
  aspect-ratio: 1000 / 736;
  overflow: hidden;
  border: 8px solid transparent;
  border-image: url("assets/exp-brush-border.png") 9 stretch;
  background: rgba(0, 0, 0, 0.2);
}

/* Oversized wordmark bleeding off both corners; the card clips it.
   Figma reports rotation as the negative of the CSS angle and anchors rotated
   nodes at their own origin, so every rotated piece here uses -figmaRotation
   with transform-origin at 0 0. */
/* --exp-ghost-op is the single source of truth for how strongly the word
   sits behind the card. motion.js reads it too, so the resting value and the
   value the entrance animates up to cannot drift apart — they had, badly: the
   CSS said 0.1 and the tween finished at a flat 1, so the word was nearly
   invisible until the card animated and glaring white afterwards. */
.exp-card__ghost {
  position: absolute;
  transform-origin: 0 0;
  opacity: var(--exp-ghost-op);
  font-family: Impact, Haettenschweiler, "Arial Narrow Bold", sans-serif;
  font-size: 11.7cqw;
  line-height: 1.22;
  color: var(--white);
  white-space: nowrap;
  pointer-events: none;
}

.exp-card__ghost--top {
  left: 41.813%;
  top: -16.363%;
  transform: rotate(9.07deg);
}

.exp-card__ghost--bottom {
  left: -14.8%;
  top: 86.506%;
  transform: rotate(-2.79deg);
}

/* Exported already rotated, so its box is the rotated bounding box and no CSS
   rotation is needed — which also keeps the white border square to the photo. */
.exp-card__shot {
  position: absolute;
  left: 34.7%;
  top: 13.72%;
  width: 88.57%;
  height: auto;
}

.exp-card__shot-label {
  position: absolute;
  left: 37.8%;
  top: 32.429%;
  transform: rotate(-8.95deg);
  transform-origin: 0 0;
  font-family: Impact, Haettenschweiler, "Arial Narrow Bold", sans-serif;
  font-size: 3.6cqw;
  /* 44/36 — Figma's text box height over its font size. */
  line-height: 1.222;
  color: var(--white);
  white-space: nowrap;
}

.exp-card:nth-child(2) .exp-card__shot-label {
  left: 37.184%;
  top: 31.98%;
}

.exp-card:nth-child(3) .exp-card__shot-label {
  left: 37.873%;
  top: 32.429%;
}

.exp-card__logo {
  position: absolute;
  left: 2.8%;
  top: 4.34%;
  width: 9.5%;
  height: auto;
}

.exp-card__company {
  position: absolute;
  left: 14.1%;
  top: 5.7%;
  font-family: Impact, Haettenschweiler, "Arial Narrow Bold", sans-serif;
  font-size: 6.12cqw;
  font-weight: normal;
  line-height: 1.22;
  color: var(--white);
  white-space: nowrap;
}

.exp-card__role {
  position: absolute;
  left: 3.6%;
  font-family: "Urbanist", sans-serif;
  font-weight: 900;
  font-size: 2.6cqw;
  color: #ffd570;
  white-space: nowrap;
}

.exp-card__role--a {
  top: 26.5%;
}

.exp-card__role--b {
  top: 45.7%;
}

.exp-card__date {
  position: absolute;
  left: 3.6%;
  font-family: "Urbanist", sans-serif;
  font-weight: 500;
  font-size: 2cqw;
  color: var(--white);
  white-space: nowrap;
}

.exp-card__date--a {
  top: 31.7%;
}

.exp-card__date--b {
  top: 50.8%;
}

/* Runs upward from the intern role to the current one — the line, and above
   it the arrowhead. One construction, both cards.

   Figma draws this as a single 2px stroke with an ARROW_EQUILATERAL cap on
   its top vertex: a filled triangle 11.5 across and, by the geometry of an
   equilateral triangle, 9.96 tall. Two things worth knowing before touching
   the numbers.

   The head is not a three-borders triangle. Border widths round to whole
   pixels, so an 11.5x9.96 head drawn that way came out 10x9 — and the
   rounding travelled with the viewport, reaching 12x11 on a wide phone, which
   is not equilateral at all. The mark changed shape from screen to screen. A
   width/height pair keeps its fractions, and clip-path cuts the triangle out
   of it.

   The line stops at the head's base rather than running the mark's full
   height. It used to run the whole way with the head laid over the top, and
   since a triangle narrows to nothing at its apex, the 2px line showed
   through above it as a little chimney. `bottom: 100%` stacks the head on the
   line instead, so the apex is the topmost thing painted. */
.exp-card__connector {
  position: absolute;
  left: 5%;
  /* The mark's own top (37.5%) plus the height of the head above the line. */
  top: 38.8833%;
  width: 0.2033cqw;
  height: 4.7972%;
  background: #f9c442;
}

.exp-card__connector::before {
  content: "";
  position: absolute;
  left: 50%;
  bottom: 100%;
  /* 11.5 and 9.96 over this card's 9.84px-per-cqw content box. */
  width: 1.1687cqw;
  height: 1.0122cqw;
  background: #f9c442;
  clip-path: polygon(50% 0, 100% 100%, 0 100%);
  transform: translateX(-50%);
}

.exp-card__project {
  position: absolute;
  left: 3.6%;
  top: 31.9%;
  font-family: "Urbanist", sans-serif;
  font-weight: 900;
  font-size: 4.4cqw;
  color: #ffd570;
  white-space: nowrap;
}

.exp-card__project--sm {
  top: 32.9%;
  font-size: 3.8cqw;
}

.exp-card__desc {
  position: absolute;
  left: 3.6%;
  top: 41.3%;
  width: 28.9%;
  font-family: "Urbanist", sans-serif;
  font-weight: 500;
  font-size: 2.1cqw;
  line-height: 1.32;
  color: var(--white);
}

/* Controls sit over the card at the positions the frame puts them. */
.exp-nav {
  position: absolute;
  bottom: 3.3%;
  width: 8%;
  aspect-ratio: 1;
  padding: 0;
  border: none;
  background: transparent;
  cursor: pointer;
  transition: opacity 0.2s ease;
}

/* The press and disabled states scale the *image*, not the button. The button
   itself now carries a magnetic pull from motion.js, and that writes an inline
   transform which would outrank any transform declared here and strand the
   arrow at full size. Giving each its own element is the same split the works
   arrows and the hero photo use. --exp-arrow-flip keeps --prev's 180deg
   rotation in the mix, since scale alone would overwrite it. */
.exp-nav img {
  --exp-arrow-flip: 0deg;
  display: block;
  width: 100%;
  height: 100%;
  transform: rotate(var(--exp-arrow-flip)) scale(1);
  transition: transform 0.19s cubic-bezier(0.34, 1.56, 0.64, 1);
}

/* Mirrors --next's inset. The frame sets this one 83px in, which reads as a
   misalignment once the two are seen as a pair. */
.exp-nav--prev {
  left: 1.6%;
}

.exp-nav--prev img {
  --exp-arrow-flip: 180deg;
}

.exp-nav--next {
  right: 1.6%;
}

.exp-nav:active:not(:disabled) img {
  transform: rotate(var(--exp-arrow-flip)) scale(0.85);
  transition-duration: 0.07s;
}

/* At either end of the list the arrow fades and shrinks back, so a dead
   control reads as dead at a glance rather than only on hover. */
.exp-nav:disabled {
  opacity: 0.3;
  cursor: default;
}

.exp-nav:disabled img {
  transform: rotate(var(--exp-arrow-flip)) scale(0.8);
}

/* ===== About: My Scribble Stories ===== */

.scribbles {
  width: 100%;
  margin: 100px auto 60px;
  padding: 0 var(--gutter);
  box-sizing: border-box;
}

/* .scribbles is a plain block, so align-self did nothing here — the heading
   needs text-align to centre. */
.scribbles__title {
  margin: 0 0 40px;
  font-family: "Hardshock", sans-serif;
  font-size: 32px;
  color: #ffbe46;
  text-align: center;
}

.scribbles__viewport {
  width: 100%;
  overflow: hidden;
  -webkit-mask-image: linear-gradient(to right, transparent 0, black 60px, black calc(100% - 60px), transparent 100%);
  mask-image: linear-gradient(to right, transparent 0, black 60px, black calc(100% - 60px), transparent 100%);
}

.scribbles__track {
  --scribbles-gap: 20px;
  display: flex;
  gap: var(--scribbles-gap);
  width: max-content;
  animation: scribbles-scroll 35s linear infinite;
}

/* Hover-only pause — see the note on .design-stack__track. */
@media (hover: hover) and (pointer: fine) {
  .scribbles__viewport:hover .scribbles__track {
    animation-play-state: paused;
  }
}

/* Hit-testable on purpose. `pointer-events: none` here meant the hover rules
   in motion.css could never fire — the lift was declared but dead, the same
   way the brainstorm cards' cursor label was. Native image drag is blocked by
   draggable="false" in the markup and -webkit-user-drag below, so restoring
   pointer events costs nothing. */
.scribbles__item {
  flex: 0 0 338.5px;
  height: 362px;
  display: block;
  object-fit: cover;
  user-select: none;
  -webkit-user-drag: none;
  -webkit-touch-callout: none;
}

/* Half the items plus half a gap — a flat -50% leaves a seam. */
@keyframes scribbles-scroll {
  from {
    transform: translateX(0);
  }

  to {
    transform: translateX(calc(-50% - var(--scribbles-gap) / 2));
  }
}

/* ===== Mobile nav: burger + full-screen menu =====

   Both live in the markup on every page but are inert above the mobile
   breakpoint, so the desktop layout is untouched. */

.nav__burger,
.mnav {
  display: none;
}

/* ===== Resume viewer ===== */

/* The Resume link opens the resume as a scrollable document over a blurred
   page. The viewer covers the whole viewport, nav included, so the only thing
   left to do here is hold the page still behind it. */
body.resume-open {
  overflow: hidden;
}

.resume[hidden] {
  display: none;
}

/* Blurs everything painted behind it. It outranks .nav (210) so the header is
   blurred along with the rest of the page rather than sitting on top. */
.resume__backdrop {
  position: fixed;
  inset: 0;
  z-index: 300;
  background: rgba(12, 12, 12, 0.55);
  -webkit-backdrop-filter: blur(14px) saturate(115%);
  backdrop-filter: blur(14px) saturate(115%);
}

@supports not ((backdrop-filter: blur(2px)) or (-webkit-backdrop-filter: blur(2px))) {
  .resume__backdrop {
    background: rgba(12, 12, 12, 0.93);
  }
}

/* Ignores pointer events so a click in the empty space beside the document
   falls through to the backdrop and closes the viewer. */
.resume__frame {
  position: fixed;
  inset: 0;
  z-index: 305;
  display: flex;
  flex-direction: column;
  align-items: center;
  /* Bottom room so the document's brush edge closes the panel instead of
     running off the viewport. */
  padding: 20px 20px 22px;
  pointer-events: none;
}

.resume__bar,
.resume__doc {
  pointer-events: auto;
}

/* Header bar and document are two squared-off panels outlined in the same
   4px purple, matching the Resume Preview frame in Figma. */
.resume__bar {
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  width: 100%;
  max-width: 800px;
  height: 90px;
  margin-bottom: 19px;
  padding: 0 28px 0 32px;
  border: 6px solid transparent;
  /* 9-slice: the source is 2x, so a 12px slice drawn into a 6px border renders
     the stroke at its natural weight. The middle slices stretch from the
     frame's own 800px edge to ~920px — barely 1.15x, so the hand-drawn
     wobble survives instead of smearing. */
  border-image: url("assets/resume-brush-border.png") 12 stretch;
  background: var(--bg);
}

/* Yields first when the bar runs out of room, so the actions are never
   squeezed or pushed outside it on a narrow phone. The marker highlight is a
   pseudo-element behind the glyphs, sitting low the way a real one would. */
.resume__bar-title {
  position: relative;
  z-index: 0;
  min-width: 0;
  padding: 0 9px;
  font-family: Impact, Haettenschweiler, "Arial Narrow Bold", sans-serif;
  font-size: 44px;
  line-height: 1.1;
  letter-spacing: 0.01em;
  white-space: nowrap;
  color: var(--white);
}

/* Swept in from the left by motion.js, which tweens --resume-marker. It
   defaults to 1 so the highlight is simply present with no motion layer — a
   pseudo-element cannot be handed to GSAP directly, so the variable is the
   handle. */
.resume__bar-title::before {
  content: "";
  position: absolute;
  z-index: -1;
  left: 0;
  right: 0;
  top: 34%;
  height: 53%;
  background: rgba(105, 83, 151, 0.85);
  transform: scaleX(var(--resume-marker, 1));
  transform-origin: 0 50%;
}

.resume__bar-actions {
  flex-shrink: 0;
  display: flex;
  align-items: center;
  gap: 12px;
}

/* Backgrounds are the exported Figma artwork rather than CSS shapes — the
   torn-paper edges and grain don't reduce to a border and a fill. */
.resume__download {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 117px;
  height: 38px;
  padding: 0 14px;
  border: 0;
  background: url("assets/resume-download-bg.png") center / 100% 100% no-repeat;
  font-family: Impact, Haettenschweiler, "Arial Narrow Bold", sans-serif;
  font-size: 17.5px;
  letter-spacing: 0.02em;
  color: var(--white);
  /* The design sets black type behind white, offset by a pixel. */
  text-shadow: -1px 1px 0 #000;
  text-decoration: none;
  white-space: nowrap;
  transition: filter 0.16s ease;
}

.resume__download:hover {
  filter: brightness(1.12);
}

/* The press scales the label and the icon, never the buttons themselves.
   motion.js gives both buttons the same magnetic lean the works and experience
   arrows have, and GSAP does not merely write `transform` when it takes a
   transform over — it also writes `translate: none; rotate: none; scale: none`
   inline, specifically so the independent transform properties cannot
   interfere with it. That inline `scale: none` outranks any stylesheet rule,
   so a press declared on the button is silently dead. An inner element is the
   only thing GSAP is not holding. */
.resume__download-label,
.resume__close-icon {
  transition: transform 0.19s cubic-bezier(0.34, 1.56, 0.64, 1);
}

.resume__download:active .resume__download-label,
.resume__close:active .resume__close-icon {
  transform: scale(0.86);
  transition-duration: 0.07s;
}

.resume__close {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  width: 38.5px;
  height: 38.5px;
  padding: 0;
  border: 0;
  background: url("assets/resume-btn-bg.png") center / 100% 100% no-repeat;
  color: #5d4b84;
  cursor: pointer;
  transition: filter 0.16s ease;
}

.resume__close:hover {
  filter: brightness(1.06);
}

.resume__close svg {
  width: 17px;
  height: 17px;
  fill: currentColor;
}

.resume__close-icon {
  display: block;
  width: 17.5px;
  height: 16.4px;
}

/* One tall page that scrolls inside the panel, the way an embedded PDF reads.
   It runs to the bottom edge so the document looks like it continues. */
.resume__doc {
  flex: 1;
  width: 100%;
  max-width: 800px;
  overflow: auto;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
  border: 6px solid transparent;
  border-image: url("assets/resume-brush-border.png") 12 stretch;
  background: var(--bg);
  box-shadow: 0 -6px 60px rgba(0, 0, 0, 0.6);
}

.resume__status {
  padding: 40px 20px;
  font-family: "Hardshock", sans-serif;
  font-size: 15px;
  color: rgba(255, 255, 255, 0.65);
  text-align: center;
}

/* One canvas per PDF page, stacked so the two A4 pages read as a continuous
   document rather than something you have to page through. */
.resume__pagewrap {
  position: relative;
  margin: 0 auto 18px;
}

.resume__pagewrap:first-child {
  margin-top: 0;
}

.resume__canvas {
  display: block;
  width: 100%;
  height: auto;
}

/* PDF.js text layer: transparent glyphs positioned over the canvas, so the
   rendered page can still be selected and copied. */
.textLayer {
  position: absolute;
  inset: 0;
  z-index: 1;
  overflow: hidden;
  line-height: 1;
  text-align: initial;
  text-size-adjust: none;
  forced-color-adjust: none;
  transform-origin: 0 0;
  caret-color: CanvasText;
}

.textLayer span,
.textLayer br {
  position: absolute;
  white-space: pre;
  color: transparent;
  transform-origin: 0 0;
  cursor: text;
}

.textLayer ::selection {
  background: rgba(210, 192, 249, 0.4);
}

/* Hyperlinks from the PDF, sitting above the text layer so a click reaches the
   link rather than starting a text selection. */
.resume__links {
  position: absolute;
  inset: 0;
  z-index: 2;
}

.resume__link {
  position: absolute;
  display: block;
  border-radius: 4px;
  transition: background 0.15s ease, box-shadow 0.15s ease;
}

.resume__link:hover,
.resume__link:focus-visible {
  background: rgba(210, 192, 249, 0.22);
  box-shadow: 0 0 0 2px rgba(210, 192, 249, 0.55);
  outline: none;
}

.resume__fallback {
  padding-bottom: 1px;
}

.resume__fallback-note {
  padding: 22px 20px;
  font-family: "Hardshock", sans-serif;
  font-size: 14px;
  color: rgba(255, 255, 255, 0.7);
  text-align: center;
}

.resume__fallback picture {
  display: block;
}

.resume__page {
  display: block;
  width: 100%;
  height: auto;
}

/* The mobile frames carry every width below the desktop composition, which is
   laid out in fixed pixels against a ~1600px canvas and does not reflow — at
   834px it used to scroll sideways by 287px. Most of what follows is already
   fixed-width and simply centres itself as the viewport grows; --col caps the
   handful of things that would otherwise stretch, so a tablet reads as the
   mobile design with room around it rather than a magnified phone. */
@media (max-width: 1023px) {

  /* Each section heading has its own mobile export. The frames redraw them —
     the script word clears the band instead of overlapping it — so the desktop
     artwork is a different composition, not the same one at another size, and
     the widths below are each frame's drawn bounds. */

  /* ===== Mobile chrome ===== */

  :root {
    --gutter: 16px;
    /* Content column. 390px is the frame width; the cap keeps a tablet from
       inflating cards and type past the proportions they were drawn at. */
    --col: min(100vw, 560px);
  }

  .nav {
    padding-top: 0;
  }

  /* 97 + the 3px progress bar = a 100px header, the same total the desktop bar
     was cut to. The frame draws this band 135 tall and opens the hero at 135;
     the user asked for the trim instead, so every section below simply sits
     35px higher than the frame's absolute y while keeping the frame's spacing
     between them exactly. */
  .nav__inner {
    height: 97px;
    padding: 0 18px 0 20px;
    align-items: flex-start;
  }

  /* 45.75x61 keeps the drawn 54:72 proportion and leaves an even 18px above
     and below it in the shorter band. */
  .nav__logo {
    height: 61px;
    width: 45.75px;
    margin-top: 18px;
  }

  /* The desktop link row is replaced wholesale by the burger + overlay. */
  .nav__links {
    display: none;
  }

  .nav__burger {
    display: flex;
    flex-direction: column;
    justify-content: center;
    gap: 6px;
    width: 36px;
    height: 36px;
    /* Held at 36 rather than scaled with the band — it is the only tap target
       in the header and shrinking it further would put it under the comfortable
       minimum. 30 centres it in the 97px band. */
    margin-top: 30px;
    padding: 0;
    border: none;
    background: transparent;
    cursor: pointer;
  }

  .nav__burger span {
    display: block;
    width: 24px;
    height: 2px;
    margin: 0 auto;
    background: var(--white);
  }

  /* ===== Full-screen menu overlay ===== */

  /* The four items stand 744px tall, which overflows any phone shorter than
     an iPhone 14 — hidden overflow left CONTACT unreachable. */
  /* Above `.nav`'s 210, not below it. At 100 the sticky header painted its own
     dark band and logo over the top of the overlay, which clipped the WORK item
     and hid the overlay's own close button behind the burger. */
  .mnav {
    display: none;
    position: fixed;
    inset: 0;
    z-index: 300;
    height: 100vh;
    height: 100dvh;
    overflow-y: auto;
    /* The active item's band and wedge are meant to bleed past both page
       edges. Setting only overflow-y computes the x-axis to `auto` as well,
       which turned that bleed into a sideways scroll — clip it instead so the
       overlay moves vertically and nothing else. */
    overflow-x: hidden;
    overscroll-behavior: contain;
    -webkit-overflow-scrolling: touch;
    background: var(--bg);
  }

  .mnav.is-open {
    display: block;
  }

  body.menu-open {
    overflow: hidden;
  }

  /* Fixed rather than absolute so it stays put while the list scrolls under
     it. .mnav has no transform, so this still resolves to the viewport. */
  /* `1479:276` puts the cross at x326, y50 at 36x36 — the WORK band opens at
     106, clear below it. */
  .mnav__close {
    position: fixed;
    top: 50px;
    right: 28px;
    z-index: 4;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 36px;
    height: 36px;
    padding: 0;
    border: none;
    background: transparent;
    opacity: 0.9;
    cursor: pointer;
  }

  .mnav__close svg {
    width: 22px;
    height: 22px;
    fill: var(--white);
  }

  /* The frame draws this list 792px tall. That is taller than the viewport on
     most phones once the browser's own chrome is counted, so the last item sat
     below the fold and the overlay had to be scrolled to reach it — which is
     what "work is stuck at the top and I can't scroll down" was. Everything
     below is that composition scaled to fit in one screen: same band, same
     wedge, same ghost word, ~604px tall instead of 792. */
  /* Three frames now describe this list, one per highlighted item — `1479:276`
     (WORK), `2168:46` (ABOUT) and `2168:57` (CONTACT) — and the rules below are
     the one set that satisfies all three at once.

     Read across them, the pattern is that the highlighted item is a *block*,
     not a line: its band reaches 56px above the label box and its wedge 85px
     below, and every block is separated by the same gap. So the list opens at
     106 — where the band starts when WORK is highlighted, and where the WORK
     label itself sits when it is not — and the highlighted item carries 56/85
     of its own. Every label box across all three frames then lands within
     2.4px of where it is drawn. */
  .mnav__list {
    display: flex;
    flex-direction: column;
    padding: 106px 0 68px 42px;
    gap: 53.5px;
  }

  .mnav__item {
    position: relative;
    align-self: flex-start;
    text-decoration: none;
  }

  .mnav__label,
  .mnav__ghost {
    display: block;
    font-family: Impact, Haettenschweiler, "Arial Narrow Bold", sans-serif;
    text-transform: uppercase;
    white-space: nowrap;
    line-height: 81px;
  }

  /* Solid white at 40%, not an outline. Every unhighlighted label in all three
     frames is a plain white fill with the *node* at opacity 0.4 and no stroke
     on it at all — the 1.13 strokeWeight those nodes carry is a leftover
     default with an empty strokes array, which is what this was built from. */
  .mnav__label {
    position: relative;
    z-index: 3;
    font-size: 66px;
    color: rgba(255, 255, 255, 0.4);
    -webkit-text-stroke: 0;
  }

  /* The oversized ghost word only belongs to the highlighted item. */
  .mnav__ghost {
    display: none;
  }

  .mnav__item--active .mnav__ghost {
    display: block;
    position: absolute;
    left: 22px;
    top: -25px;
    z-index: 2;
    font-size: 107.75px;
    line-height: 131px;
    color: #ddcdff;
    opacity: 0.1;
  }

  .mnav__item--active .mnav__label {
    color: #ddcdff;
  }

  /* Extra room above and below so the band has space to bleed into. */
  /* The band's and the wedge's own reach. No `:first-child` exception: when
     WORK is the highlighted one this 56 is exactly what puts its label on the
     frame's 162 with the band starting at the list's own 106. */
  .mnav__item--active {
    margin: 56px 0 85px;
  }

  /* Tilted lavender wedge, behind the band and only visible below it. */
  .mnav__item--active::before {
    content: "";
    position: absolute;
    left: -33px;
    top: 23px;
    z-index: 0;
    width: 350px;
    height: 143px;
    background: #e8ddff;
    transform: rotate(-4.72deg);
    transform-origin: 0 0;
  }

  /* Dark band, overhanging both page edges as it does in the frame. */
  .mnav__item--active::after {
    content: "";
    position: absolute;
    left: -63px;
    top: -56px;
    z-index: 1;
    width: 428px;
    min-width: calc(100vw + 38px);
    height: 193.1px;
    background: #5c4c7f;
  }

  /* ===== Landing: hero =====

     The desktop hero pins and scrolls under the panel; the mobile frame is a
     plain block, so the sticky behaviour is unwound here. */

  /* Sticky, exactly as the desktop hero is. This is what makes the cloud read
     as rising *over* the hero rather than the whole page sliding as one slab:
     the hero holds its position while `.animate` (z-index 1 against the hero's
     0) climbs across it and the hero dims and blurs behind. Resetting this to
     `relative` is what made the mobile handoff feel stuck.

     The `min()` matches desktop's: 0 on any viewport taller than the hero, and
     negative on a short one so the hero's bottom edge still reaches the top. */
  /* No `overflow: hidden` here. Tilting the collab badge swings its right-hand
     end — the Motiff logo — 6px below the hero's own 626, and clipping cut the
     card in half. Nothing else in this hero reaches its edges: the photo ends
     at 534 and stays inside even at the Ken Burns 1.06, the fade is flush at
     390x354, the star ends at 595. The 6px that now bleeds sits in the dark gap
     before `.animate`, which paints over it anyway at z-index 1 against this
     element's 0. Keeping the clip would mean moving the badge off the position
     the frame draws it at, to hide a problem the clip was causing. */
  .hero {
    position: sticky;
    top: min(0px, 100vh - 626px);
    z-index: 0;
    height: 626px;
    margin: 0;
  }

  .hero__greeting {
    top: 13px;
    font-size: 16px;
  }

  .hero__name {
    font-size: 16px;
  }

  /* 58px is the design size at 390px wide; below that it scales so the longest
     line ("UI UX DESIGNER") never runs past the screen edge. */
  .hero__title {
    font-size: min(58px, 15vw);
  }

  .hero__title--1 {
    top: 57px;
    color: var(--white);
  }

  /* Filled, not outlined — the frame draws this solid in the hero purple. */
  .hero__title--2 {
    top: 127px;
    color: #695397;
    -webkit-text-stroke: 0;
  }

  /* This is the tilted rect's *bounding* box, not the 289x331 rect: the mobile
     export carries the frame's 2.79deg rotation in its pixels, so the box that
     holds it is the 305x344.2 the frame reports at x42.5, y190.4. Nothing here
     rotates anything — GSAP owns the transform on this element, on
     .hero__photo-zoom (hover) and on .hero__photo (entrance), and two things
     writing one transform is the bug that keeps biting this file. */
  .hero__photo-wrap {
    top: 190.4px;
    margin-left: -152.5px;
    width: 305px;
    height: 344.2px;
  }

  /* 390x354 at x0, y189 in the hero — the frame's own gradient, and its own
     position, which is why these offsets are the negative of the wrap's rather
     than anything to do with the photo's edges.

     Width is the viewport rather than the frame's literal 390: the wrap is
     centred, so a hardcoded 390 hangs 15px off each side of a 360px phone —
     and the right-hand 15px is a real horizontal scroll on the document. That
     sideways slop is what let the page be panned and zoomed on a phone, which
     in turn threw the fixed overlays (the focused clip, the back-to-top
     button) off the screen they are supposed to be pinned to. `100vw` with
     the offset written as its negative half reproduces exactly -42.5/390 at
     390px wide and keeps the gradient flush with both edges at every other
     width. */
  .hero__photo-fade {
    left: calc(152.5px - 50vw);
    top: -1.4px;
    width: 100vw;
    height: 354px;
    background: linear-gradient(to bottom, rgba(27, 27, 27, 0) 39.9%, rgba(27, 27, 27, 1) 92.8%);
  }

  /* Below the illustrator badge in this frame, not above it. */
  .hero__badge--collab .hero__badge-tilt {
    transform: rotate(4.409deg);
  }

  /* Same 2.5 across. No vertical correction: the two logos disagree about the
     sign here (-5.2 and +1.7), which is the badge's own internal spacing rather
     than a placement error, and averaging it would only move the disagreement
     around. */
  .hero__badge--collab {
    margin-left: -24.5px;
    top: 543px;
    width: 195px;
    height: 83px;
  }

  .hero__collab-text {
    left: 3px;
    top: -7px;
    width: 157px;
    font-size: 16px;
    opacity: 1;
  }

  .hero__collab-logo--1 {
    left: 35px;
    top: 39px;
    width: 82px;
    height: 37px;
  }

  .hero__collab-logo--2 {
    left: 123px;
    top: 46px;
    width: 69px;
    height: 36px;
  }

  .hero__collab-arrow {
    left: 158px;
    top: 25px;
    width: 20px;
    height: 20px;
  }

  /* Figma reports its rotation as the negative of the CSS angle, so the frame's
     +4.797 is this. Rotating about the box's own centre keeps every child's
     offset inside the badge untouched. */
  .hero__badge--illustrator .hero__badge-tilt {
    transform: rotate(-4.797deg);
  }

  /* Tilting about the centre moved the drawn content off the frame's ink by a
     consistent 2.5 across and 5 down, measured on the two stickers — the only
     tight boxes in here — so the badge gives it back. */
  .hero__badge--illustrator {
    margin-left: -165.5px;
    top: 455px;
    width: 182px;
    height: 86px;
  }

  /* Figma leaves the line height on auto, which opens the two lines further
     apart than the desktop's 1.2 — the ink stands 60px tall, not 47. */
  .hero__illustrator-text {
    left: 0;
    top: 10px;
    width: 212px;
    font-size: 18.3px;
    line-height: 1.5;
    color: var(--purple);
  }

  .hero__illustrator-text span {
    font-size: 20.6px;
    color: var(--white);
  }

  .hero__sticker--1 {
    left: 113px;
    top: 24px;
    width: 34px;
    height: 36px;
  }

  .hero__sticker--2 {
    left: 137px;
    top: 25px;
    width: 29px;
    height: 31px;
  }

  /* The star's own frame in Figma carries a white fill behind the glyph, which
     is an import artifact — on a dark page it reads as a white block. Only the
     vector is exported, at 26% white, and its tilt is baked into the PNG. */
  .hero__star {
    display: block;
    position: absolute;
    left: 50%;
    margin-left: -118px;
    top: 555px;
    width: 45px;
    height: 40px;
  }

  /* ===== Landing: animate =====

     The mobile frame uses its own cloud silhouette. It is drawn at the top and
     the solid fill picks up below the bumps so the section can be any height. */

  .animate {
    /* The hero ends at 761 and the frame opens this section at 851. */
    margin-top: 90px;
    padding: 30px 0 33px;
    background:
      url("assets/mobile-animate-bg.svg") top center / 100% auto no-repeat,
      linear-gradient(#2d2d2d, #2d2d2d) center 55px / 100% calc(100% - 55px) no-repeat;
  }

  /* The heading is absolutely positioned, so the carousel has to be pushed
     clear of it by hand. 30 + 16 puts the heading at 46 and 30 + 143 puts the
     video window at 173, which is where the frame has them. The 27px sides
     make the window exactly one 336px card wide. */
  .animate__inner {
    padding: 143px 27px 0;
  }

  /* 203x109 at x94 in the frame; -101 is that x off the centre line. */
  .animate__heading {
    margin-left: -101px;
    top: 16px;
    width: 202.6px;
    height: 109.4px;
  }

  .animate__carousel {
    -webkit-mask-image: none;
    mask-image: none;
  }

  /* The frame draws one still 336x240 window, and forcing every clip into that
     box is what squared off the portrait phone crops — the Valorant clip is a
     0.45 ratio and was being stretched to 1.4. So the mobile row works the way
     the desktop row does: the height is fixed and each clip's own `--w` sets
     its width, which is the only way a 0.45 and a 1.74 can sit in one strip
     without either being cropped or letterboxed.

     0.4428 is 240/542 — every source clip is 542px tall, so this is that one
     downscale applied to the encoded width, the same relationship desktop
     expresses as `--w * 0.5px` for its 271px row. */
  .animate__clip {
    height: 240px;
    width: calc(var(--w) * 0.4428px);
    aspect-ratio: auto;
  }

  /* 32px, matching desktop. The frame has no gap to copy — it draws a single
     still — and at 16 the clips still read as one moving strip. The marquee
     reads this back off the computed style, so its loop stays seamless. */
  .animate__track {
    gap: 32px;
  }

  /* ===== Landing: projects ===== */

  /* The animate block closes at 1297 and the frame opens the heading at
     1387.3. */
  .projects {
    margin-top: 90.3px;
  }

  /* 173px is the heading's box in the Figma mobile frame; the artwork's own
     aspect ratio matches that box, so the width alone places it. */
  .projects__heading {
    width: 179.5px;
    margin-bottom: 20.2px;
    height: 108.5px;
  }

  /* The four cards stack edge to edge, so their heights are the whole ladder
     from the heading down to the button. Three of them are 264; the frame draws
     Hausbot 2px taller and Lie & Spy 10px shorter. */
  .project-card {
    /* The trimmed header: 97 of nav plus the 3px progress bar. */
    top: 100px;
    height: 264px;
  }

  .project-card:nth-of-type(2) {
    height: 266px;
  }

  /* Lie & Spy. The art hangs past the bottom edge in the frame too — the group
     is 297 tall inside a 253.6 card — so the crop here is the drawing, not an
     overflow. */
  .project-card:nth-of-type(4) {
    height: 253.6px;
  }

  .project-card__title {
    top: 6px;
    font-size: 30px;
  }

  .project-card__desc {
    top: 69px;
    width: 357px;
    font-size: 12px;
  }

  .project-card__thumb--1 {
    top: 113px;
    width: 325px;
    height: 172px;
  }

  .project-card__thumb--2 {
    top: 114px;
    width: 324px;
    height: 216px;
  }

  .project-card__thumb--3 {
    top: 113px;
    width: 328px;
    height: 187px;
  }

  .project-card__thumb--4 {
    top: 106.2px;
    width: 311.7px;
    height: 148.8px;
  }

  .project-card__tag--1 {
    margin-left: 62px;
    top: 22px;
    width: 90px;
    height: 49px;
  }

  .project-card__tag--2 {
    margin-left: 55px;
    top: 24px;
    width: 82px;
    height: 48px;
  }

  .project-card__tag--3 {
    margin-left: 28px;
    top: 25px;
    width: 71px;
    height: 46px;
  }

  .project-card__tag--4 {
    margin-left: 55px;
    top: 25px;
    width: 70px;
    height: 46px;
  }

  /* Both words are one Urbanist Black run at Figma's own 27.41px; 33px is what
     the family's auto line height comes to there, which is the box height the
     frame reports. Each rule below is that frame's text box, so the glyph ink
     lands where the frame draws it without having to correct for side bearing. */
  .project-card:nth-of-type(4) .project-card__desc {
    top: 64px;
    width: 362px;
    font-size: 11px;
  }

  .project-card__soon {
    font-size: 27.41px;
    line-height: 33px;
  }

  /* Centred in the frame, not left-aligned as the desktop card has it. */
  .project-card__soon--left {
    margin-left: -161.7px;
    top: 150.4px;
    width: 148.5px;
    text-align: center;
  }

  .project-card__soon--right {
    margin-left: 57.3px;
    top: 179.8px;
    width: 78px;
  }

  /* The exported art is the spy as the card clips him — the group in the frame
     runs 297px tall and only its top 163 are drawn — so this is his full box
     and it finishes exactly on the card's bottom edge. It was sitting 80px
     right of here, which put it over SOON and hid the word completely. */
  .project-card__art {
    margin-left: -73px;
    top: 90.6px;
    width: 159.1px;
    height: 163px;
  }

  .projects__view-all {
    width: 196px;
    margin-top: 32.4px;
  }

  /* ===== Landing: works =====

     Desktop stands the note up beside the screenshot; the mobile frame is one
     landscape card with the screenshot stacked underneath the copy. */

  /* The VIEW ALL button's artwork renders 81.6 tall against the frame's 79, so
     this margin is 2.6 short of the frame's own 100.9 gap and swallows the
     difference — the heading still lands on 2775.9. */
  .works {
    margin-top: 98.3px;
    gap: 17.5px;
  }

  .works__heading {
    width: 153.2px;
    height: 106.6px;
  }

  /* 358x320 matches the Figma card. The height has to stay fixed: the rule
     under the date is painted into the background artwork at a fixed fraction
     of its height, so the copy below it can only be positioned reliably
     against a known height. */
  .works__content {
    width: 358px;
    height: 320px;
  }

  /* The lavender panel, its hand-drawn frame and the rule under the date are
     all one exported image, so the border and ::after rule that used to fake
     them are gone. Stretched rather than `cover`ed — cover would crop the
     frame off the edges. The flat colour underneath is what shows if the
     artwork is missing. */
  .works__note {
    width: 100%;
    height: 100%;
    background: url("assets/works-note-bg-mobile.png") 0 0 / 100% 100% no-repeat,
      #b2b2d6;
    border: 0;
  }

  .works__note-date {
    left: 23px;
    top: 26px;
    width: auto;
    font-size: 14px;
    text-align: left;
  }

  /* Clears the baked-in rule, which sits at ~72px on a 320px-tall card. */
  .works__note-desc,
  .works__note-desc--2 {
    left: 23px;
    right: 23px;
    top: 86px;
    width: auto;
    font-size: 16px;
    text-align: left;
  }

  .works__note-title {
    display: none;
  }

  /* The desktop screenshot is the hand-drawn purple frame with the brand
     painted into its header. The mobile crop is the untilted interior, so the
     brand has nowhere to live inside the picture and stands on the card
     instead — top right, against the 23px gutter the copy already uses. */
  .works__brand {
    position: absolute;
    right: 23px;
    top: 26px;
    display: flex;
    align-items: center;
    gap: 2px;
    height: 34px;
  }

  .works__brand-mark {
    display: block;
    width: auto;
    height: 34px;
    object-fit: contain;
  }

  /* Motiff's mark is drawn to a smaller square than Creatie's. */
  .work-item:nth-child(2) .works__brand-mark {
    height: 30px;
  }

  .works__brand-name {
    font-family: Impact, Haettenschweiler, "Arial Narrow Bold", sans-serif;
    font-size: 20px;
    line-height: 24px;
    color: var(--white);
    white-space: nowrap;
  }

  .works__shot {
    left: 23px;
    right: 23px;
    top: 138px;
    width: auto;
    height: 156px;
  }

  /* cover, not contain: the mobile source is already cropped to this box's
     ratio, so cover fills it exactly and leaves no letterbox. contain is a
     desktop necessity only because the artwork's frame runs to the PNG edge. */
  .works__screenshot {
    object-fit: cover;
  }

  /* The frame now steps through with a pair of 39.6px arrow buttons at x151 and
     x200.4 — `2165:32`, which replaced the dot row that used to sit here. */
  .works__arrows {
    display: flex;
    gap: 9.8px;
    margin-top: -0.4px;
  }

  .works__arrow {
    width: 39.6px;
    height: 39.6px;
  }

  /* 17px below the card in the frame; the section's own 20px gap covers all
     but 3 of it. */
  /* The flex gap above puts the dots 17.5 below the card; the frame has 17.1. */
  .works__dots {
    display: none;
  }

  /* Every button is the unlit track. Nothing here reacts to `.is-active` — the
     lit state is the pip below, because a class can only ever be on one dot and
     the highlight has to be able to stand between them mid-drag. */
  .works__dot {
    width: 8px;
    height: 8px;
    padding: 0;
    border: none;
    border-radius: 50%;
    background: rgba(255, 255, 255, 0.3);
    cursor: pointer;
  }

  /* --dot-p is the deck's position in cards: 0 on the first, 1 on the second,
     0.4 when the card has been dragged 40% of the way. 16px is one dot plus one
     gap, so the pip lands dead on each dot at every whole number and travels
     with the card in between. motion.js owns the value. */
  .works__dot-pip {
    position: absolute;
    left: 0;
    top: 0;
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: var(--white);
    transform: translateX(calc(var(--dot-p, 0) * 16px));
    pointer-events: none;
  }

  /* The card has to be able to take a horizontal drag while the page keeps
     vertical panning — same split the brainstorm deck uses. */
  .works__content {
    touch-action: pan-y;
  }

  /* ===== Landing: brainstorms ===== */

  /* The works section closes on the arrow row's bottom edge at 3276.7 now, not
     the old dot row's 3245.1, and the frame opens this heading at 3338.5. */
  .brainstorms {
    margin-top: 61.8px;
  }

  .brainstorms__heading {
    width: 241.2px;
    height: 91.2px;
  }

  .brainstorms__main {
    margin-top: 16.3px;
    background: #b7a285;
  }

  /* The frame draws this panel 269 tall and the deck filled 209 of it, which
     left the quote too small to read on a phone. Panel and deck both grow by
     1.4: every note's width and its offset from the centre line scale by that,
     and the tops scale with it and then shift up 8.3 to re-centre the taller
     pile in the taller panel. Everything below this section therefore sits 91px
     lower than the frame's y — that is intended, not drift. */
  .brainstorms__stack {
    height: 360px;
  }

  /* The five notes are drawn 14% larger here than the frame has them, and with
     the deck's depth offsets stacked on top the resting pile ran 39px past the
     panel's bottom edge and got clipped — which is what read as the cards
     sitting in a corner. Boxes are the frame's size now; `depth()` in motion.js
     carries the matching shorter step. */

  /* At 77px "ROUGH IDEAS" and "INSPIRATION" are wider than the frame and broke
     onto a second line. `nowrap` states the intent and the size is capped
     against the viewport so the longest word still fits on the narrowest
     phone; `top` re-centres the shorter box on the deck's own centre. */
  .brainstorms__title {
    top: 141px;
    height: 78px;
    font-size: min(72px, 18.5vw);
    line-height: 78px;
    white-space: nowrap;
  }

  .brainstorm-note {
    height: auto;
  }

  .brainstorm-note--1 {
    margin-left: -109.5px;
    top: 33.7px;
    width: 195px;
  }

  .brainstorm-note--2 {
    margin-left: -96.5px;
    top: 50.5px;
    width: 201.2px;
  }

  .brainstorm-note--3 {
    margin-left: -102.5px;
    top: 60.3px;
    width: 213.4px;
  }

  .brainstorm-note--4 {
    margin-left: -79.4px;
    top: 56.1px;
    width: 185.2px;
  }

  .brainstorm-note--5 {
    margin-left: -111.4px;
    top: 61.7px;
    width: 206.1px;
  }

  /* ===== Landing: design stack ===== */

  /* `.design-stack__scroll` carries `padding: 30px 0` against `margin: -30px 0`
     so the hover lift has room to bleed, which means the gap has to clear that
     30px before the icon row's own top edge lands on the frame's 3915. */
  .design-stack {
    margin-top: 89.9px;
    gap: 21.1px;
  }

  .design-stack__heading {
    width: 242px;
    height: 89px;
  }

  .design-stack__scroll {
    -webkit-mask-image: none;
    mask-image: none;
    /* The row takes a horizontal drag; the page keeps vertical panning. */
    touch-action: pan-y;
  }

  /* Drives both the flex gap and the loop distance in the keyframes. */
  .design-stack__track {
    --stack-gap: 16px;
  }

  .design-stack__icon {
    flex: 0 0 120px;
    width: 120px;
    height: 120px;
  }

  /* ===== Connect / footer ===== */

  /* ending-bg.jpg has "Let's Connect" baked into its middle band, so mobile
     uses ending-bg-mobile.jpg — the same grain cropped from a strip below the
     wordmark. Keeps the textured backdrop, drops the lettering. */
  /* Background is left to the desktop rules so the textured backdrop and its
     baked-in "Let's Connect" wordmark carry over to mobile. The duplicate
     wordmark that used to sit on .connect__inner::before is gone, so only the
     one in the artwork shows. */
  .connect {
    margin-top: 90px;
  }

  .connect__inner {
    height: 230px;
    overflow: hidden;
  }

  .connect__row {
    left: 0;
    right: 0;
    top: 0;
    height: 230px;
  }

  /* ending-photo.png is a 276x222 composite, so its width sets its height.
     At 164px it ran to y=165 and collided with the icon row at y=146; 127px
     keeps the whole composite clear of it. */
  .connect__photo {
    left: 0;
    top: 27px;
    width: 127px;
    height: auto;
  }

  .connect__contacts {
    left: 21px;
    right: auto;
    top: 151px;
    width: 348px;
    max-width: calc(100% - 42px);
    height: 57px;
    gap: 15.7px;
  }

  .connect__contact-icon {
    width: 57px;
    height: 57px;
    flex: 1 1 0;
  }

  /* 52px of tail below the footer is what closes the work frame at 4487. */
  .connect__footer {
    width: auto;
    margin: 38px auto 52px;
    gap: 6px;
  }

  /* The about frame closes 4px earlier, at 2387. */
  body.about-page .connect__footer {
    margin-bottom: 48px;
  }

  .connect__footer-text {
    font-size: 16px;
  }

  .connect__footer-heart {
    width: 16px;
    height: 16px;
  }

  /* ===== About: bio + photo grid ===== */

  /* The frame opens the heading at 132.2 — 2.8px *above* where the 135px
     header ends. Nothing can be gained by chasing that: pulling the section up
     would slide the artwork's own top edge under the nav's solid band, and
     shortening the nav only for this page makes the header jump 2.8px on every
     transition between the two. So the heading starts at 135 and the gap below
     it gives the 2.8 back, which puts the photo grid — and everything after
     it — on the frame's number. */
  .about-intro {
    margin-top: 0;
    padding: 0 17px;
    gap: 18.3px;
  }

  .about-intro__heading {
    width: 221.2px;
    height: 110.7px;
  }

  /* The cell ratios are already correct from the base rules; only the gap has
     to shrink, by the same 14.5/1000 the design uses. */
  .about-intro__grid {
    gap: calc((100vw - 34px) * 0.0145);
  }

  /* ===== About: experience carousel =====

     A different card, not the desktop one shrunk. Desktop stands the photo
     beside the copy in a landscape card; the mobile frame is portrait and
     stacks them — logo and company, then the shot straight rather than
     tilted, then the roles, then the arrows *inside* the card. Every figure
     below is the Figma mobile card (356x545) expressed against the card's own
     box, so the whole thing scales as one when the card grows on a tablet. */

  /* Measured off the bottom of the knowmore block's *box*, which stops at 804
     — its arrow is pulled below that with a negative margin on purpose, so the
     box is 46 tall where the frame's group reads 65.4. */
  .experience {
    margin-top: 75.1px;
    padding: 0 17px;
    gap: 27px;
  }

  .experience__heading {
    width: 213px;
    height: 87px;
  }

  .experience__viewport {
    --exp-gap: 20px;
  }

  /* The card is border-box 356x545 with a 3px brush border, so an absolutely
     positioned child resolves its percentages against the 350x539 padding box
     and starts from (3, 3). Every figure below is therefore Figma's coordinate
     with the border subtracted — (x - 3) / 350 across, (y - 3) / 539 down —
     and 1cqw is 3.5px, the content box's width. Converting against the border
     box instead pulls the whole composition in by ~7px and shrinks it ~4.5%,
     which is small enough to look like nothing and wrong everywhere. */
  .exp-card {
    aspect-ratio: 356 / 545;
    /* 8px of painted edge was too heavy at this size. Dropping to 3 changes
       the padding box every child resolves its percentages against — 340x529
       becomes 350x539 — so every figure in this block was re-derived through
       the card's own coordinates rather than nudged, and every cqw size came
       down by 340/350 to keep its rendered pixel size. */
    border-width: 3px;
  }

  /* Same wordmark, but the bottom copy leans the other way here. */
  .exp-card__ghost {
    font-size: 12.0001cqw;
  }

  .exp-card__ghost--top {
    left: 29.429%;
    top: -5.38%;
    transform: rotate(9.07deg);
  }

  .exp-card__ghost--bottom {
    left: -3.486%;
    top: 90.056%;
    transform: rotate(3.446deg);
  }

  .exp-card__logo {
    left: 3.143%;
    top: 2.041%;
    width: 11.256%;
  }

  .exp-card__company {
    left: 15.143%;
    top: 2.597%;
    font-size: 6.2861cqw;
  }

  /* The desktop asset is exported already rotated, which is why it needs no
     CSS angle there and why it cannot be reused here: this frame wants the
     photo square. The mobile source is that same photo straightened, cropped
     to exactly this box's ratio, so cover fills it with nothing left over.
     The white edge is a real border here rather than part of the artwork. */
  /* The frame specifies a crisp 4.3px white stroke here, and that is what this
     was. The user wanted the painted edge the desktop card has instead — so the
     mobile asset is now the desktop artwork with its baked 8.68deg tilt rotated
     back out, which leaves the brush frame intact around a square photo. It
     comes out at a 1.4537 ratio against this box's 1.4575, so it drops in with
     no crop; `contain` rather than `cover` guarantees the painted edge is never
     the thing that gets trimmed by that 0.3%. */
  /* The shot assets are the desktop artwork straightened and then cropped to
     the *photograph*, not to its white card: that card is a good deal larger
     than the picture sitting on it, and showing the whole thing left a bare
     white wedge along two edges of every shot. The painted edge is the card's
     own brush border applied here instead, so both frames on this card are the
     same artwork at the same weight. */
  .exp-card__shot {
    left: 5.257%;
    top: 12.95%;
    width: 89.2%;
    height: 39.739%;
    object-fit: cover;
    border: 3px solid transparent;
    border-image: url("assets/exp-brush-border.png") 9 stretch;
  }

  .exp-card__shot-label,
  .exp-card:nth-child(2) .exp-card__shot-label,
  .exp-card:nth-child(3) .exp-card__shot-label {
    left: 8.857%;
    top: 15.269%;
    font-size: 4.2859cqw;
    /* Straight, matching the photo underneath it. */
    transform: none;
  }

  .exp-card__role {
    left: 4.857%;
    font-size: 4.8571cqw;
  }

  .exp-card__role--a {
    top: 58.441%;
  }

  .exp-card__role--b {
    top: 74.026%;
  }

  .exp-card__date {
    left: 4.857%;
    font-size: 3.7147cqw;
  }

  .exp-card__date--a {
    top: 62.894%;
  }

  .exp-card__date--b {
    top: 78.479%;
  }

  /* The same mark with this card's numbers. The frame runs it from y366.9 to
     y393.3 with the line centred on x42, and the head is the top 9.96 of
     that — so the line itself begins at y376.86.

     Percentages resolve against the card's padding box, and this card carries
     a 3px border: 350x539 inside a 356x545 card, offset 3px on both axes.
     Missing that is what put the mark 2.6px right of where the frame draws
     it. cqw is the content box too, so 1cqw is 3.5px here. */
  .exp-card__connector {
    left: 6%;
    top: 69.3618%;
    width: 0.5714cqw;
    height: 3.0501%;
  }

  /* 11.5 and 9.96 over this card's 3.5px-per-cqw content box. */
  .exp-card__connector::before {
    width: 3.2857cqw;
    height: 2.8457cqw;
  }

  .exp-card__project,
  .exp-card__project--sm {
    left: 4.857%;
    top: 58.441%;
    font-size: 4.8571cqw;
  }

  .exp-card__desc {
    left: 4.857%;
    top: 63.265%;
    width: 90.285%;
    font-size: 3.7147cqw;
  }

  /* Inside the card at this width. `prev` and `next` are 40x40 groups at
     x14 / x302, y489 in the 356x545 card, so both are inset 14 from their own
     side and 16 from the bottom — expressed as percentages of the card so the
     whole thing scales together when the card grows on a tablet. */
  .exp-nav {
    bottom: 2.936%;
    width: 10.915%;
  }

  .exp-nav--prev {
    left: 5.249%;
  }

  .exp-nav--next {
    right: 5.249%;
  }

  /* 22px under the card in the frame; the section gap covers 27 of it. The
     frame sets the 40px dot row at x171, 4px left of the page centre — the
     right margin takes the row's own centre off by that much rather than
     writing a transform motion.js would have to share. */
  .experience__dots {
    display: flex;
    justify-content: center;
    gap: 8px;
    margin-top: -5px;
    margin-right: 8px;
  }

  .experience__dot {
    width: 8px;
    height: 8px;
    padding: 0;
    border: none;
    border-radius: 50%;
    background: rgba(255, 255, 255, 0.3);
    cursor: pointer;
    transition: background 0.25s ease, transform 0.25s ease;
  }

  .experience__dot.is-active {
    background: var(--white);
    transform: scale(1.05);
  }

  /* ===== About: "Go on, to know more" =====

     Mobile only, from the Figma mobile frame — a nudge from the photo grid
     down into the experiences. The arrow is drawn pointing down and right and
     hangs below the baseline in the frame, hence the nudge. */

  .knowmore {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 7px;
    /* The section's 18.3px gap plus this lands the block's box on 758. */
    margin-top: 0.9px;
    font-family: "Hardshock", sans-serif;
    font-size: 12px;
    color: var(--white);
  }

  .knowmore__doodle {
    display: block;
    width: 35px;
    height: 46px;
  }

  /* Sized and placed off the frame's own arrow, which is a 31.75x32 box at
     x263.2 — the export is that box at 4x, 127x128, with its 22.2deg tilt
     already in the pixels. It was drawn at 24x25 a full 10px further right,
     which left it stranded away from the word it belongs to.

     The negative margins are how it gets there without disturbing anything
     else: pulling left tucks it back over the tail of "more" the way the
     frame overlaps them, and pulling down hangs it below the row's box —
     which stays 46px tall, set by the doodle, so the experience section
     below is measured off exactly the same edge as before. */
  .knowmore__arrow {
    display: block;
    width: 31.75px;
    height: 32px;
    align-self: flex-end;
    margin-left: -10px;
    margin-bottom: -9.65px;
  }

  /* ===== About: scribbles ===== */

  /* 91px below the experience dots, per the mobile frame. It used to be
     measured from the card, which had nothing under it. */
  .scribbles {
    margin: 90.9px auto 40px;
    padding: 0 16px;
  }

  .scribbles__title {
    margin-bottom: 24px;
    font-size: 16px;
    text-align: center;
  }

  .scribbles__viewport {
    -webkit-mask-image: none;
    mask-image: none;
  }

  /* Cards shrink from 338.5px to 200px, so the -50% loop covers far less
     ground; the duration comes down with it to keep the drift speed the same
     as desktop. */
  .scribbles__track {
    --scribbles-gap: 12px;
    animation-duration: 21s;
  }

  .scribbles__item {
    flex: 0 0 200px;
    height: 214px;
  }

  /* ===== Resume viewer ===== */

  .resume__frame {
    padding: 14px 12px 16px;
  }

  .resume__bar {
    gap: 10px;
    height: 62px;
    margin-bottom: 12px;
    padding: 0 12px 0 14px;
    border-width: 5px;
  }

  .resume__bar-title {
    padding: 0 6px;
    font-size: 27px;
  }

  .resume__bar-actions {
    gap: 8px;
  }

  .resume__download {
    min-width: 92px;
    height: 33px;
    padding: 0 10px;
    font-size: 14.5px;
  }

  .resume__close {
    width: 33px;
    height: 33px;
  }

  .resume__close svg {
    width: 15px;
    height: 15px;
  }

  .resume__doc {
    border-width: 5px;
  }

  .resume__pagewrap {
    margin-bottom: 12px;
  }
}

/* Below this the three actions and the label no longer fit on one row, and the
   label is the expendable part — the dialog is already named for a11y. */
@media (max-width: 360px) {
  .resume__bar {
    justify-content: flex-end;
    padding-left: 12px;
  }

  .resume__bar-title {
    display: none;
  }
}

/* ===== Project card hit area ===== */

/* Covers the whole card. It sits above the artwork but the artwork is all
   decorative, so nothing readable is lost behind it. Deliberately absent from
   the Lie & Spy card — that one is unreleased, so there is nowhere to go and
   the cursor stays in its resting dot-and-ring state over it. */
.project-card__link {
  position: absolute;
  inset: 0;
  z-index: 20;
  display: block;
  /* The custom cursor is the whole affordance here; a system pointer on top of
     it would be two cursors saying the same thing. */
  cursor: none;
}


/* ===== Focused clip ("I Animate") =====

   Mobile only, built by motion.js on tap. The backdrop blurs the page rather
   than covering it, so the clip reads as lifted out of the row it came from.
   `backdrop-filter` is not universal; the rgba beneath it is what carries the
   contrast on its own where it is missing. */
.clipfocus {
  position: fixed;
  inset: 0;
  z-index: 260;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  background: rgba(12, 12, 12, 0.82);
  -webkit-backdrop-filter: blur(18px);
  backdrop-filter: blur(18px);
}

/* The stage takes the clip's own ratio from the script, so a portrait phone
   capture stays portrait. Both caps are on, so whichever axis runs out first
   is the one that decides the size. */
.clipfocus__stage {
  position: relative;
  max-width: min(100%, 520px);
  max-height: 100%;
  width: auto;
  height: auto;
}

.clipfocus__video {
  display: block;
  width: 100%;
  height: 100%;
  max-height: calc(100vh - 48px);
  object-fit: contain;
  background: #141414;
}

/* On the clip's own corner, half off it, so it never sits over the picture.
   The drawing is cloned from #clipCloseTemplate — the same hand-drawn ring the
   back-to-top button uses, which at this size reads as a ~1pt brush outline
   around the lavender disc rather than a printed circle. */
.clipfocus__close {
  position: absolute;
  top: -15px;
  right: -15px;
  display: block;
  width: 34px;
  height: 34px;
  padding: 0;
  border: none;
  background: transparent;
  cursor: pointer;
  transition: transform 0.2s cubic-bezier(0.34, 1.4, 0.64, 1);
}

.clipfocus__close:active {
  transform: scale(0.88);
}

.clipfocus__close-art {
  display: block;
  width: 100%;
  height: 100%;
}

.clipfocus__close-fill {
  fill: var(--purple);
}

/* Filled, not stroked: the path is the ring's own outline, so painting it dark
   leaves the brush edge sitting on the lavender. */
.clipfocus__close-ring {
  fill: #1b1b1b;
}

.clipfocus__close-x {
  fill: none;
  stroke: #1b1b1b;
  stroke-linejoin: round;
}

body.clip-open {
  overflow: hidden;
}

/* ===== Copy toast =====

   Touch only. The reveal bubbles below are a hover affordance, so on a phone
   the tile copies on the first tap and this is the only thing that says so. */

.copy-toast {
  position: fixed;
  left: 50%;
  bottom: 28px;
  z-index: 300;
  padding: 11px 18px;
  border-radius: 999px;
  background: rgba(20, 20, 20, 0.92);
  box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
  font-family: "Urbanist", sans-serif;
  font-weight: 600;
  font-size: 14px;
  color: var(--white);
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  transform: translate(-50%, 12px);
  transition: opacity 0.24s ease, transform 0.24s ease;
}

.copy-toast.is-open {
  opacity: 1;
  transform: translate(-50%, 0);
}


/* ===== Contact reveal ===== */

/* The bubble is the Figma drawing, exported as SVG and used as the background
   so the hand-drawn edge survives; the number, the address and the copy glyph
   are live elements on top of it. Two fixed widths rather than one stretchy
   one, because the tail is part of the drawing and stretching the box would
   drag the tail off the icon it points at. */
.connect__reveal {
  position: absolute;
  /* 100% is the icon's top edge. The tile it points at scales to 1.15 and
     tilts on hover, which lifts its top corner by ~13px — so the offset has
     to clear that as well as the 16px of air the design asks for. */
  bottom: calc(100% + 28px);
  z-index: 30;
  height: 52px;
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 8px;
  padding: 8px 13px 0 10px;
  background-repeat: no-repeat;
  background-position: 0 0;
  background-size: 100% 100%;
  opacity: 0;
  visibility: hidden;
  transform: translateY(8px);
  transition: opacity 0.26s ease, transform 0.26s cubic-bezier(0.34, 1.4, 0.64, 1),
    visibility 0s linear 0.26s;
  pointer-events: none;
}

.connect__reveal.is-open {
  opacity: 1;
  visibility: visible;
  transform: translateY(0);
  transition-delay: 0s, 0s, 0s;
  pointer-events: auto;
}

/* Width and artwork are per-icon. The left offsets are applied in JS from the
   tail's x inside each drawing, so the point lands on the icon's centre. */
.connect__reveal--phone {
  width: 183px;
  background-image: url("assets/reveal-bubble-phone.svg");
}

.connect__reveal--mail {
  width: 288px;
  background-image: url("assets/reveal-bubble-mail.svg");
}

.connect__reveal-value {
  font-family: "Urbanist", sans-serif;
  font-weight: 600;
  font-size: 19px;
  line-height: 23px;
  color: #000;
  white-space: nowrap;
}

.connect__reveal-copy {
  flex-shrink: 0;
  width: 24px;
  height: 24px;
  margin-top: -1px;
  padding: 0;
  border: 0;
  background: transparent;
  color: #000;
  cursor: none;
  transition: transform 0.2s cubic-bezier(0.34, 1.4, 0.64, 1);
}

.connect__reveal-copy:hover {
  transform: scale(1.14);
}

.connect__reveal-copy svg {
  display: block;
  width: 24px;
  height: 24px;
  fill: currentColor;
}

/* Copy confirmed. The glyph becomes a tick for a moment — the bubble keeps its
   size and its drawing, so only the thing you just pressed changes. */
.connect__reveal-copy .connect__reveal-tick {
  display: none;
  fill: none;
  stroke: currentColor;
  stroke-width: 2.6;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.connect__reveal.is-copied .connect__reveal-copy .connect__reveal-tick {
  display: block;
}

.connect__reveal.is-copied .connect__reveal-copy .connect__reveal-glyph {
  display: none;
}

/* ===== Back to top ===== */

/* 56px, not the drawing's native 90. The site's own controls run 38.5px for
   the resume close, 46px for the nav's Contact and 67px for the works arrows —
   at 90 this floated larger than all of them and read as a second logo. */
.scrolltop {
  position: fixed;
  right: 34px;
  bottom: 34px;
  z-index: 200;
  width: 56px;
  height: 56px;
  padding: 0;
  border: 0;
  background: transparent;
  cursor: none;
  transition: opacity 0.4s ease, transform 0.4s cubic-bezier(0.34, 1.4, 0.64, 1);
}

/* Inline rather than two <img>: on hover the ring has to take the page's own
   background colour, and that differs between the two pages. An <img> cannot
   inherit anything, so the drawing lives in the markup and CSS owns every
   fill. One shape set, three colours swapped. */
.scrolltop__art {
  display: block;
  width: 100%;
  height: 100%;
}

.scrolltop__fill,
.scrolltop__ring,
.scrolltop__arrow {
  transition: fill 0.3s ease, stroke 0.3s ease, opacity 0.3s ease;
}

/* The disc behind the ring. Absent at rest, so the dark page shows through. */
.scrolltop__fill {
  fill: var(--purple);
  opacity: 0;
}

.scrolltop__ring {
  fill: var(--purple);
}

.scrolltop__arrow {
  fill: none;
  stroke: var(--purple);
}

.scrolltop:hover .scrolltop__fill {
  opacity: 1;
}

/* The hand-drawn ring reads as a gap punched out of the purple disc, so it
   has to be exactly the page behind it — matched here the same way .nav does
   it, rather than a hardcoded near-black that would sit as a dark rim on the
   about page's purple. */
.scrolltop:hover .scrolltop__ring {
  fill: var(--bg);
}

body.about-page .scrolltop:hover .scrolltop__ring {
  fill: #5c4c7f;
}

.scrolltop:hover .scrolltop__arrow {
  stroke: #503884;
}

/* Hidden for most of the page. It is an up arrow, so it only earns its place
   once Let's Connect is on screen and there is nothing left below. Also stays
   down while the resume viewer or the burger menu is over the page. */
.scrolltop.is-hidden {
  opacity: 0;
  transform: scale(0.55);
  pointer-events: none;
}

@media (prefers-reduced-motion: reduce) {
  .scrolltop,
  .scrolltop__fill,
  .scrolltop__ring,
  .scrolltop__arrow,
  .connect__reveal {
    transition-duration: 0.01ms;
  }
}

/* On mobile and tablet the control is drawn filled, always. The disc is a
   hover state, and a touch screen has no hover — it has a tap that leaves one
   behind. So the button sat outlined until you touched it, went filled, and
   stayed filled until you touched something else: the same control wearing two
   different faces depending on what you last did. Filled is the one that reads
   at this size, so it is simply the resting state here, on both pages. */
@media (max-width: 1023px) {
  .scrolltop__fill {
    opacity: 1;
  }

  /* The ring is the page showing through the disc, so it takes each page's own
     background exactly as the hover rules do. */
  .scrolltop__ring {
    fill: var(--bg);
  }

  body.about-page .scrolltop__ring {
    fill: #5c4c7f;
  }

  .scrolltop__arrow {
    stroke: #503884;
  }
}

@media (max-width: 900px) {
  /* The insets are for the phones that draw something over the bottom of the
     page — a home indicator, a rounded corner, a browser bar that overlays
     rather than resizes. 16px from the edge is the design; on those screens
     the edge is not where the glass ends, and the control was being clipped
     in half by it. `env()` resolves to 0 everywhere else, so this is the same
     16px on every other device. */
  .scrolltop {
    right: calc(16px + env(safe-area-inset-right, 0px));
    bottom: calc(16px + env(safe-area-inset-bottom, 0px));
    width: 46px;
    height: 46px;
    cursor: pointer;
  }

  .connect__reveal-copy,
  .project-card__link {
    cursor: pointer;
  }
}
