/* ============================================================================
   Isak Frostå — Portfolio 2026
   Inspired by jib.design: monochrome, editorial, lots of air. Geist throughout.
   No build step, no dark-mode switch — one clean light theme.
   ========================================================================== */

/* ── Fonts (self-hosted Geist, variable weight, OFL) ──────────────────────── */
@font-face {
  font-family: "Geist";
  src: url("fonts/geist-sans.woff2") format("woff2");
  font-weight: 100 900;
  font-display: swap;
  font-style: normal;
}
@font-face {
  font-family: "Geist Mono";
  src: url("fonts/geist-mono.woff2") format("woff2");
  font-weight: 100 900;
  font-display: swap;
  font-style: normal;
}

/* ── Tokens ───────────────────────────────────────────────────────────────── */
:root {
  --bg:        #ffffff;
  --ink:       #0d0d0d;
  --ink-soft:  #6b6b6b;   /* muted secondary text */
  --ink-faint: #9a9a9a;   /* faintest labels, on white only */
  --ink-card:  #474747;   /* secondary label text sitting on artwork */
  --line:      #e7e7e7;   /* hairlines */
  --line-soft: #f0f0f0;
  --surface:   #f4f4f4;   /* subtle fills — hover washes, etc. */
  /* The grey field screens sit on. Shared by the thumbnails and the fullscreen
     carousel so both read as the same surface, with white screens popping off it.
     Started from jib.design's gallery tile (#f2f2f2) and lifted a little. Don't go
     much lighter: white screens sit on this, and past ~#f7f7f7 they stop reading
     as objects on a surface. */
  --field:     #f5f5f5;

  /* Corner radius for every piece of media on the site. --radius is the
     full-measure value; --radius-sm is for small tiles, where the same
     absolute radius would read as a pill rather than a soft corner. Both
     are ~3.4% of their element's width, which is the proportion the Famly
     design uses. case.css redefines --radius for article pages. */
  --radius:    48px;
  --radius-sm: 14px;
  /* One shadow for every screen, everywhere — thumbnails, slides and the flying
     ghost. They must match exactly or the handoff at the end of a flight swaps
     one shadow for another and reads as a pop.
     A FILTER, not a box-shadow: the ghost is clipped during a flight, and
     clip-path removes an element's box-shadow entirely. drop-shadow applied to an
     ancestor follows the clipped silhouette, so the shadow survives the flight.
     Offset down-and-right, as if the light sits up behind your left shoulder. */
  --screen-shadow: drop-shadow(13px 18px 22px rgba(20, 20, 20, .20));

  --font-sans: "Geist", system-ui, -apple-system, sans-serif;
  --font-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;

  /* Screen aspect ratios, in one place: the thumbnails, the slides and the morph
     maths all read these, so a device shape can never disagree between them.
     tablet = the real 834×1194 iPad export. */
  --ar-phone:   0.4615;
  --ar-tablet:  0.6985;
  --ar-desktop: 1.6;

  --maxw: 1080px;         /* the outer container — sets the LEFT margin */
  --contentw: 800px;      /* the narrower reading column inside it */
  --pad:  clamp(20px, 5vw, 56px);

  /* The case pages' frame. It lives here rather than in case.css because the
     back link is shared with the CV and has to sit on the same edge there. */
  --frame:     1512px;
  --frame-pad: clamp(24px, 4vw, 56px);

  --ease-out:    cubic-bezier(.22, 1, .36, 1);       /* smooth stop */
  --ease-spring: cubic-bezier(.34, 1.32, .5, 1);     /* gentle overshoot */

  /* Carousel timing — all the knobs live here. Kept deliberately quick so
     stepping through slides feels snappy: with the stagger, the last screen of
     a 4-screen slide lands at ~(3 × step) + screen-dur. */
  --slide-dur:   260ms;   /* slide fly-in / fly-out */
  --screen-dur:  360ms;   /* each screen's rise-and-settle */
  --screen-step:  50ms;   /* delay between screens revealing one by one */
  --caption-dur: 170ms;   /* caption cross-fade on slide change */
  --underline-dur: 200ms; /* link underline redraw on hover — snappy, like a pen stroke */
  --morph-open:  440ms;   /* hero screen flying thumbnail → slide */
  --morph-close: 340ms;   /* and back again (a touch quicker) */
  /* When the sibling screens start joining, measured from the start of the flight,
     and how long each takes. Tuned so they SETTLE as the hero lands rather than
     after it: 90 + 50 + 320 ≈ 440. Their own fade is shorter than --screen-dur
     because a flight is a tighter moment than a plain slide change. */
  --morph-join:      60ms;
  --morph-join-dur: 320ms;
  --carousel-in:    180ms; /* grey field fade-in — fast, so siblings aren't double-faded */
  --field-dur:      260ms; /* grey field opening out from the thumbnail — quick and snappy */
}

/* ── Reset ────────────────────────────────────────────────────────────────── */
* { box-sizing: border-box; margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body {
  font-family: var(--font-sans);
  color: var(--ink);
  background: var(--bg);
  font-size: 17px;
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  font-synthesis: none;
  /* The strip's right-bleed is measured in vw, which on platforms with classic
     (non-overlay) scrollbars includes the scrollbar width — that would overshoot
     the visible edge by ~15px and add a stray horizontal scrollbar. `clip`
     absorbs the overshoot without creating a scroll container (so it can't
     affect the fixed-position carousel). */
  overflow-x: clip;
}
img { max-width: 100%; display: block; }
button { font: inherit; color: inherit; background: none; border: none; cursor: pointer; }
a { color: inherit; }

/* ── Back link ───────────────────────────────────────────────────────────────
   One control in one place. The case pages and the CV each have their own
   content column — 1392px of media on a case, a 210mm sheet on the CV — so
   aligning the link to whichever column it happens to sit above put it in
   three different spots. It hangs off the frame instead, which is the same on
   every page, and at 1512 that is also exactly where a case page's copy
   starts. Case pages drop the horizontal padding: .case__inner has already
   applied it (see case.css). */
.backlink {
  max-width: calc(var(--frame) + var(--frame-pad) * 2);
  margin: 0 auto;
  padding: clamp(22px, 4vw, 40px) var(--frame-pad) 0;
  /* flex, not a block: a block would wrap the link in a line box sized by
     whatever font the page sets on its body, which is 24px on a case page and
     16px on the CV — a 5px difference in where the link lands. */
  display: flex;
  align-items: flex-start;
}
.backlink__a {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  font-family: var(--font-mono);
  font-size: 13px;            /* the wordmark's size: it leads back to it */
  color: var(--ink-soft);
  text-decoration: none;
  transition: color .15s var(--ease-out);
}
@media (hover: hover) { .backlink__a:hover { color: var(--ink); } }
/* It is a control, not part of the page. */
@media print { .backlink { display: none; } }

/* ── Header ───────────────────────────────────────────────────────────────── */
.site-head {
  max-width: var(--maxw);
  margin: 0 auto;
  padding: clamp(22px, 4vw, 40px) var(--pad) 0;
}
.wordmark {
  font-family: var(--font-mono);
  font-size: 13px;
  font-weight: 500;
  letter-spacing: .01em;
  color: var(--ink);
}

/* ── Page shell ───────────────────────────────────────────────────────────── *
 * .page is left untouched on purpose: it defines where the content's left edge
 * sits, so narrowing happens on the inside (--contentw) and the left margin
 * never moves. Selected work deliberately opts out and bleeds right instead.
 * ------------------------------------------------------------------------- */
.page {
  max-width: var(--maxw);
  margin: 0 auto;
  padding: 0 var(--pad) clamp(80px, 14vw, 160px);
}
/* The narrower reading column: left-aligned inside .page, so only the right
   edge pulls in. .work is excluded — its strip runs to the viewport edge. */
.experience,
.doing,
.contact { max-width: var(--contentw); }

/* ── Intro ────────────────────────────────────────────────────────────────── */
.intro {
  padding: clamp(64px, 13vw, 150px) 0 clamp(56px, 11vw, 120px);
  max-width: 640px;
}
.intro__lead {
  font-size: 24px;
  line-height: 1.42;
  letter-spacing: -0.01em;
  font-weight: 400;
}
/* Inline underlined links. The underline is a background gradient rather than a
   border so it can be redrawn left-to-right on hover — like someone quickly
   underlining the word again. A gradient (not an absolutely-positioned ::after)
   because it keeps working when a link wraps across two lines: each line
   fragment paints its own underline. */
.link {
  text-decoration: none;
  color: var(--ink);
  padding-bottom: 1px;
  background-image: linear-gradient(currentColor, currentColor);
  background-repeat: no-repeat;
  background-position: 0 100%;
  background-size: 100% 1px;
  transition: color .15s var(--ease-out);
}
@media (hover: hover) {
  /* Re-runs on every hover-in, so the stroke replays each time. */
  .link:hover { animation: underline-draw var(--underline-dur) var(--ease-out); }
}
@keyframes underline-draw {
  from { background-size: 0 1px; }
  to   { background-size: 100% 1px; }
}

/* ── Section titles (small mono label; no rule — sections are separated by
      whitespace alone, the only hairlines on the page are between experience
      items) ──────────────────────────────────────────────────────────────── */
.section-title {
  font-family: var(--font-mono);
  font-size: 13px;          /* sentence case reads smaller than caps did at 12px */
  font-weight: 500;
  letter-spacing: .01em;    /* the wide tracking was there to open up the caps */
  color: var(--ink-soft);
  margin-bottom: 28px;
}

/* ── Selected work — horizontal cover strip ───────────────────────────────── */
.work { margin-bottom: clamp(72px, 12vw, 140px); }

/* The wrapper stays at content width and only provides the positioning context
   for the arrows (which reach out to the viewport edges themselves). */
.stripwrap {
  position: relative;
}
/* The scroll container bleeds to BOTH viewport edges, so thumbnails scrolling out
   of view are clipped by the screen edge rather than cut off at the content
   column.
   --gutter-l / --gutter-r are measured in JS (setGutters) as the real distance
   from each viewport edge to the content column. Deliberately NOT `50% - 50vw`:
   100vw includes the scrollbar on platforms that reserve space for one, which
   threw the alignment out by ~15px. The negative margin pushes the box out to the
   edge and the equal padding puts the FIRST thumbnail back on the shared content
   margin; scroll-padding keeps snap landing on that same line. Fallback 0px means
   no bleed (graceful) if the JS hasn't run. */
.strip {
  display: flex;
  gap: 8px;
  overflow-x: auto;
  overflow-y: hidden;
  margin-left:  calc(-1 * var(--gutter-l, 0px));
  margin-right: calc(-1 * var(--gutter-r, 0px));
  padding-left: var(--gutter-l, 0px);
  scroll-padding-left: var(--gutter-l, 0px);
  /* End gutter so the last thumbnail doesn't jam against the edge when scrolled. */
  padding-right: var(--pad);
  padding-bottom: 8px;
  scroll-snap-type: x proximity;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
}
.strip::-webkit-scrollbar { display: none; }

/* Step arrows — hidden until the strip is hovered, and each hides itself once
   there's nothing further that way. Solid fill so they read on top of the grey
   thumbnails they overlap. */
.snav {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  z-index: 2;
  display: grid;
  place-items: center;
  width: 42px;
  height: 42px;
  border-radius: 50%;
  background: var(--bg);
  color: var(--ink);
  font-size: 20px;
  line-height: 1;
  box-shadow: 0 4px 16px -4px rgba(20,20,20,.30);
  opacity: 0;
  pointer-events: none;
  transition:
    opacity .18s var(--ease-out),
    background-color .15s var(--ease-out);
}
/* Pushed right out to the page edges, using the same measured gutters as the
   strip so both arrows sit an equal 16px from their respective viewport edge. */
.snav--prev { left:  calc(-1 * var(--gutter-l, 0px) + 16px); }
.snav--next { right: calc(-1 * var(--gutter-r, 0px) + 16px); }
@media (hover: hover) {
  .stripwrap:hover .snav:not(:disabled) { opacity: 1; pointer-events: auto; }
  .snav:hover { background: var(--surface); }
}
.snav:focus-visible { opacity: 1; pointer-events: auto; outline: 2px solid var(--ink); outline-offset: 2px; }
/* Touch scrolls the strip directly — no arrows needed. */
@media (hover: none) { .snav { display: none; } }

/* A slide thumbnail: mini white screens on a grey field. No text at rest — the
   label fades in on hover (bottom-left), the way the first portfolio's cards
   behaved, with a deliberately subtle lift. */
.thumb {
  position: relative;
  display: block;            /* it's an <a> now — inline wouldn't size */
  text-decoration: none;
  color: inherit;
  flex: 0 0 auto;
  width: clamp(310px, 29.3vw, 431px);   /* +15% again (was 270/25.5vw/375) */
  aspect-ratio: 4 / 3;
  border-radius: var(--radius-sm);
  overflow: hidden;
  background: var(--field);
  padding: 0;
  scroll-snap-align: start;
  cursor: pointer;
}
@media (hover: hover) {
  /* The label fades in and the artwork zooms slightly inside the card's own
     clip. No lift, no shadow. The label is a sibling of the cover, not a
     child, so it holds still while the picture moves under it. */
  .thumb__cover,
  .thumb__stage { transition: transform .35s var(--ease-out); }
  .thumb:hover .thumb__cover,
  .thumb:hover .thumb__stage { transform: scale(1.05); }
  .thumb:hover .thumb__label { opacity: 1; }
}
.thumb:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 2px;
}
/* Keyboard users get the same information a pointer would reveal. */
.thumb:focus-visible .thumb__label { opacity: 1; }

/* The mini screens. Sizes arrive from script.js as a % of the thumbnail so a row
   of four still fits; white-on-grey is what makes them read at this scale. */
.thumb__stage {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 3%;
  transition: transform .35s var(--ease-out);
}
.mini {
  position: relative;
  display: block;
  flex: 0 0 auto;
  overflow: hidden;
  background: #fff;
  filter: var(--screen-shadow);
}
/* A cropped hero overrides aspect-ratio inline (script.js) — a wider window that
   shows only the top of the screen. */
.mini--phone   { aspect-ratio: var(--ar-phone);   border-radius: 3px; }
.mini--tablet  { aspect-ratio: var(--ar-tablet);  border-radius: 4px; }
.mini--desktop { aspect-ratio: var(--ar-desktop); border-radius: 3px; }

/* Real screenshot filling a mini (see fillScreen in script.js). */
.mini__img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: top;
  border-radius: inherit;
}

/* Magnified hero: the window is pinned to the tile's top-left with a small margin
   and runs all the way to the right and bottom edges, where the tile clips it. The
   screenshot inside is scaled by --hero-zoom from its top-left corner, so what you
   see is a zoomed view of the screen's top-left that bleeds off two edges. */
.mini--bleed {
  position: absolute;
  /* Anchored 12% in from the tile's top and left. Both insets are the same
     fraction, so the window's aspect is unchanged and how much of the screen you
     see still depends only on --hero-zoom. */
  top: 12%;
  left: 12%;
  right: 0;
  bottom: 0;
  width: auto;
  height: auto;
  aspect-ratio: auto;
  border-radius: 6px 0 0 0;   /* only the corner that's actually visible */
}
.mini--bleed .mini__img,
.mini--bleed .mini__inner {
  position: absolute;
  top: 0;
  left: 0;
  width: calc(100% * var(--hero-zoom, 1));
  height: auto;
  max-width: none;
  aspect-ratio: var(--ar-tablet);   /* keeps height from collapsing on placeholders */
  border-radius: inherit;
}
.mini--bleed .mini__img { aspect-ratio: auto; }   /* real images size from their own ratio */

/* ── Thumbnail cover ──────────────────────────────────────────────────────
   One flat colour (or a photo) with the project's logo centred on it — the
   same treatment as the cards on the first portfolio, so the two sites read
   as the same person's work. --logo-max is set per project where a mark needs
   to sit smaller than the default. */
.thumb__cover {
  position: absolute;
  inset: 0;
  display: block;
  overflow: hidden;
}
.thumb__img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.thumb__logo {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  max-width: var(--logo-max, 52%);
  max-height: 46%;
  width: auto;
  height: auto;
  object-fit: contain;
  pointer-events: none;
}

.thumb__label {
  position: absolute;
  left: 11px;
  bottom: 10px;
  display: flex;
  align-items: center;      /* centres the app icon against the text */
  gap: 6px;
  font-family: var(--font-mono);
  font-size: 14px;
  letter-spacing: .01em;
  color: var(--ink);
  text-align: left;
  opacity: 0;
  transition: opacity .2s var(--ease-out);
}
/* A touch screen has no hover to reveal it with, so there the name stays on.
   This has to sit after the rule above it, not with the other hover rules:
   same specificity, so order is what decides. */
@media (hover: none) {
  .thumb__label { opacity: 1; }
}
/* App icon — deliberately larger than the text, reading as a small app tile. */
.thumb__icon {
  flex: 0 0 auto;
  display: block;
  width: 21px;
  height: 21px;
  border-radius: 5px;
}
/* A step back from the name, but these sit on artwork rather than on white,
   so --ink-faint is too light for the job: measured against the lightest
   pixel under the label it comes out at 1.7:1 on the Famly gradient and
   2.4:1 on Hooked. --ink-card keeps the hierarchy and clears 4.5:1 on both
   (5.7 and 8.0). */
.thumb__year, .thumb__role { color: var(--ink-card); }
/* Tattoodo and Nets have dark covers; the label flips to white on those. */
.thumb__label--light,
.thumb__label--light .thumb__year,
.thumb__label--light .thumb__role { color: #fff; }

/* Breathing room between the lower sections (Selected work sets its own). */
.experience { margin-bottom: clamp(72px, 12vw, 140px); }

/* ── Experience — one line per role: company + role, year on the right ────── */
.xp { list-style: none; }
.xp__row {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 16px 24px;
  padding: 18px 0;
  border-top: 1px solid var(--line);
  font-size: 14px;              /* everything in this section is 14px */
}
/* Strictly BETWEEN items: no rule above the first row, none below the last. */
.xp__row:first-child { border-top: none; padding-top: 0; }
.xp__company { color: var(--ink); }
.xp__role    { color: var(--ink-soft); margin-left: 6px; }
.xp__year {
  font-family: var(--font-mono);
  color: var(--ink-faint);
  white-space: nowrap;
}

/* ── What I do / Contact — plain lists, no rules (hairlines are reserved for
      the experience items) ───────────────────────────────────────────────── */
.dolist {
  list-style: none;
  display: grid;
  gap: 10px;
  font-size: 14px;
}
.contact__list {
  list-style: none;
  display: grid;
  gap: 8px;
  font-size: 14px;
}

/* ============================================================================
   FULLSCREEN CAROUSEL
   ========================================================================== */
.carousel {
  position: fixed;
  inset: 0;
  z-index: 40;
  background: var(--field);   /* grey so the white screens pop */
  display: grid;
  grid-template-rows: 1fr auto;   /* stage grows, footer hugs bottom */
  opacity: 0;
  visibility: hidden;
  transition: opacity .32s var(--ease-out), visibility 0s linear .32s;
}
/* When the expanding .field is providing the grey, the carousel must not paint its
   own or the two would double up — and on close its content has to clear quickly,
   before the field has shrunk away underneath it. Declared BEFORE .is-open so that
   rule still governs the fade-in while open (equal specificity, later wins). */
.carousel.has-field {
  background: transparent;
  transition: opacity 130ms var(--ease-out), visibility 0s linear 130ms;
}
/* Fades in quicker than it fades out: the siblings live inside this box, so a slow
   fade-in double-faded them (own fade × container fade) and made them read as
   arriving late. The ghost is outside, so it was never affected — which is exactly
   why the hero looked prompt and its friends looked delayed. */
.carousel.is-open {
  opacity: 1;
  visibility: visible;
  transition: opacity var(--carousel-in) var(--ease-out);
}

/* Carousel controls — bare icons, no button chrome. The only affordance is a
   circular grey wash on hover; it also flashes briefly when the matching real
   key is pressed (script.js adds .is-pressed), so the keyboard still announces
   itself without a key-cap drawn on screen. All three share the --pad inset. */
.cbtn {
  position: absolute;
  z-index: 3;
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  color: var(--ink);
  line-height: 1;
  transition:
    background-color .15s var(--ease-out),
    opacity .15s var(--ease-out);
}
@media (hover: hover) {
  /* White on the grey field — --surface would be almost invisible against it. */
  .cbtn:hover { background: var(--bg); }
}
.cbtn.is-pressed { background: var(--bg); }
.cbtn:focus-visible { outline: none; }
.cbtn:disabled { opacity: .3; pointer-events: none; }
/* The scrim is only 93% opaque, so these would ghost through right beside the
   lightbox's own controls and read as doubled arrows. Higher specificity than
   :disabled above, so it wins for whichever arrow happens to be dimmed. */
body.lb-open .cbtn { opacity: 0; pointer-events: none; }

.cbtn--close {
  top: 20px;
  right: var(--pad);
  font-size: 15px;          /* small ✕ */
}
/* Arrows sit on the vertical centre line; slightly larger than the ✕. */
.cbtn--prev,
.cbtn--next {
  top: 50%;
  transform: translateY(-50%);
  font-size: 24px;
}
.cbtn--prev { left: var(--pad); }
.cbtn--next { right: var(--pad); }
@media (hover: none) {
  .cbtn--prev, .cbtn--next { display: none; }   /* touch = swipe */
}

/* Stage: the area the slide moves through */
.stage {
  position: relative;
  overflow: hidden;
  display: grid;
  place-items: center;
  padding: clamp(60px, 9vh, 96px) clamp(56px, 8vw, 120px) 12px;
}
.slidewrap { position: relative; width: 100%; height: 100%; }

/* A slide fills the stage and centers its screens. Absolutely positioned so the
   outgoing and incoming slides can overlap during the fly-in. */
.slide {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  opacity: 1;
  transform: translateX(0);
}
.slide.is-enter-right { animation: slide-in-right var(--slide-dur) var(--ease-out) both; }
.slide.is-enter-left  { animation: slide-in-left  var(--slide-dur) var(--ease-out) both; }
.slide.is-leave-left  { animation: slide-out-left  var(--slide-dur) var(--ease-out) both; }
.slide.is-leave-right { animation: slide-out-right var(--slide-dur) var(--ease-out) both; }

@keyframes slide-in-right { from { opacity: 0; transform: translateX(7%); } to { opacity: 1; transform: translateX(0); } }
@keyframes slide-in-left  { from { opacity: 0; transform: translateX(-7%);} to { opacity: 1; transform: translateX(0); } }
@keyframes slide-out-left  { from { opacity: 1; transform: translateX(0); } to { opacity: 0; transform: translateX(-5%); } }
@keyframes slide-out-right { from { opacity: 1; transform: translateX(0); } to { opacity: 0; transform: translateX(5%); } }

/* Screens row */
.screens {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: clamp(16px, 3vw, 40px);
  width: 100%;
}
.screens--phone   { gap: clamp(14px, 2.4vw, 34px); }
.screens--tablet  { gap: clamp(16px, 2.6vw, 38px); }
.screens--desktop { gap: clamp(20px, 3vw, 44px); }

/* A single screen "device". Placeholder until real shots are wired in. White (not
   grey) so it reads as a device against the grey field — same as the thumbnail
   minis, and matching how the real screenshots sit. Each carries a scatter offset
   (--dx/--dy) and a physical drop shadow, and reveals one-by-one via --stagger. */
.screen {
  position: relative;
  flex: 0 0 auto;
  border-radius: 13px;              /* 40% down from 22px */
  background: var(--bg);
  filter: var(--screen-shadow);
  cursor: zoom-in;                       /* each screen opens in the lightbox */
  transform: translate(var(--dx, 0), var(--dy, 0));
  /* start hidden; the stagger animation reveals it */
  opacity: 0;
  animation: screen-in var(--screen-dur) var(--ease-spring) both;
  animation-delay: var(--stagger, 0ms);
}
.screen--phone {
  width: clamp(120px, 15vw, 190px);
  aspect-ratio: var(--ar-phone);
}
/* iPad — portrait, so three sit side by side and contrast with the wide
   desktop slide that follows. */
.screen--tablet {
  width: clamp(170px, 21vw, 280px);
  aspect-ratio: var(--ar-tablet);
  border-radius: 11px;              /* 40% down from 18px */
}
.screen--desktop {
  width: clamp(320px, 42vw, 520px);
  aspect-ratio: var(--ar-desktop);
  border-radius: 8px;               /* 40% down from 14px */
}
/* A screen with the stage to itself gets more room. */
.screens.is-single .screen--desktop { width: clamp(420px, 56vw, 760px); }
.screens.is-single .screen--tablet  { width: clamp(240px, 30vw, 400px); }
.screens.is-single .screen--phone   { width: clamp(160px, 19vw, 250px); }

.screen__inner {
  position: absolute;
  inset: 8px;
  border-radius: 8px;
  /* Kept clearly lighter than --field, else the placeholder blends into the
     carousel background instead of reading as a screen. */
  background: linear-gradient(160deg, #ffffff, #f9f9f9);
  box-shadow: inset 0 0 0 1px rgba(0,0,0,.03);
}
.screen--tablet  .screen__inner { inset: 7px; border-radius: 7px; }
.screen--desktop .screen__inner { inset: 6px; border-radius: 5px; }

/* Real screenshot (or screen recording) filling a slide screen — flush to the
   rounded edge, no inset bezel; the export already includes its own chrome. */
.screen__img,
.screen__video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: inherit;
  display: block;
}

/* reveal: rise from below the resting scatter position and settle into it */
@keyframes screen-in {
  from { opacity: 0; transform: translate(var(--dx, 0), calc(var(--dy, 0px) + 46px)) scale(.96); }
  to   { opacity: 1; transform: translate(var(--dx, 0), var(--dy, 0)) scale(1); }
}

/* ── Carousel footer: caption, then project name + dots ───────────────────── */
.cfoot {
  padding: 4px var(--pad) clamp(26px, 4vh, 44px);
  text-align: center;
}
.cfoot__caption {
  max-width: 620px;
  margin: 0 auto 22px;
  font-size: 16px;
  line-height: 1.5;
  color: var(--ink);
  /* Reserve three lines — every caption wraps to exactly 3 at 16px. Since .stage
     takes the remaining height, letting this box grow/shrink per slide would
     resize the screens as you navigate; a fixed height keeps the stage — and
     the screens — perfectly still. Bump this if captions get longer. */
  min-height: 4.5em;
  transition: opacity var(--caption-dur) var(--ease-out);
}
.cfoot__meta {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
}
.cfoot__project {
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--ink-soft);
}
/* Square, windowed pagination. Size carries the meaning (full = near the current
   slide, shrinking = further out), so there's no scale/transform on the active
   one — script.js sets --sq per marker and hides the ones outside the window. */
.dots {
  display: flex;
  align-items: center;      /* different sizes share one centre line */
  gap: 4px;
  height: 6px;              /* fixed, so the row never shifts as sizes change */
}
.dot {
  flex: 0 0 auto;
  width: var(--sq, 6px);
  height: var(--sq, 6px);
  border-radius: 0;         /* square */
  background: color-mix(in srgb, var(--ink) 15%, transparent);
  padding: 0;
  transition:
    width .3s var(--ease-out),
    height .3s var(--ease-out),
    background-color .3s var(--ease-out);
}
.dot.is-active { background: var(--ink); }
.dot[hidden] { display: none; }
@media (hover: hover) {
  .dot:not(.is-active):hover { background: color-mix(in srgb, var(--ink) 40%, transparent); }
}

/* ── Lightbox ─────────────────────────────────────────────────────────────── *
   One screen at a time, from the current slide only. Dark scrim rather than the
   page's usual light surfaces: it's the one moment the screenshot should be the
   only thing you're looking at, and grey-on-grey wouldn't separate it from the
   carousel underneath. */
.lightbox {
  position: fixed;
  inset: 0;
  z-index: 60;
  display: grid;
  place-items: center;
  background: rgba(14, 14, 14, .93);
  opacity: 0;
  visibility: hidden;
  transition: opacity .2s var(--ease-out), visibility 0s linear .2s;
}
.lightbox.is-open {
  opacity: 1;
  visibility: visible;
  transition: opacity .2s var(--ease-out);
}
.lightbox__frame { display: grid; place-items: center; }
.lightbox__screen {
  background: #fff;
  border-radius: 10px;
  overflow: hidden;
  max-width: 88vw;
  max-height: 84vh;
}
.lightbox__screen--phone   { aspect-ratio: var(--ar-phone);   height: 84vh; }
.lightbox__screen--tablet  { aspect-ratio: var(--ar-tablet);  height: 84vh; }
.lightbox__screen--desktop { aspect-ratio: var(--ar-desktop); width: 88vw; }
.lightbox__screen img,
.lightbox__screen video {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;      /* the whole screen, uncropped — that's the point */
}
/* Placeholder screens keep the same treatment as everywhere else. */
.lightbox__screen .screen__inner { position: absolute; inset: 0; border-radius: inherit; }

.lightbox__count {
  position: absolute;
  bottom: 26px;
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: .04em;
  color: rgba(255,255,255,.65);
}
/* Controls: same bare-icon language as the carousel, inverted for the dark scrim. */
.lbtn {
  position: absolute;
  z-index: 2;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  border-radius: 50%;
  color: #fff;
  line-height: 1;
  transition: background-color .15s var(--ease-out), opacity .15s var(--ease-out);
}
@media (hover: hover) {
  .lbtn:hover { background: rgba(255,255,255,.14); }
}
.lbtn:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
.lbtn--close { top: 20px; right: var(--pad); font-size: 16px; }
.lbtn--prev  { top: 50%; transform: translateY(-50%); left:  clamp(10px, 2vw, 26px); font-size: 24px; }
.lbtn--next  { top: 50%; transform: translateY(-50%); right: clamp(10px, 2vw, 26px); font-size: 24px; }
.lbtn:disabled { opacity: 0; pointer-events: none; }

/* ── Expanding grey field ─────────────────────────────────────────────────── *
   Always fullscreen; a clip-path opens it out from the clicked thumbnail's tile.
   z 39 puts it under the carousel's content (40) and under the ghost (50), but
   over the page. When it's in play the carousel drops its own background so the
   two can't double up. */
.field {
  position: fixed;
  inset: 0;
  z-index: 39;
  background: var(--field);
  opacity: 0;
  pointer-events: none;
  will-change: clip-path;
}
.field.is-on { opacity: 1; }

/* ── Morph ghost ──────────────────────────────────────────────────────────── *
   The hero screen in flight between a thumbnail and its slide. Sits above the
   carousel (z 50 > 40) and outside it, so the grey field can fade underneath
   without dimming the flying screen. Geometry is set per flight in script.js. */
.ghost {
  position: fixed;
  z-index: 50;
  top: 0; left: 0;
  filter: var(--screen-shadow);   /* identical to .mini and .screen */
  opacity: 0;
  pointer-events: none;
  will-change: transform;
}
.ghost.is-flying { opacity: 1; }
/* The clipped layer. Keeping the clip off .ghost is what lets the shadow above
   survive the flight. */
.ghost__clip {
  width: 100%;
  height: 100%;
  overflow: hidden;
  background: var(--bg);
}
.ghost img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
/* Placeholder hero (slides without real screenshots yet). Must match
   .screen__inner exactly — the ghost hands over to a real screen mid-flight, so
   any difference in the gradient would show as a flicker at the swap. */
.ghost__inner {
  width: 100%;
  height: 100%;
  background: linear-gradient(160deg, #ffffff, #f9f9f9);
  box-shadow: inset 0 0 0 1px rgba(0,0,0,.03);
}

/* ── Reduced motion ───────────────────────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
  .slide, .screen, .link:hover { animation-duration: 1ms !important; }
  .thumb__stage { transition-duration: 1ms !important; }
}

/* ── Small screens ────────────────────────────────────────────────────────── */
@media (max-width: 640px) {
  .stage { padding: 72px 20px 8px; }
  .screens--phone, .screens--tablet { flex-wrap: wrap; gap: 14px; }
  .screen--phone { width: clamp(96px, 30vw, 140px); }
  .screen--tablet { width: clamp(130px, 38vw, 200px); }
  .screen--desktop { width: min(88vw, 460px); }
  .screens.is-single .screen--desktop { width: min(90vw, 480px); }
  .screens.is-single .screen--tablet  { width: min(56vw, 260px); }
}

/* ── Famly logo loop ────────────────────────────────────────────────────────
   images/famly/famly-logo-loop.svg is a self-contained animated SVG — the CSS
   animation lives inside the file, so it plays when referenced from an <img>
   and needs no script.

   The file itself is transparent and the gradient sits here, on the container.
   That's what lets one asset serve both the 16:7 case-study hero and the 4:3
   strip thumbnail: the artwork is centred with object-fit and the gradient
   simply fills whatever box it's in, so neither crop nor letterbox bars.
   --------------------------------------------------------------------------- */
.famly-loop {
  /* The gradient field. Its height grows with the viewport but stops at the
     design's 318, and never collapses below 200 — which is what used to
     shrink the mark to nothing on a phone, because the field was a fixed
     ratio and the mark was sized as a share of it. The mark is clamped
     separately below, so the two no longer drag each other down.
     Gradient is the one the animation's own snippet ships with. */
  display: grid;
  place-items: center;
  height: clamp(240px, 23vw, 318px);
  overflow: hidden;
  background:
    radial-gradient(70% 145% at 4% 108%, rgba(241, 82, 151, .24) 0%, rgba(241, 82, 151, 0) 62%),
    radial-gradient(58% 120% at 27% -22%, rgba(105, 16, 231, .18) 0%, rgba(105, 16, 231, 0) 68%),
    radial-gradient(66% 135% at 91% 18%, rgba(255, 255, 255, .30) 0%, rgba(255, 255, 255, 0) 72%),
    radial-gradient(45% 100% at 64% 112%, rgba(223, 200, 244, .34) 0%, rgba(223, 200, 244, 0) 74%),
    linear-gradient(118deg, #b99ae5 0%, #c5aae7 48%, #d8c8ee 100%),
    #c2a4e4;
}
/* Fills its parent instead of setting its own ratio — used in the strip, where
   .thumb already defines a 4/3 tile. */
.famly-loop--fill {
  width: 100%;
  height: 100%;
}
.famly-loop img {
  /* Sized on its own terms, not as a share of the field. A flat percentage
     fell away too fast — 445 on desktop down to 210 on a phone. This is a
     shallow line instead: 11.6% of the field plus a 284px base, so it still
     shrinks, but about half as far. The 84% term is the guard that keeps it
     inside a genuinely narrow screen, where the base alone would overflow. */
  display: block;
  width: min(84%, 11.6% + 284px, 460px);
  height: auto;
  max-width: none;
}
