/* Mei Knutson — Portfolio
   A single scrolling page. Each Figma frame from "Portfolio-website" becomes
   one full-width section, stacked top to bottom, with the nav pinned above
   them:

     #home            node 1:3      1440x1024
     #sustainability  node 1:8      1440x1024
     mobile notice    node 113:398   402x874   (replaces the site below 768px)

   Layer geometry lives in the HTML as --x/--y/--w/--h percentages of the
   section's artboard, so this file stays generic and the coordinate table
   stays readable next to the markup it describes. Cropped layers add
   --ix/--iy/--iw/--ih to place the image inside its window.

   Most art is stored pre-transposed: Figma wraps those images in
   rotate(90deg) scaleY(-1), which composes to a reflection across the main
   diagonal. That is baked into the .webp files, so nothing here needs a
   transform and the alpha mask lines up in plain page coordinates. */

:root {
  --canvas: #feffff;
  --accent: #ff00f2;
  --ink: #000;
  --type-lg: 36.667;    /* px, on whichever artboard is showing */
  --type-label: 26;     /* piece labels and the nav, which match each other */
  --type-stroke: 1.4;   /* px, same artboard units as --type-lg */
  --nav-h: 72;          /* artboard px; the artboard's own bar is 104 */

  /* Detail panels render their artboard smaller than the sections do.
     26/36.667 — the nav size over the artboard's body size — so the panels'
     body copy lands exactly on the nav, and every image, heading and gap
     scales by the same amount rather than needing its own adjustment. */
  --panel-zoom: 0.709091;
  --artboard: 1440;  /* the desktop artboard width, for the pinned nav */
}

* { box-sizing: border-box; }

html {
  scroll-behavior: smooth;
}

html, body {
  margin: 0;
  padding: 0;
  height: 100%;
  background: var(--canvas);
}

/* --- Scroll shell -------------------------------------------------------
   The sections scroll inside .scroller instead of the document, so the nav
   can be an absolutely positioned sibling rather than a fixed one. Nothing
   in this chain may create a stacking context, or the nav's hue blend would
   be cut off from the sections behind it — so no z-index, no transform, no
   filter on .shell, .scroller or .nav. */

.shell {
  position: relative;
  height: 100dvh;
  overflow: hidden;
}

/* While a detail panel is open the site behind it is inert — it keeps its
   scroll position but must not respond to the wheel or to focus. */
.has-panel .shell {
  pointer-events: none;
}

.scroller {
  height: 100%;
  overflow-y: auto;
  overflow-x: hidden;
  scroll-behavior: smooth;
}

@media (prefers-reduced-motion: reduce) {
  .scroller { scroll-behavior: auto; }
}

body {
  font-family: "42dot Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
  color: var(--ink);
}

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
}

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  z-index: 100;
  padding: 0.6rem 1rem;
  background: var(--ink);
  color: #fff;
}

.skip-link:focus {
  left: 0;
}

/* Holds the nav's refraction filter. Kept in the layout rather than
   display:none so the filter reference stays resolvable. */
.svg-defs {
  position: absolute;
  width: 0;
  height: 0;
  overflow: hidden;
}

/* --- Sections ----------------------------------------------------------- */

.stage {
  --artboard-w: 1440;
  --artboard-h: 1024;

  position: relative;
  display: block;
  width: 100%;
  max-width: calc(var(--artboard-w) * 1px);
  margin-inline: auto;
  aspect-ratio: var(--artboard-w) / var(--artboard-h);
  overflow: hidden;
  background: var(--canvas);

  /* Gives descendants cqw/cqh units, and confines the blend modes below so
     they composite against the canvas rather than whatever is behind. */
  container-type: size;
  container-name: stage;
  isolation: isolate;
}

/* About is the last section; cropping it short of the artboard's 1024 drops
   the dead space that otherwise ends the page. Its layers are written as
   percentages of this height, so nothing inside shifts. */
#about {
  --artboard-h: 670;
}

/* Full-bleed on phones — no max-width, so the notice fills the screen. */
.stage--mobile {
  --artboard-w: 402;
  --artboard-h: 874;
  max-width: none;
  display: none;
}

@media (max-width: 767px) {
  html, body { height: auto; }
  .shell { display: none; }
  .stage--mobile { display: block; }
}

/* --- Detail panels -------------------------------------------------------
   Full-screen overlays opened from the picture frames. Each scrolls on its
   own, so the site behind keeps its scroll position untouched, and each
   close button is pinned to the overlay rather than to the page inside it —
   that is what keeps it in the same spot however far you scroll. */

.panel {
  /* The full-width artboard column: the background art and the close button
     are both measured against this, so they stay at their designed size. */
  --panel-scale: min(100vw, calc(var(--artboard) * 1px));

  position: fixed;
  inset: 0;
  z-index: 50;
  background: var(--canvas);
}

.panel[hidden] {
  display: none;
}

.panel__scroll {
  position: absolute;
  inset: 0;
  overflow-y: auto;
  overflow-x: hidden;
  /* Stops a flick past the end of a panel from scrolling the site behind it. */
  overscroll-behavior: contain;
}

.panel__scroll:focus {
  outline: none;
}

/* The artboard: 1440 wide like the sections. The page is --panel-zoom of
   --page-h tall, because the content inside is scaled by that much and the
   page should end where the content does. */
.panel__page {
  position: relative;
  width: 100%;
  max-width: calc(var(--artboard) * 1px);
  margin-inline: auto;
  aspect-ratio: var(--artboard) / calc(var(--page-h) * var(--panel-zoom));
  overflow: hidden;
  background: var(--canvas);
  container-type: size;
  isolation: isolate;
}

/* The background is laid out on the full, unscaled artboard, so it keeps the
   size Figma gave it. It is taller than the page it sits in and simply gets
   clipped, which is what keeps it covering the full height at its own scale. */
.panel__bg {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: calc(100cqw * var(--page-h) / var(--artboard));
}

/* The content fills the page, which is already --panel-zoom of the artboard
   height — so its layers' vertical percentages come out scaled for free, and
   only the horizontal ones are pre-scaled in the markup.

   Deliberately no transform: that would make this a stacking context and cut
   the arrow's colour-dodge off from the background behind it. */
.panel__content {
  position: absolute;
  inset: 0;
}

/* Placed against the centred artboard column rather than the viewport, so it
   lands where the artboard puts it (88, 64) at any window width. */
.panel__close {
  position: absolute;
  left: calc(50% - var(--panel-scale) / 2 + 88 / 1440 * var(--panel-scale));
  top: calc(64 / 1440 * var(--panel-scale));
  width: calc(99.319 / 1440 * var(--panel-scale));
  height: calc(80 / 1440 * var(--panel-scale));
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
}

/* The cross is drawn rather than loaded as an SVG, because the artboard puts
   it on a blend and blending an <img> composites its whole box — which is
   what drew a pale square around it. Drawn as two pills, the blended
   element's box *is* the ink, so the blend only touches the cross.

   The blend sits on this wrapper rather than on each bar, so the two do not
   dodge each other where they cross. Geometry is the artboard's: two
   26.34-thick round-capped strokes across a 99.32x93.17 box, the box sitting
   13.17 above the button's top edge. */
.panel__close__x {
  position: absolute;
  inset: 0;
  mix-blend-mode: var(--x-blend, color-dodge);
}

.panel__close__x > span {
  position: absolute;
  left: 50%;
  top: 38.36%;                        /* stroke midpoint, 43.86 - 13.17, over 80 */
  width: 113.92%;                     /* 113.14 over 99.32, round caps included */
  aspect-ratio: 113.1367 / 26.3438;
  background: var(--accent);
  border-radius: 9999px;
}

.panel__close__x > span:first-child { transform: translate(-50%, -50%) rotate(45deg); }
.panel__close__x > span:last-child  { transform: translate(-50%, -50%) rotate(-45deg); }

/* Hover blooms the cross: a touch of blur to soften the edges, then two
   drop-shadows in the cross's own colour for the glow. Because the filter
   sits inside the blended wrapper, the glow goes through the same blend as
   the cross and so reads as the same colour rather than a lighter halo.
   Radii are in artboard units so the bloom scales with the button. */
.panel__close__x {
  transition: filter 160ms ease;
}

.panel__close:hover .panel__close__x,
.panel__close:focus-visible .panel__close__x {
  /* Plain lengths and a literal colour, no calc() or var() inside the filter.
     Firefox drops a whole filter declaration if any part of the value is
     one it will not parse, which is what left the bloom doing nothing there
     — the same all-or-nothing failure as the nav's backdrop-filter. The
     button only spans 53-99px across the supported widths, so fixed radii
     read much the same throughout. */
  filter:
    blur(0.4px)
    drop-shadow(0 0 3.5px #ff00f2)
    drop-shadow(0 0 9px #ff00f2);
}

@media (prefers-reduced-motion: reduce) {
  .panel__close__x { transition: none; }
}

/* Four of the pages put their X slightly higher and on a different blend. */
.panel__close--overlay {
  left: calc(50% - var(--panel-scale) / 2 + 64 / 1440 * var(--panel-scale));
  top: calc(50 / 1440 * var(--panel-scale));
  --x-blend: overlay;
}

.panel__close:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 6px;
}

/* A flat wash over a whole panel page. */
.shader {
  background: #040404;
}

/* Panel copy. The detail pages set type at a handful of artboard sizes, so
   --fs carries the size rather than a named token. */
.p-text {
  margin: 0;
  /* Sizes here are already multiplied by --panel-zoom in the markup, which
     is why the default is 26 (the artboard's 36.667) — the same size as the
     nav and the piece labels. */
  font-size: calc(var(--fs, 26) / var(--artboard) * 100cqw);
  line-height: normal;
  color: #fff;
  word-break: break-word;
  -webkit-text-stroke: calc(var(--type-stroke) / var(--artboard) * 100cqw) currentColor;
  paint-order: stroke fill;
}

/* The campaign video is an embed in the artboard, so it exports as an empty
   bordered box; kept as the artboard has it until there is a video to put in. */
.p-embed {
  border: calc(5 * var(--panel-zoom) / var(--artboard) * 100cqw) solid #fff;
  background: #000;
}

.p-embed > iframe {
  display: block;
  width: 100%;
  height: 100%;
  border: 0;
}

/* A layer whose content sits at an angle in the artboard. The child carries
   the rotation and its own size (--rw/--rh, percentages of the layer box),
   centred in the layer exactly as Figma's flex wrapper does it. */
.p-rot {
  display: flex;
  align-items: center;
  justify-content: center;
}

.p-rot > * {
  position: relative;
  flex: none;
  width: var(--rw);
  height: var(--rh);
  transform: rotate(var(--rot));
}

/* Like .crop but unclipped: the artwork is meant to bleed past its box. */
.p-bleed > img {
  position: absolute;
  left: var(--ix, 0);
  top: var(--iy, 0);
  width: var(--iw, 100%);
  height: var(--ih, 100%);
  max-width: none;
}

/* A flat black scrim, as on the skier automaton page. */
.scrim {
  background: #000;
}

/* A plain media box — no corner radius, unlike .card. The artboard rounds
   the cards on the main sections but not the media on the detail pages. */
.p-media {
  overflow: hidden;
}

.p-media > video,
.p-media > img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* --- Layers ------------------------------------------------------------- */

.layer {
  position: absolute;
  left: var(--x, 0);
  top: var(--y, 0);
  width: var(--w, auto);
  height: var(--h, auto);
  margin: 0;
  opacity: var(--opacity, 1);
  mix-blend-mode: var(--blend, normal);
}

.layer > img {
  display: block;
  width: 100%;
  height: 100%;
  max-width: none;
  object-fit: cover;
  pointer-events: none;
}

/* Frames reuse the same assets at different rotations. Against the baked,
   pre-transposed files those come out as plain flips. Applied to the layer
   rather than the image so a masked layer mirrors its mask along with its
   contents — which is what keeps an offset child correctly placed without
   needing mirrored offsets of its own. */
.layer--flip-y { transform: scaleY(-1); }
.layer--flip-x { transform: scaleX(-1); }

/* A window that clips an image larger than itself. The image is positioned
   with its own --ix/--iy/--iw/--ih, in percentages of the window. */
.crop {
  overflow: hidden;
}

.crop > img {
  position: absolute;
  left: var(--ix, 0);
  top: var(--iy, 0);
  width: var(--iw, 100%);
  height: var(--ih, 100%);
  object-fit: fill;
}

/* Figma clip group: one shared alpha mask over two painted layers. Sized to
   the box with no repeat, so anything outside the box is masked away too —
   which is what makes the offset wash below crop correctly. Both artboards
   place the mask box identically relative to its contents, so one rule serves
   them both. */
.mask-a { --mask: url("../assets/teal-mask.webp"); }
.mask-b { --mask: url("../assets/teal-mask-b.webp"); } /* physical's columns */

.layer--masked {
  -webkit-mask-image: var(--mask, url("../assets/teal-mask.webp"));
          mask-image: var(--mask, url("../assets/teal-mask.webp"));
  -webkit-mask-size: 100% 100%;
          mask-size: 100% 100%;
  -webkit-mask-repeat: no-repeat;
          mask-repeat: no-repeat;
          mask-mode: alpha;
}

/* The wash is a larger image peeking through the mask, offset relative to it. */
.layer--wash > img {
  position: absolute;
  left: -67.7063%;
  top: -3.9614%;
  width: 243.3628%;
  height: 103.9614%;
}

/* --- Pinned nav --------------------------------------------------------- */

/* Absolute, not fixed — see the note on .shell. It still sits at the top of
   the viewport because .shell is exactly viewport-height and does not scroll;
   the content scrolls inside it. It paints above .scroller by DOM order, so
   it must stay z-index:auto. */
.nav {
  position: absolute;
  inset: 0 0 auto 0;
  display: flex;
  justify-content: center;
  pointer-events: none;

  --nav-scale: min(100vw, calc(var(--artboard) * 1px));
  --nav-font: calc(var(--type-label) / var(--artboard) * var(--nav-scale));
  --nav-line: calc(var(--nav-font) * 1.25);
}

.nav__frame {
  position: relative;
  width: 100%;
  max-width: calc(var(--artboard) * 1px);
  aspect-ratio: var(--artboard) / var(--nav-h);
  pointer-events: auto;
}

/* The glass: blur for distortion, then a hue rotation for the colour.

   This is deliberately NOT the artboard's mix-blend-mode:hue. That blend
   forces every pixel to a single hue, so it would flatten everything under
   the nav to the same magenta. Rotating instead shifts each colour by a
   fixed amount, which keeps the artwork's own hue relationships — and 218deg
   happens to land the yellow-green background art on magenta (so the band
   still reads as designed) while taking reds round to blue. The two cannot
   be combined: the blend would overwrite the rotation.

   Like the blend, this leaves whites and greys alone — they have no hue to
   rotate — so the band still disappears over white. No opacity wash either,
   so the art stays fully visible, just softened and shifted. */
/* The glass proper: a light blur so the art stays legible through the pane,
   then the colour shift. Nothing here is browser-specific, so the band
   always renders. */
.nav__glass {
  position: absolute;
  inset: 0;
  -webkit-backdrop-filter: blur(4px) hue-rotate(218deg) saturate(1.1);
          backdrop-filter: blur(4px) hue-rotate(218deg) saturate(1.1);
}

/* The refraction rides on its own layer. Only Chromium supports url() SVG
   filters inside backdrop-filter; Firefox and Safari do not, and because an
   unsupported value invalidates the whole declaration, keeping this on
   .nav__glass took the blur and the hue down with it and left no band at
   all. On its own layer the worst case is losing the distortion. */
@supports (backdrop-filter: url(#glass-distort)) {
  .nav__refract {
    position: absolute;
    inset: 0;
    backdrop-filter: url(#glass-distort);
  }
}

.nav__links {
  position: absolute;
  inset: 0;
  margin: 0;
  padding: 0;
  list-style: none;
}

/* left/width keep the artboard's horizontal positions. Vertically each link
   is centred in the bar rather than pinned to the artboard's 30px offset, so
   the bar's height is free to change without moving the type off-centre.
   Centred by arithmetic rather than a translate: a transform would make this
   a stacking context, which would cut the halo below off from the backdrop
   it is supposed to blend with and leave it a solid grey blob. */
.nav__links li {
  position: absolute;
  left: var(--x);
  width: var(--w);
  top: calc(50% - var(--nav-line) / 2);
  height: var(--nav-line);
}

/* A halo behind each word, so the links stay readable wherever the nav
   happens to be sitting — including over the dark artwork further down the
   page.

   mix-blend-mode:lighten makes it self-adjusting rather than a flat wash:
   the result is per-channel max(backdrop, halo), so anything already
   brighter than the halo colour is left completely alone and only darker
   pixels get lifted. That is why the colour is a mid grey rather than white
   — white would max out against everything and wash the pale upper sections
   too. Dark artwork gets lifted hard; the hero gets essentially nothing. */
.nav__links li::before {
  content: "";
  position: absolute;
  inset: -80% -18%;
  background: radial-gradient(
    closest-side ellipse at 50% 50%,
    rgba(157, 153, 162, 0.95) 0%,
    rgba(157, 153, 162, 0.78) 45%,
    rgba(157, 153, 162, 0) 100%
  );
  mix-blend-mode: lighten;
  pointer-events: none;
}

/* Positioned, so the word paints above its own halo. */
/* Same size as the piece labels, so the nav reads as part of the same
   typographic system rather than looming over it. */
.nav__links a {
  position: relative;
  display: block;
  font-size: var(--nav-font);
  line-height: 1.25; /* must match the 1.25 in --nav-line */
  color: var(--ink);
  text-decoration: none;
  white-space: nowrap;
}

.nav__links a.is-current,
.nav__links a:hover,
.nav__links a:focus-visible {
  color: var(--accent);
}

.nav__links a:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 4px;
}

@media (prefers-reduced-motion: no-preference) {
  .nav__links a { transition: color 120ms ease; }
}

/* --- Type --------------------------------------------------------------- */

/* Outline around the type, echoing the hand-lettered wordmark. The stroke uses
   currentColor so every item gains the same weight — the magenta current link
   gets a magenta stroke rather than a black outline — and it follows the hover
   colour for free. paint-order puts the stroke behind the fill so the counters
   stay open instead of the stroke eating inward from both sides. */
.nav__links a {
  -webkit-text-stroke: calc(var(--type-stroke) / var(--artboard) * var(--nav-scale)) currentColor;
  paint-order: stroke fill;
}

/* --type-scale is set on a section whose foreground has been scaled down to
   fit a short window; the copy shrinks with the pieces it labels. */
.caption,
.m-notice,
.m-smiley {
  font-size: calc(var(--type-lg) * var(--type-scale, 1) / var(--artboard-w) * 100cqw);
  line-height: normal;
  color: var(--ink);
  -webkit-text-stroke: calc(var(--type-stroke) * var(--type-scale, 1) / var(--artboard-w) * 100cqw) currentColor;
  paint-order: stroke fill;
}

.caption,
.m-notice {
  word-break: break-word;
}

/* About's copy is the only body text on the site. Left to scale with its
   section it came out at 28.75px against the nav's 26, with a stroke thinned
   to 1.1 against 1.4 — bigger and lighter at once, which read as a different
   font. Pinning it to --type-label puts it on the same footing as the nav
   and the piece labels. */
#about .caption {
  font-size: calc(var(--type-label) / var(--artboard-w) * 100cqw);
  -webkit-text-stroke-width: calc(var(--type-stroke) / var(--artboard-w) * 100cqw);
}

/* Labels under the portfolio pieces. Unlike the body copy these do not scale
   with their section, so every label on the site is the same size and the nav
   can match them. That means the artboard's box no longer fits the text, so
   they size to their own content from the same top-left anchor. Multi-line
   labels use an explicit <br>. */
.label {
  /* Centred on --cx, the centre of the piece it names, rather than anchored
     to the artboard's text box — which was sized for type more than twice
     this large and left the labels sitting well off to one side. */
  left: var(--cx);
  transform: translateX(-50%);
  width: max-content;
  height: auto;
  text-align: center;
  font-size: calc(var(--type-label) / var(--artboard-w) * 100cqw);
  line-height: normal;
  color: var(--ink);
  white-space: nowrap;
  -webkit-text-stroke: calc(var(--type-stroke) / var(--artboard-w) * 100cqw) currentColor;
  paint-order: stroke fill;
}

.m-smiley {
  white-space: nowrap;
}

/* --- Sustainability ----------------------------------------------------- */

/* A second magenta band, offset from the nav and on soft-light. */
.band-soft {
  background: var(--accent);
  mix-blend-mode: soft-light;
}

.card {
  display: block;
  border-radius: 2.083333cqw; /* 30 / 1440 */
  overflow: hidden;
}

.card > img,
.card > video {
  pointer-events: none;
}

.card > video {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Species names in the About copy, per the artboard. */
.sci-name {
  font-family: "Times New Roman", Times, serif;
  font-style: italic;
}

.caption--accent {
  color: var(--accent);
}

blockquote.caption {
  margin: 0;
}

.card:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 4px;
}

/* The hand-drawn frames are painted over the cards they surround, so they
   were swallowing every click meant for the link underneath. They now open
   the same panel themselves, which makes the whole framed piece — border and
   photo alike — one target. The card's <a> still carries the keyboard and
   screen-reader affordance. */
.crop[data-panel] {
  cursor: pointer;
}

/* --- Mobile notice ------------------------------------------------------ */

/* The SVG carries its own blur bleed, so it is stretched to the given box. */
.m-ellipse {
  display: block;
  object-fit: fill;
}
