/* ============================================================================
   Bogdana Andriikiv — portfolio
   1:1 port of the Figma frame "Main" (1512 × 10761).

   Layout model: every element keeps its exact Figma coordinate inside a fixed
   1512px canvas. It never scales up: past 1512px the content keeps its design
   pixel size and is centred while only the background stretches. Below that the
   canvas scales down as a whole, so the composition never reflows.
   ========================================================================== */

:root {
  --canvas-w: 1512px;
  --canvas-h: 10761px;
  --scale: 1;

  --sky:        #5da1e4;   /* root frame fill */
  --ink:        #0c2230;   /* body / heading text */
  --pink:       #f55dda;   /* button fill */
  --pink-line:  #ff89eb;   /* card border */
  --card:       #ffffff;

  --font-display: "Londrina Solid", "Arial Black", sans-serif;
  --font-body:    "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  /* Fixel Display is not on Google Fonts — self-host it to match the CTA exactly */
  --font-fixel:   "Fixel Display", var(--font-body);

  --lh-display: 1.18;      /* Londrina "auto" line-height in Figma */
  --lh-body:    1.4;
}

* { box-sizing: border-box; }

html { scroll-behavior: smooth; }

body {
  margin: 0;
  background: var(--sky);
  color: var(--ink);
  font-family: var(--font-body);
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;
}

/* --- canvas scaling ------------------------------------------------------ */

.stage {
  width: 100%;
  /* JS keeps this equal to canvas height × scale so the page scrolls correctly */
  height: calc(var(--canvas-h) * var(--scale));
  overflow: hidden;
}

.canvas {
  position: relative;
  width: var(--canvas-w);
  height: var(--canvas-h);
  /* Below 1512px the canvas is scaled to exactly fill the viewport, so its left
     edge is already flush and `auto` margins collapse to 0. Above 1512px the
     scale is 1 and the margins do the centring. Either way `top left` is the
     only origin that keeps the box inside the clipped stage. */
  margin: 0 auto;
  /* --pin holds the canvas still while the memes block plays; the translate is
     in document pixels, so it comes before the scale. */
  transform: translateY(var(--pin, 0px)) scale(var(--scale));
  transform-origin: top left;
}

/* every positioned element */
.a {
  position: absolute;
  transform: rotate(var(--rot, 0deg));
}

img.a, .bg img { display: block; user-select: none; -webkit-user-drag: none; }

/* --- background collage -------------------------------------------------- */

/* The collage is full-bleed: above 1512px the tiles stretch past the canvas by
   --bg-overhang on each side so no bare `--sky` shows at the edges. Below
   1512px the canvas already scales to exactly fill the viewport, so JS pins the
   overhang to 0 and the tiles sit flush. */
.bg img {
  position: absolute;
  left: calc(0px - var(--bg-overhang, 0px));
  width: calc(1512px + var(--bg-overhang, 0px) * 2);
  height: 1672px;
}

/* the meadow band behind the contact section is background art too */
.full-bleed {
  left: calc(0px - var(--bg-overhang, 0px)) !important;
  width: calc(1512px + var(--bg-overhang, 0px) * 2) !important;
}

/* --- hero ---------------------------------------------------------------- */

.intro {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 900;
  font-size: 88px;
  line-height: var(--lh-display);
  text-align: center;
  color: #fff;
}

/* --- navigation (header + footer) ---------------------------------------- */

.nav a { text-decoration: none; }

.tab {
  position: absolute;
  top: 10px;
  display: flex;
  align-items: center;
  padding: 4px 12px;
  font-family: var(--font-display);
  font-weight: 300;
  font-size: 32px;
  line-height: var(--lh-display);
  color: #fff;
  border-radius: 8px;
  transition: background-color .18s ease, transform .18s ease;
}
.tab:hover { background: rgba(255, 255, 255, .18); transform: translateY(-1px); }

/* Exported one-to-one from Figma (LOGO, node 684:428): the wordmark is already
   outlined, so the lettering no longer depends on the display font loading. */
.logo {
  position: absolute;
  left: 324px;
  top: 0;
  display: block;
  height: 56px;
}
.logo img { display: block; width: 263px; height: 56px; }

.nav-lg .tab { top: 24px; }
.logo-lg { height: 93px; }
.logo-lg img { width: 437px; height: 93px; }

/* --- work cards ---------------------------------------------------------- */

/* Two things move on hover and nothing else does: the plumbob shrinks and rises
   to sit over her head, and the portrait comes up under it. The rest of the card
   — frosted panel, blurred placeholder copy, button — is one flat export, so
   only these two are separate layers.

   The numbers are Figma's own: the gem goes 275x547 -> 78x156 and its centre
   travels 261.5px up the card. That shrink is not quite uniform — 0.2836 across,
   0.2852 down — and a CSS scale() has to be one number, so it takes the taller
   of the two: 0.2836 lands the height a pixel short, 0.2852 rounds both sides
   onto Figma's. Its resting box is the design position, so the transform is the
   whole of the change.

   No position: relative here — .a has already made this absolute, and adding it
   would win on source order and break every card's placement. */
.card-link { display: block; transition: transform .22s ease; }
.card-link:hover { transform: translateY(-6px); }
.card-link img { display: block; }
.card-link .card-bg { border-radius: 12px; }

.card-gem,
.card-face { position: absolute; pointer-events: none; }

/* Each card has its own gem — pink, green, yellow, purple — and Figma places
   them a pixel or two apart, so the box and the shift that lands it on the
   hover target both come in on the element. */
.card-gem { transition: transform .55s cubic-bezier(.2, .85, .25, 1); }
.card-face {
  left: 109px; top: 217px;
  opacity: 0;
  transform: translateY(24px) scale(.94);
  transition: opacity .34s ease,
              transform .55s cubic-bezier(.2, .85, .25, 1);
}

/* the gem is listed before the portrait, as in Figma, so it tucks behind her
   head on the way up rather than riding over it */
.card-link:hover .card-gem  { transform: var(--gem-to); }
.card-link:hover .card-face { opacity: 1; transform: none; }

/* --- article cards ------------------------------------------------------- */

/* 1340 × 622 with 12px padding, rotated ±2° about the top-left corner — the
   same origin Figma rotates about, so left/top stay the design coordinates. */
.card {
  width: 1340px;
  height: 622px;
  background: var(--card);
  /* Figma draws this stroke inside the frame, so it must not offset the
     children the way a CSS border does — an inset shadow takes no layout box.
     The two outer layers are the hover's shadows held at zero: box-shadow only
     interpolates between equal layer counts, so they must exist here to animate
     rather than snap. Collapsed by negative spread, they render as nothing. */
  box-shadow: inset 0 0 0 3px var(--pink-line),
              0 0 0 -12px rgba(255, 137, 235, 0),
              0 0 0 -30px rgba(12, 34, 48, 0);
  border-radius: 12px;
  transform-origin: 0 0;
  transition: transform .5s cubic-bezier(.2, .9, .25, 1),
              box-shadow .5s cubic-bezier(.2, .9, .25, 1);
}

/* Hover reads the block for what it is — a scatter of tilted paper with a print
   laid on each sheet. The resting tilt is the resting state, so hovering pulls
   the sheet off the pile: it snaps level, overshoots the way stiff paper does,
   throws a hard offset behind itself like a misregistered risograph pass, and
   the photo peels up off the page with it.

   transform-origin stays 0 0 — it is what keeps left/top as the raw Figma
   coordinates, so the whole move is written in that same frame. Levelling
   swings the far corner across the neighbouring cards; the z-index and the cast
   shadow are what make that read as depth instead of collision.

   Keyboard users get the whole thing too — the card holds its lift while the
   link inside it has focus. */
.card:hover,
.card:focus-within {
  /* back-out easing: past level, then settles — the snap of a stiff sheet */
  transition-timing-function: cubic-bezier(.34, 1.4, .64, 1);
  transform: rotate(0deg) translateY(-18px);
  box-shadow: inset 0 0 0 3px var(--pink),
              20px 22px 0 -3px var(--pink-line),
              0 34px 64px -24px rgba(12, 34, 48, .6);
  z-index: 2;
}

/* The whole card is the link. The anchor fills the card exactly, which makes it
   the containing block for everything inside — so every child keeps the raw
   Figma left/top it already had and nothing needed re-measuring. The visual
   button is a span now: a link inside a link is invalid, and one link is also
   the right count for the tab order and for a screen reader, which reads the
   heading as the link's name. */
.card-hit {
  position: absolute;
  inset: 0;
  display: block;
  border-radius: 12px;
  color: inherit;
  text-decoration: none;
}

/* The card already answers focus with the same lift it gives the pointer, but
   that is a hover cue, not a focus indicator — the ring is what says which
   card the keyboard is actually on. */
.card-hit:focus-visible {
  outline: 4px solid var(--pink);
  outline-offset: 8px;
}

.card .thumb {
  position: absolute;
  width: 618px;
  height: 598px;
  border-radius: 8px;
  object-fit: fill;          /* Figma STRETCH */
  transition: transform .5s cubic-bezier(.2, .9, .25, 1),
              filter .5s ease;
}
.card .thumb-cover { object-fit: cover; }   /* Figma FILL */

/* The photo peels off the sheet: it grows, tilts, drifts outward and picks up a
   shadow of its own, so it stops being a hole cut in the card and becomes a
   print lying loose on top of it. Overhanging the card's outer edge is the
   point — that is what sells "lying on top" rather than "inset into".

   Direction is the one thing it must get right, because drifting inward would
   run the photo into the copy. DOM order already encodes the layout: the two
   image-left cards open with the img, the image-right card closes with it, so
   :first-child and :last-child separate them without matching on style strings.
   Both are counted inside .card-hit, which is the only child .card now has.

   Clearance, worst case: the photo's inner edge sits 20px from the text at rest
   after growth (+9px), drift (-12px) and tilt (+7px at the corner). It ends up
   further from the copy than it started. */
.card:hover .thumb:first-child,
.card:focus-within .thumb:first-child {
  transform: translate(-14px, -10px) rotate(-1.5deg) scale(1.03);
  filter: drop-shadow(-6px 14px 14px rgba(12, 34, 48, .38));
}

.card:hover .thumb:last-child,
.card:focus-within .thumb:last-child {
  transform: translate(14px, -10px) rotate(1.5deg) scale(1.03);
  filter: drop-shadow(6px 14px 14px rgba(12, 34, 48, .38));
}

/* The button rides up with the card. It outranks .btn:hover on specificity, so
   the button's own hover has to be restated louder or it would read as dead
   under the pointer that is already lifting it. */
.card:hover .card-btn,
.card:focus-within .card-btn { transform: translateY(-4px); }
.card .card-btn:hover { transform: translateY(-9px); }

@media (prefers-reduced-motion: reduce) {
  /* Keep every static cue — the level snap, the offset, the shadows, the hot
     border — and drop only what moves: the travel, the tilt and the growth. */
  .card, .card .thumb, .card .card-btn { transition: none; }
  .card:hover, .card:focus-within { transform: rotate(var(--rot, 0deg)); }
  .card:hover .thumb, .card:focus-within .thumb { transform: none; }
  .card:hover .card-btn,
  .card:focus-within .card-btn,
  .card .card-btn:hover { transform: none; }
}

.card h3 {
  position: absolute;
  margin: 0;
  width: 658px;
  font-family: var(--font-display);
  font-weight: 400;
  font-size: 40px;
  line-height: 47px;
  color: var(--ink);
}

.card p {
  position: absolute;
  margin: 0;
  width: 658px;
  font-family: var(--font-body);
  font-weight: 300;
  font-size: 24px;
  line-height: 29px;
  color: var(--ink);
}

/* Google's Inter is a hair narrower than the cut Figma renders with, and in this
   one paragraph that is enough for "really" to squeeze onto the first line where
   Figma pushes it down — 6 lines instead of 7. The box keeps its 658px design
   width; only the wrap point moves in. */
.card p.metric-fix { padding-right: 18px; }

/* --- buttons ------------------------------------------------------------- */

.btn {
  position: absolute;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px 4px;
  background: var(--pink);
  border-radius: 12px;
  font-family: var(--font-display);
  font-weight: 400;
  font-size: 40px;
  line-height: var(--lh-display);
  text-transform: uppercase;
  text-decoration: none;
  color: #fff;
  transition: filter .18s ease, transform .18s ease;
}
.btn:hover  { filter: brightness(1.08); transform: translateY(-2px); }
.btn:active { transform: translateY(0); }

/* the article card button: fixed box, text centred inside it */
.card-btn { width: 658px; height: 87px; }

/* button welded to the bottom edge of a card */
.btn-attached { border-radius: 0 0 12px 12px; }

/* --- memes deck ---------------------------------------------------------- */

/* The memes are a pile of prints being dealt onto the laptop: the next one
   tips up into place, and the one it covers sinks back into the stack, fanned
   out and shaded.

   Opacity and transform are written every frame from the scroll offset, so
   there is deliberately no transition here — one would fight the other. The
   easing lives in JS instead, as a spring on the deck position, so that even a
   stepped mouse wheel reads as a glide. */
.memes {
  perspective: 1600px;            /* gives the tip-up some real depth */
  perspective-origin: 50% 42%;
}

/* Each meme ships as a finished print — white mount, pink edge, drop shadow and
   its own couple of degrees of tilt, all baked into the asset on transparency.
   So there is no card chrome here to draw: the box is sized to the artwork's
   own aspect ratio and the image simply fills it. */
.memes .slide {
  position: absolute;
  margin: 0;
  opacity: 0;
  pointer-events: none;
  /* below centre, so the card hinges nearer its lower edge on the way in */
  transform-origin: 50% 72%;
  backface-visibility: hidden;
  /* Buried cards are shaded, not faded: a print further down the pile is in
     shadow but never see-through — two memes showing through each other is
     what made the earlier version look muddy. It has to be a filter rather
     than an overlay, because the artwork is a tilted card on a transparent
     ground and a rectangle laid over it would darken the empty corners too. */
  filter: brightness(calc(1 - 0.45 * var(--sink, 0)))
          saturate(calc(1 - 0.32 * var(--sink, 0)));
  will-change: opacity, transform;
}

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

#memes-h { will-change: transform; }

@media (prefers-reduced-motion: reduce) {
  /* the pile still reads through the shading; only the movement goes */
  .memes .slide { transform: none !important; }

  #memes-h { transform: none !important; }
}

/* --- about: the folder is pulled out, then laid up ------------------------ */

/* The pose of each part is written every frame from the scroll offset, so —
   exactly as with the memes deck — there is deliberately no transition here.
   One would fight the other. All this rule does is hide the parts until JS has
   something to say about them, and it is armed by a class JS sets only once it
   knows it is going to run the scenes at all: with scripting off, or with
   reduced motion, the block simply renders. */
.js-reveal [data-in] { opacity: 0; }

/* A part that has reached its place is handed back to the stylesheet, inline
   transform and all — which is how the button gets to keep its own hover lift
   rather than being pinned by a written-in identity transform. Until then it
   must not transition, or its 180ms hover ease would drag a frame behind every
   write. */
.js-reveal [data-in]:not(.is-in) { transition: none; }
.js-reveal [data-in].is-in { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  .js-reveal [data-in] { opacity: 1; transform: rotate(var(--rot, 0deg)); }
}

/* --- about --------------------------------------------------------------- */

.about-card {
  background: var(--card);
  /* Figma has this stroke on the OUTSIDE of the 944x627 box, at 27% — not the
     solid inset edge the other cards carry. Outside is what tucks it under the
     button welded to the bottom, exactly as the frame does. */
  box-shadow: 0 0 0 3px rgba(255, 137, 235, .27);
  border-radius: 12px;
  padding: 12px;
}
.about-body {
  height: 100%;
  padding: 24px 20px;
  display: flex;
  flex-direction: column;
  gap: 24px;
}
.about-body p {
  margin: 0;
  font-family: var(--font-body);
  font-weight: 300;
  font-size: 24px;
  /* Figma leaves this on "auto", which for Inter at 24px lands on a 29px line
     box — the six paragraphs then measure 1+5+3+3+2+1 lines and, with the 24px
     gaps, fill the card's 555px of content exactly. A ratio would drift. */
  line-height: 29px;
  color: var(--ink);
}

/* The emphasis is per phrase, not per paragraph: Inter Bold for the key
   phrases, Extra Bold where the design steps up to it, and the card's pink for
   the ones being called out. */
/* The Inter that Figma draws with is about 1.6% wider than the Inter v4 Google
   Fonts serves, so one paragraph — and only this one — pulls "research," up onto
   its first line and comes out a line short, which leaves the card 29px unfilled.
   No global setting fixes it: tracking or a narrower measure flips this
   paragraph to five lines and the next one to four at the very same threshold,
   because the difference is in the glyph widths, not the spacing. A hairline of
   tracking here is 0.4% of the em — invisible — and it holds anywhere from
   0.02px to 0.9px, so it survives a font update in either direction. The real
   fix is to self-host the Inter build Figma uses. */
.about-body .keep-wrap { letter-spacing: .1px; }

.about-body strong { font-weight: 700; }
.about-body .xb    { font-weight: 800; }
.about-body .hl    { color: var(--pink-line); }

/* --- section headings ---------------------------------------------------- */

.display {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 900;
  font-size: 120px;
  line-height: var(--lh-display);
  text-align: center;
  color: var(--ink);
}

/* The contact heading is part of the exported artwork: in Figma the words are
   booleaned into the meadow shape, so they cannot be separated from it. This
   element stays for the document outline and the section's aria-labelledby —
   painting it as well laid a second, slightly differently-metricked copy of the
   words over the baked one, which is what read as doubled letters. */
.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* --- contact CTA --------------------------------------------------------- */

.cta {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  padding: 20px 16px;
  background: var(--card);
  /* Figma strokes only the top edge here — 4px, on the outside of the 677x305
     box — not all four sides. Hence the offset shadow rather than a border. */
  box-shadow: 0 -4px 0 var(--pink-line);
}
.cta p {
  margin: 0;
  padding: 0 20px 12px;
  width: 645px;
  font-family: var(--font-fixel);
  font-weight: 700;
  font-size: 36px;
  line-height: 1.5;
  letter-spacing: -0.023em;
  text-align: center;
  color: #000;
}
.cta .btn { position: relative; }

/* --- motion preference --------------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after { transition-duration: .01ms !important; }
}

/* ============================================================================
   Hero hover hints
   Ported from the six `Main_Hover` frames on the Figma "Site" page. Each frame
   dims the hero behind a 49% black rectangle, lifts the hovered sticker above
   it, and draws a hand-made arrow to a rotated label.

   Figma's z-order puts `Rectangle 1` above the headline and the nav, so those
   are dimmed too — only the hovered sticker, the arrow and the label stay lit.

   The frames carry no prototype timing, so the durations and easings below are
   authored, not exported.
   ========================================================================== */

#hero { --hint-draw: 620ms; --hint-fade: 260ms; }

.hero-dim {
  position: absolute;
  /* must span the same full-bleed width as the background it dims, otherwise
     the stretched edges stay lit while the canvas darkens */
  left: calc(0px - var(--bg-overhang, 0px));
  top: 0;
  width: calc(1512px + var(--bg-overhang, 0px) * 2);
  height: 982px;
  background: rgba(0, 0, 0, .49);
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--hint-fade) ease-out;
  z-index: 5;
}
#hero.is-hinting .hero-dim { opacity: 1; }

.hotspot { cursor: pointer; }
.hotspot:focus-visible { outline: 3px solid #fff; outline-offset: 4px; }

/* --- the hero collage levitates ------------------------------------------ */

/* Each sticker animates the standalone `translate` and `rotate` properties
   rather than `transform`. They compose ahead of it, so the Figma rotation on
   `.a` and the `scale(1.03)` the hover adds still apply untouched — animating
   `transform` here would have overwritten both.
   --dx/--dy give every object its own drift axis, as a vector rather than an
   angle: no two stickers travel the same way, so the collage reads as separate
   things adrift instead of one sheet breathing. The pair is the full sweep, and
   the object swings half of it either side of where Figma put it.
   `alternate` turns a one-way keyframe pair into a there-and-back swing, and
   ease-in-out makes it slowest at the ends, which is what reads as hovering
   rather than sliding. The tilt runs at 1.37× the drift period: the two never
   line up, so the pair never settles into a single visible beat. A negative
   delay of --phase drops each sticker in mid-swing, already out of step with
   its neighbours instead of the whole collage bobbing in unison. */
@keyframes float-drift {
  from { translate: calc(var(--dx, 0px) * -.5) calc(var(--dy, 30px) * -.5); }
  to   { translate: calc(var(--dx, 0px) * .5) calc(var(--dy, 30px) * .5); }
}
@keyframes float-tilt {
  from { rotate: var(--tilt, 1.2deg); }
  to   { rotate: calc(var(--tilt, 1.2deg) * -1); }
}

.float {
  animation:
    float-drift var(--dur, 7s) ease-in-out infinite alternate,
    float-tilt calc(var(--dur, 7s) * 1.37) ease-in-out infinite alternate;
  animation-delay: calc(var(--phase, 0s) * -1), calc(var(--phase, 0s) * -1.6);
}

/* the sticker being pointed at rises above the dim */
.hotspot.is-lit {
  z-index: 6;
  transform: rotate(var(--rot, 0deg)) scale(1.03);
  transition: transform var(--hint-fade) cubic-bezier(.34, 1.3, .64, 1);
}

.hint {
  position: absolute;
  left: 0;
  top: 0;
  pointer-events: none;
  z-index: 7;
}

/* --- arrow: drawn on with a dash offset set from getTotalLength() --------- */

.hint-arrow {
  position: absolute;
  overflow: visible;
  opacity: 0;
}
.hint-arrow path {
  stroke-dashoffset: var(--len, 0);
  transition: stroke-dashoffset var(--hint-draw) cubic-bezier(.4, 0, .2, 1);
}
.hint.is-on .hint-arrow      { opacity: 1; }
.hint.is-on .hint-arrow path { stroke-dashoffset: 0; }

/* --- label: lands as the arrow finishes ---------------------------------- */

.hint-label {
  position: absolute;
  margin: 0;
  /* Figma's label is an auto-layout frame hugging its text. Hugging here too —
     capped at the frame's design width — reproduces its line breaks without
     depending on the browser matching Figma's font metrics to the pixel. */
  width: max-content;
  max-width: var(--w);
  padding: 8px 16px;
  background: #fff;
  border: 1px solid var(--pink-line);
  font-family: var(--font-display);
  font-weight: 900;
  font-size: 40px;
  line-height: 47.32px;
  text-align: center;
  color: var(--pink-line);
  transform-origin: 0 0;
  transform: rotate(var(--rot)) scale(.94);
  opacity: 0;
  transition: opacity 200ms ease-out, transform 320ms cubic-bezier(.34, 1.4, .64, 1);
}
/* labels that occupy a single line in Figma — pinned, because Google's
   Londrina Solid is slightly wider than the family Figma renders with and the
   design width would otherwise force a second line */
/* Single-line labels in Figma. Google's Londrina Solid is narrower than the cut
   Figma renders with, so hugging the text would make the box too small — and on
   a rotated label the box width shifts where its bounding box starts. Pin the
   design width and keep the text on one line. */
.hint-label--1line { white-space: nowrap; width: var(--w); max-width: none; }

.hint.is-on .hint-label {
  opacity: 1;
  transform: rotate(var(--rot)) scale(1);
  transition-delay: calc(var(--hint-draw) * .62);
}

@media (prefers-reduced-motion: reduce) {
  /* the collage holds still, and no draw-on, no pop — the hint simply appears */
  .float { animation: none; }
  .hint-arrow path { stroke-dashoffset: 0 !important; transition: none; }
  .hint-label { transform: rotate(var(--rot)); transition-delay: 0s; }
  .hotspot.is-lit { transform: rotate(var(--rot, 0deg)); }
}


/* ============================================================================
   MOBILE — 899px and down
   1:1 port of the Figma frame "iPhone 16 - 1" (393 × 8206), Site page.

   The mobile design is not a reflow of the desktop one — it is a second canvas,
   built the same way: every element on an absolute coordinate inside a fixed
   box. So this block does not rebuild the page, it swaps the coordinate set.
   Each element carries its mobile `left/top/width/height` inline as `--m-*`
   alongside its desktop ones, and one rule below reads them.

   Nothing needed re-exporting. Every rotation in the mobile frame is identical
   to the desktop one to two decimals — the same nodes, just smaller — so every
   asset, with its rotation already baked in, drops straight in at the mobile
   bounding box. The work card is the same composition at 0.5435 scale (650×873
   → 353×475, gem 275 → 149.4), so even the flat card export reuses cleanly.
   The one exception is the About gem, rotated 10.21° further here than on the
   desktop; it gets that difference back as a CSS rotation.

   Scale mirrors the desktop rule. There it is `min(1, width / 1512)` — fit the
   design, never past it. Here it is `min(1.35, width / 393)`: a phone narrower
   than the frame scales down, a wider one scales up so the art still reaches
   both edges, and past about 530px it stops and centres on the sky, exactly as
   the desktop canvas stops at 1512.
   ========================================================================== */

@media (max-width: 899px) {

  :root {
    --canvas-w: 393px;
    --canvas-h: 8206px;
  }

  /* The desktop canvas centres on an auto margin, which works there because it
     only ever shrinks: at the width where the margin would matter the scale is
     already 1. Here the canvas can be scaled up past the viewport, and an auto
     margin centres the box before the transform rather than after — so the
     centring is a translate that JS sizes from the scaled width. */
  .canvas {
    margin: 0;
    transform: translateX(var(--m-shift, 0px)) scale(var(--scale));
  }

  /* --- the coordinate switch --------------------------------------------- */

  /* `!important` throughout, and it is the point of the rule rather than a
     smell: the desktop coordinate is an inline style on every one of these
     elements, and an inline style is exactly what this has to reach past. The
     mobile value is inline too, as a custom property, so both sets sit on the
     element together and neither is hidden in a stylesheet far from the other. */
  .a {
    left:   var(--m-l) !important;
    top:    var(--m-t) !important;
    width:  var(--m-w) !important;
    height: var(--m-h) !important;
    transform: rotate(var(--m-rot, 0deg));
  }

  /* the mobile frame carries neither the hover hints nor a header menu */
  .hero-dim, .hint, #hero nav .tab { display: none; }

  /* The frame stacks the hero differently from the desktop one, and the DOM
     order is the desktop's — there the portrait sits under the toast and the
     sneaker, here it is the thing everything else is arranged around and only
     the P is allowed over it. Paint order is the one part of a collage a
     coordinate cannot carry, so it comes back as z-index rather than as a
     second DOM. The eight run bottom to top exactly as Figma lists them, and
     all of them clear the headline and the wordmark, which the frame puts
     underneath. */
  .s-cat      { z-index: 1; }
  .s-latte    { z-index: 2; }
  .s-toast    { z-index: 3; }
  .s-z        { z-index: 4; }
  .s-i        { z-index: 5; }
  .s-sneaker  { z-index: 6; }
  .s-portrait { z-index: 7; }
  .s-p        { z-index: 8; }

  /* --- background --------------------------------------------------------- */

  /* The frame drops the desktop BG in whole — 1512 wide at x -559 — and lets
     the 393px artboard crop it. So the tiles keep their design width and their
     design `top`, and only move left. That also makes them full-bleed on their
     own: 1512 design px is wider than this breakpoint can ever be, so there is
     no overhang to compute. */
  /* The mobile frame's own copy of BG carries art down to y 7286 and nothing
     below it — from there the frame's `#5da1e4` fill is the background. The
     tiles in assets/ were sliced from the desktop BG, which does still have art
     that low, and it is a near-white cyan; a white heading on it comes out at
     about 1.2:1 and disappears.

     So the art is taken away over the tile that carries it. A straight cut left
     a line right across the page — the tile is still fully saturated where it
     would end — so it fades instead, over the 386px above the frame's own
     cut-off. The mask goes on the tile rather than on the layer, because the
     tiles reach 559px past the canvas on each side and a mask on the layer is
     bounded by the layer's box, which would take the bleed with it. */
  .bg img[src$="bg-4.webp"] {
    -webkit-mask-image: linear-gradient(to bottom, #000 212px, transparent 598px);
            mask-image: linear-gradient(to bottom, #000 212px, transparent 598px);
  }
  .bg img[src$="bg-5.webp"] { display: none; }   /* starts below the canvas */

  .bg img {
    left: -559px;
    width: 1512px;
    height: 1672px;
  }
  .full-bleed {
    left:  calc(var(--m-l) - var(--bg-overhang, 0px)) !important;
    width: calc(var(--m-w) + var(--bg-overhang, 0px) * 2) !important;
  }

  /* --- hero --------------------------------------------------------------- */

  .intro { font-size: 54px; }

  /* the wordmark is the whole of the header here, and it sits left */
  #hero .logo { left: 65px; top: 9px; }

  /* --- section headings ---------------------------------------------------- */

  .display { font-size: 54px; }

  /* These four hug their text in Figma, so they are one line by construction —
     and at the design width they clear it by between 0.1 and 0.5px. That is not
     a margin, it is a coin toss: Safari rounds text metrics differently from
     Chrome, and on a phone all four dropped to two lines, which put the contact
     heading under the CTA card and the memes heading under the deck. Pinned to
     one line they cannot, and since each box is already centred on the canvas,
     text that runs wider than its box still runs wider evenly and stays centred
     on the same axis the frame puts it on. Same fix, same reason, as the hero's
     single-line hint labels. */
  #articles-h, #memes-h, #about-h, #contact-h { white-space: nowrap; }

  /* --- work ---------------------------------------------------------------- */

  /* 0.5435 of the desktop card, which is what the frame is: 650×873 → 353×475.
     The gem's travel goes with it — written as a share of the gem's own height
     it is the same number at either size, so the four inline pixel values are
     replaced by one. */
  .card-link .card-bg  { width: 353px; height: 474.8px; }
  .card-link .card-gem {
    left: 102.6px !important; top: 63.6px !important;
    width: 149.4px; height: 297px;
    --gem-to: translate(0, -47.9%) scale(.2852) !important;
  }
  .card-link .card-face {
    left: 59.2px; top: 117.9px;
    width: 236.4px; height: 270.1px;
  }

  /* The artboard can only show the card at rest, and a phone has no hover to
     move it — but the move is the card. So it plays once as the card comes up
     into the viewport and holds the open pose: same two transitions, same
     easing, only the trigger changes. See the observer at the end of script.js. */
  .card-link.is-revealed .card-gem  { transform: var(--gem-to); }
  .card-link.is-revealed .card-face { opacity: 1; transform: none; }

  /* --- articles ------------------------------------------------------------ */

  /* the paper backing is a tall column here rather than a wide sheet, so it
     crops to the box instead of stretching into it */
  #articles > img[src$="art-bg.webp"] { object-fit: cover; }

  /* Figma strokes this card on the OUTSIDE at 27%, not the desktop's solid
     inset edge — so it is the About card's treatment, at the article's scale. */
  .card {
    padding: 5.82px;
    border-radius: 5.82px;
    box-shadow: 0 0 0 1.46px rgba(255, 137, 235, .27);
  }
  /* A tap fires :hover and leaves it fired, so the sheet would come off the
     pile and stay off. The static cues stay; the movement goes. */
  .card:hover, .card:focus-within {
    transform: rotate(var(--m-rot, 0deg));
    box-shadow: 0 0 0 1.46px var(--pink);
  }
  .card:hover .thumb, .card:focus-within .thumb { transform: none; filter: none; }
  .card:hover .card-btn, .card:focus-within .card-btn { transform: none; }

  .card h3 { font-size: 19.4px; line-height: 23px; }
  .card p  { font-size: 11.64px; line-height: 14px; padding-right: 0; }

  /* The desktop's fix for this paragraph is the opposite of the one it needs
     here: there Google's Inter is narrow enough to pull a word up, and padding
     pushes the wrap back in. At 11.64px it comes out a line long instead, and
     the card's height is fixed, so the sixth line would run into the button.
     A hairline of tracking takes it back to the design's five. It holds from
     -0.15px down past -0.70px, so -0.3 sits with room either side of whatever
     Inter ships next — and 2.6% of the em is invisible at this size. */
  .card p.metric-fix { letter-spacing: -0.3px; }
  .card .card-btn { font-size: 19.4px; padding: 0; }
  .card .thumb { border-radius: 4px; }

  /* Every one of these carries a desktop coordinate inline. They are laid out
     per card because the frame sizes each card's photo to its own copy. */
  .card:nth-of-type(1) .thumb    { left: 16.2px !important; top:   5.8px !important; width: 341.1px; height: 330px; }
  .card:nth-of-type(1) h3        { left: 27.2px !important; top: 347.5px !important; width: 319.2px; }
  .card:nth-of-type(1) p         { left: 27.2px !important; top: 446.3px !important; width: 319.2px; }
  .card:nth-of-type(1) .card-btn { left: 27.2px !important; top: 537.1px !important; width: 319.2px; height: 42.4px; }

  .card:nth-of-type(2) .thumb    { left:  7.4px !important; top:   5.8px !important; width: 358.7px; height: 347.1px; }
  .card:nth-of-type(2) h3        { left: 27.2px !important; top: 364.5px !important; width: 319.2px; }
  .card:nth-of-type(2) p         { left: 27.2px !important; top: 440.3px !important; width: 319.2px; }
  .card:nth-of-type(2) .card-btn { left: 27.2px !important; top: 559.1px !important; width: 319.2px; height: 42.4px; }

  .card:nth-of-type(3) .thumb    { left: 10.4px !important; top:   5.8px !important; width: 352.8px; height: 341.4px; }
  .card:nth-of-type(3) h3        { left: 15.5px !important; top: 358.8px !important; width: 342.5px; }
  .card:nth-of-type(3) p         { left: 15.5px !important; top: 411.6px !important; width: 342.5px; }
  .card:nth-of-type(3) .card-btn { left: 15.5px !important; top: 488.4px !important; width: 342.5px; height: 42.4px; }

  /* --- memes --------------------------------------------------------------- */

  /* The laptop reads as a surface rather than an object: the frame runs it off
     both edges of the artboard. Past the scale cap the canvas stops growing and
     it would stop short of the viewport, so it takes the same overhang as the
     meadow — cropping to the wider box rather than stretching, because a
     stretched lid is obvious and a cropped one is just a slightly taller lid. */
  #memes > img[src$="memes-panel.webp"] {
    left:  calc(var(--m-l) - var(--bg-overhang, 0px)) !important;
    width: calc(var(--m-w) + var(--bg-overhang, 0px) * 2) !important;
    object-fit: cover;
  }

  /* `Frame 13` is a horizontal row of eight prints, 2483px of them across a
     393px artboard with clipping off — a strip you move through, not a stack.
     So it is built as one: the deck's scroll-driven dealing is a wheel gesture
     and stands down here (script.js), and the row becomes a snapping swipe. */
  .memes {
    display: flex;
    align-items: center;
    gap: 17.63px;
    padding: 0 12px;
    overflow-x: auto;
    overscroll-behavior-x: contain;
    scroll-snap-type: x mandatory;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
    perspective: none;
  }
  .memes::-webkit-scrollbar { display: none; }

  /* The prints are wider than the frame's containers — the mobile design crops
     each meme to its own box, the desktop asset is the whole print — so each
     one is fitted inside the row's 290x300 box and keeps its own proportions.

     Fitted here, not by the image: the slides carry no width/height attributes,
     so an auto-sized box is zero until the file decodes, and eight zero-width
     flex items sit on top of each other. Every box is written out instead, so
     the row is laid out before a single meme has loaded. */
  .memes .slide {
    position: static;
    flex: 0 0 auto;
    opacity: 1 !important;
    transform: none !important;
    filter: none;
    pointer-events: auto;
    scroll-snap-align: center;
  }
  .memes .slide img { width: 100%; height: 100%; }

  .memes .slide:nth-child(1) { width: 290px !important; height: 213px !important; }
  .memes .slide:nth-child(2) { width: 290px !important; height: 281px !important; }
  .memes .slide:nth-child(3) { width: 287px !important; height: 300px !important; }
  .memes .slide:nth-child(4) { width: 290px !important; height: 281px !important; }
  .memes .slide:nth-child(5) { width: 266px !important; height: 300px !important; }
  .memes .slide:nth-child(6) { width: 290px !important; height: 266px !important; }
  .memes .slide:nth-child(7) { width: 290px !important; height: 266px !important; }
  .memes .slide:nth-child(8) { width: 290px !important; height: 181px !important; }

  /* --- about --------------------------------------------------------------- */

  .about-card { padding: 12px; }
  .about-body { padding: 24px 20px; gap: 24px; }
  /* Inter's "auto" line box at 16px. The six paragraphs then measure
     38+171+114+114+76+38 and, with the five 24px gaps, fill the card's 671px
     of content exactly — the same arithmetic the desktop card does at 24px. */
  .about-body p { font-size: 16px; line-height: 19.36px; }
  .about-body .keep-wrap { letter-spacing: 0; }

  /* --- buttons -------------------------------------------------------------- */

  .btn { font-size: 20px; padding: 0; }

  /* --- contact -------------------------------------------------------------- */

  /* Here the heading is a live text node booleaned into the meadow, not baked
     into the artwork the way the desktop one is — so the exported PNG cannot
     supply it and the element that exists for the document outline becomes the
     heading itself. */
  #contact-h {
    position: absolute;
    height: auto;
    margin: 0;
    clip-path: none;
    overflow: visible;
    /* No white-space here, and that is the point. The lines above undo .sr-only
       so the node can be seen, but white-space is the one property that must
       not be undone: .sr-only's nowrap is already what the one-line pin above
       asks for. Resetting it to `normal` did nothing on this line and won
       silently on source order sixty lines later, which is how this heading —
       alone of the four — still dropped to two lines. It only ever showed in
       the swap font: Londrina clears the 399px box by about a pixel, Arial
       Black needs 588, and the second line fell outside the box's fixed 72px
       and under the CTA card, cut mid-word at the card's edge. */
    font-family: var(--font-display);
    font-weight: 900;
    font-size: 60.92px;
    line-height: 1.18;
    text-align: center;
    color: rgba(255, 255, 255, .92);
  }

  /* The artwork below it is the desktop render, and that one has the lettering
     baked in — the top 25.4% of the file is those glyphs on transparency. At
     this box's proportions `cover` scales the image to fit the height exactly
     and crops only sideways, so the baked words would sit right under the live
     ones. The box is grown to 870.8 instead, which makes the crop vertical, and
     the 243.8px above the meadow — the glyphs and 22px of clearance — is cut
     off. What is left is 627px of meadow, at the design's own y. */
  #contact .full-bleed {
    top: 7207.7px !important;
    height: 1005.3px !important;
    object-fit: cover;
    /* A straight cut, because that is what the frame has: the meadow starts as
       a hard, fully saturated edge immediately under the lettering, and it is
       that edge the white heading gets its contrast from.

       The cut is measured from the glyphs, not from the artwork's alpha. The
       desktop render's lettering is knocked into the meadow as well as standing
       above it, so the row where the file first goes fully opaque — 284 — is
       only where the meadow rect starts, and the glyphs carry on over it down
       to about 390. Cutting at the alpha edge left the tail of the leading S
       showing under the live heading. 420 clears them with room to spare, and
       the box is sized so that what is left still starts at the design's own
       7586 and runs its 627px. */
    clip-path: inset(378.3px 0 0 0);
  }

  .cta { padding: 20px 16px; }
  .cta p {
    width: 269px;
    padding: 0 20px 12px;
    font-size: 20px;
  }
  .cta .btn { width: 309px !important; height: 44px; }

  /* --- footer --------------------------------------------------------------- */

  .nav-lg .logo-lg { left: 37.5px; top: 0; height: 66.6px; }
  .nav-lg .logo-lg img { width: 313px; height: 66.6px; }
  .nav-lg .tab { font-size: 32px; padding: 4px 12px; }
  .nav-lg .tab:nth-of-type(1) { left: 155px !important; top:  76.6px; }
  .nav-lg .tab:nth-of-type(2) { left: 135px !important; top: 132.6px; }
  .nav-lg .tab:nth-of-type(4) { left: 147px !important; top: 188.6px; }
  .nav-lg .tab:nth-of-type(5) { left: 134px !important; top: 244.6px; }

  /* a lift on tap reads as a mis-tap, not a response */
  .btn:hover, .card-link:hover, .tab:hover { transform: none; }
}
