/* ============================================================
   Slina studio — one pager
   Matched to the "Desktop - 2" mockup.
   ============================================================ */

:root {
  --cream:  #FDFBF2;   /* page background            */
  --ink:    #1E1C17;   /* headings / body            */
  --body:   #33312B;   /* small copy                 */
  --lime:   #E0E767;   /* CTA fill                   */
  --lime-d: #D3DB4E;   /* CTA hover                  */
  --rule:   rgba(30, 28, 23, .34);  /* graph paper   */

  --coral:  #FF8A85;
  --lilac:  #8E84DC;
  --olive:  #6E6B45;

  --script:  "Italianno", "Snell Roundhand", "Apple Chancery", cursive;
  /* headings */
  --display: "EB Garamond", "Iowan Old Style", Georgia, serif;
  /* --round is UX only now: nav links, buttons, form fields */
  --hand:    "Caveat", "Bradley Hand", "Segoe Script", cursive;
  --round:   "Quicksand", "Avenir Next", "Century Gothic", ui-rounded, sans-serif;
  /* body copy — swap this one line to try another serif, see the note below */
  --serif:  "Fraunces", "Iowan Old Style", Georgia, "Times New Roman", serif;
  --sans:   -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif;

  /* Alternatives worth trying in --serif (add the family to the Google Fonts
     link in index.html first):
       "Lora"         — warm and sturdy, the safest of the set
       "EB Garamond"  — lighter, more literary, needs ~1px more size
       "Crimson Pro"  — bookish and quiet
       "Newsreader"   — editorial, stronger italics */

  --line: 2px;   /* weight of every lilac rule on the page */

  --wrap: 1140px;
  /* fluid so the edges open up through the tablet range with no jump */
  --gutter:       clamp(1.5rem, 3.8vw, 2.75rem);  /* page edge padding */
  --board-gutter: clamp(1.5rem, 3.8vw, 3.25rem);  /* graph-paper inset */
}

*, *::before, *::after { box-sizing: border-box; }

/* anchor scrolling is animated in script.js (eased); native smooth would fight it */
html { scroll-behavior: auto; scroll-padding-top: 110px; }

body {
  margin: 0;
  background: var(--cream);
  color: var(--ink);
  font-family: var(--serif);
  /* Reading copy only. Headings, nav, buttons and form fields all set their
     own size in rem, which is relative to the root — so this moves the About
     and project-detail paragraphs without touching any of the chrome. */
  font-size: 18px;
  line-height: 1.7;
  /* Fraunces ships an optical-size axis — let the browser pick the right cut
     for each size, so small copy gets the sturdier text design */
  font-optical-sizing: auto;
  /* WONK 1 swaps in the splayed, deliberately odd letterforms (the wonky g, y,
     and the angled leg on the r). SOFT rounds the terminals off a little.
     opsz is left out on purpose so optical sizing above still applies. */
  font-variation-settings: "SOFT" 25, "WONK" 1;
  -webkit-font-smoothing: antialiased;
}

img { display: block; max-width: 100%; }
a   { color: inherit; }

.wrap {
  width: min(100% - var(--gutter) * 2, var(--wrap));
  margin-inline: auto;
}

.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}

.skip-link {
  position: absolute;
  left: -9999px;
  background: var(--ink);
  color: var(--cream);
  padding: .7rem 1.2rem;
  z-index: 100;
}
.skip-link:focus { left: 1rem; top: 1rem; }

:focus-visible {
  outline: 2px solid var(--olive);
  outline-offset: 3px;
}

/* ---------------- Decorations ----------------
   Flat SVG assets from assets/, positioned against the nearest
   positioned ancestor. Coordinates traced from the mockup. */

.deco {
  position: absolute;
  display: block;
  height: auto;
  pointer-events: none;
  user-select: none;
  z-index: 2;
}

/* ---------------- Nav ---------------- */

.nav {
  position: sticky;
  top: 0;
  z-index: 40;
  background: rgba(253, 251, 242, .9);
  backdrop-filter: blur(8px);
  will-change: transform;
  /* revealing: springs in with a little overshoot.
     opacity is here so the bar can fade out behind the full-screen menu */
  transition: transform .55s cubic-bezier(.22, 1.4, .36, 1), opacity .25s ease;
}

/* a single lilac hairline instead of a drop shadow.
   On a pseudo-element so it can't add 1px to the nav's height. */
.nav::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: var(--line);
  background: var(--lilac);
  opacity: 0;
  transition: opacity .3s ease;
  pointer-events: none;
}
.nav.is-stuck::after { opacity: 1; }

/* hiding: quicker, no bounce — it should just get out of the way */
.nav.is-hidden {
  transform: translateY(-100%);
  transition: transform .32s cubic-bezier(.4, 0, 1, 1);
}

.nav__inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  padding: 2.6rem 0 1.1rem;
  transition: padding-top .25s ease;
}
/* reclaim the breathing room once the bar is stuck to the top */
.nav.is-stuck .nav__inner { padding-top: 1.1rem; }

.logo {
  --logo-h: clamp(42px, 4.4vw, 56px);
  display: block;
  line-height: 0;
  overflow: hidden;   /* past the fold this crops the lettering away */
  width: calc(var(--logo-h) * 180 / 82);
  transition: width .45s cubic-bezier(.4, 0, .2, 1);
}
.nav.is-stuck .logo { --logo-h: 44px; }
/* only the little character survives past the fold */
.nav.is-past .logo { width: calc(var(--logo-h) * 74 / 82); }

.logo__svg {
  display: block;
  height: var(--logo-h);
  width: auto;
  transition: height .25s ease;
}
.logo__type { transition: opacity .3s ease; }
.nav.is-past .logo__type { opacity: 0; }

/* the pupils are moved by script.js; the whites just wink out for a blink */
.logo__eye { transition: opacity .06s ease; }

.nav__links { display: flex; gap: 1.9rem; }
.nav__links a {
  position: relative;
  font-family: var(--round);   /* chrome stays sans; only reading copy is serif */
  font-size: .95rem;
  text-decoration: none;
  color: var(--ink);
  padding-bottom: 8px;
}

/* pencil underline: Tina's own drawn stroke (assets/underline.svg), used as
   a mask so one background-color recolors it, and wiped in left-to-right so
   it reads as being drawn rather than switched on. The SVG carries
   preserveAspectRatio="none" so it stretches to each link's width; height
   here sets the stroke weight. */
.nav__links a::after {
  content: "";
  position: absolute;
  left: -3px;
  right: -3px;
  bottom: 1px;
  /* the SVG stretches to this box, so height IS the stroke weight */
  height: 6px;
  background-color: var(--ink);
  -webkit-mask: url("assets/underline.svg") no-repeat center / 100% 100%;
          mask: url("assets/underline.svg") no-repeat center / 100% 100%;
  clip-path: inset(0 100% 0 0);
  /* the color reverts only AFTER the wipe has finished, so the stroke stays
     lime the whole way out instead of flicking back to ink mid-retreat */
  transition:
    clip-path .42s cubic-bezier(.36, .8, .3, 1),
    background-color .25s ease .42s;
  pointer-events: none;
}

.nav__links a:hover::after,
.nav__links a.is-active::after { clip-path: inset(0 0 0 0); }
/* ink marks the active section; lime while you point at it */
.nav__links a:hover::after {
  /* the CTA lime. It's only 1.48:1 on cream, so this leans on the drawn
     stroke being chunky enough to carry a pale color — the thin synthetic
     line it replaced could not. Revert to var(--lilac) (3.12:1) if it reads
     as washed out. */
  background-color: var(--lime-d);
  transition:
    clip-path .42s cubic-bezier(.36, .8, .3, 1),
    background-color .1s ease 0s;   /* arrives instantly on the way in */
}

.nav__toggle {
  display: none;
  flex-direction: column;
  justify-content: center;
  gap: 5px;
  width: 42px; height: 38px;
  padding: 0 9px;
  background: var(--lime);
  border: none;
  border-radius: 4px;   /* matches the CTA button */
  cursor: pointer;
  transition: background .18s ease;
}
.nav__toggle:hover { background: var(--lime-d); }
.nav__toggle span { height: 2px; background: var(--ink); border-radius: 2px; transition: transform .2s ease, opacity .2s ease; }
.nav__toggle[aria-expanded="true"] span:nth-child(1) { transform: translateY(6.5px) rotate(45deg); }
.nav__toggle[aria-expanded="true"] span:nth-child(2) { opacity: 0; }
.nav__toggle[aria-expanded="true"] span:nth-child(3) { transform: translateY(-6.5px) rotate(-45deg); }

/* ---------------- Button ---------------- */

.btn {
  display: inline-block;
  background: var(--lime);
  color: var(--ink);
  border: none;
  border-radius: 4px;
  padding: .82rem 1.55rem;
  font-family: var(--round);
  font-size: 1.22rem;
  font-weight: 500;
  line-height: 1.1;
  text-decoration: none;
  cursor: pointer;
  transition: background .18s ease, transform .18s ease;
}
.btn:hover { background: var(--lime-d); transform: translateY(-1px); }

/* in flight: no lift, no hover color, and the cursor says wait.
   :disabled:hover is needed too, or .btn:hover still applies the lift. */
.btn:disabled,
.btn:disabled:hover {
  background: var(--lime-d);
  opacity: .6;
  cursor: progress;
  transform: none;
}

/* ---------------- Hero ---------------- */

.hero { padding: clamp(2.5rem, 6vw, 5.5rem) 0 clamp(3rem, 7vw, 6rem); }
.hero__inner { position: relative; }

.hero__title {
  font-family: var(--display);
  font-weight: 400;
  /* Garamond sets small for its point size — everything is scaled up ~10% */
  font-size: clamp(1.8rem, 3.6vw, 3.25rem);
  line-height: 1.26;
  letter-spacing: -.008em;
  margin: 0 0 2.2rem;
}

.deco--dome  { width: 34px;  top: -56px; left: 36%; }
.deco--star  { width: 60px;  top: -58px; left: 92%; }
.deco--squig { width: 142px; top: 84px;  left: -150px; }
.deco--aster { width: 42px;  top: 150px; left: 52%; }
.deco--zig   { width: 96px;  top: 146px; right: -92px; }

/* Idle motion, so they don't just land and freeze.
   Gated on :not([data-reveal]) — script.js strips that attribute once an
   element has finished revealing, so the loop can't fight the entrance
   animation's transform. Negative delays start each one mid-cycle so they
   never drift into sync. */
.deco--aster:not([data-reveal]) { animation: deco-spin    24s linear      infinite; }
.deco--star:not([data-reveal])  { animation: deco-twinkle 5.5s ease-in-out infinite -1.2s; }
.deco--dome:not([data-reveal])  { animation: deco-bob     7s   ease-in-out infinite -3s; }
.deco--zig:not([data-reveal])   { animation: deco-drift   8.5s ease-in-out infinite -2s; }
.deco--squig:not([data-reveal]) {
  transform-origin: 100% 50%;   /* it hangs off the left edge, so pivot inboard */
  animation: deco-sway 11s ease-in-out infinite -5s;
}

@keyframes deco-spin { to { transform: rotate(360deg); } }

@keyframes deco-twinkle {
  0%, 100% { transform: rotate(-5deg) scale(1); }
  50%      { transform: rotate(5deg)  scale(1.07); }
}

@keyframes deco-bob {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(-7px); }
}

@keyframes deco-drift {
  0%, 100% { transform: translateX(0)    rotate(0deg); }
  50%      { transform: translateX(-6px) rotate(3deg); }
}

@keyframes deco-sway {
  0%, 100% { transform: rotate(-3.5deg); }
  50%      { transform: rotate(3.5deg); }
}

/* ---------------- Projects board ---------------- */

.projects {
  padding: 0 0 clamp(3rem, 7vw, 6rem);
  /* 100vw counts the scrollbar, so the full-bleed paper would otherwise add a
     horizontal scrollbar. `clip` on x only — y stays visible for the captions. */
  overflow-x: clip;
}

.board {
  position: relative;
  width: min(100% - var(--board-gutter) * 2, 1332px);
  margin-inline: auto;
  /* taller than it is wide, from the layout mockup: the cards grew to 40.5%
     and three of them plus their pin piles need the room */
  aspect-ratio: 670 / 986;
}

/* hand-ruled grid, every line displaced by turbulence so it wavers like it was
   drawn against a ruler. Breaks out of the board to run edge to edge, while
   the tiles stay put.
   The artwork is 32 cells tall, so `auto 100%` would tie cell size to board
   height — and the taller board above would have blown the cells out from
   ~48px to ~61px, undoing the density. Drawing it at 78% of the board height
   and repeating restores exactly the old cell size (board height is 1/0.6795
   of the width, and 0.78 x that / 32 = width / 27.87, which is what
   `auto 100%` gave on the old 1332/1530 board). The seam falls on a rule, so
   it reads as one line rather than a join. */
.board__paper {
  position: absolute;
  top: 0;
  bottom: 0;
  left: calc(50% - 50vw);
  right: calc(50% - 50vw);
  background: url("assets/graph-paper.svg") center top / auto 78% repeat-y;
}

.tile {
  position: absolute;
  width: 28%;
  /* the tiles are anchors, so the caption inside inherits the browser's link
     underline unless it's cleared here */
  text-decoration: none;
  /* each stack sits at its own slight angle, as if dropped on the paper */
  transform: rotate(var(--tilt, 0deg));
  transition: transform .3s ease;
}

/* the polaroid itself */
.tile__card {
  position: relative;
  z-index: 2;
  display: block;
  background: #fff;
  /* a polaroid: even margin on three sides, deep lip along the bottom */
  padding: 11px 11px 46px;
  /* same hairline edge as the photos underneath */
  border: 1px solid rgba(30, 28, 23, .14);
  /* a tight contact shadow plus a softer one, so it lifts off the paper */
  box-shadow:
    0 1px 2px  rgba(30, 28, 23, .12),
    0 5px 12px -3px rgba(30, 28, 23, .18);
  transition: box-shadow .3s ease;
}

/* ---- arranging the photos ----
   .can-drag is set by script.js on fine-pointer desktops only. */
.can-drag .tile {
  cursor: grab;
  touch-action: none;      /* the pointer events own the gesture */
  user-select: none;
}
.can-drag .tile img { -webkit-user-drag: none; user-drag: none; }

.can-drag .tile.is-dragging {
  cursor: grabbing;
  z-index: 5;
  /* straightens and lifts, the way a photo does between finger and thumb */
  transform: rotate(calc(var(--tilt, 0deg) * .25)) scale(1.04);
  /* no transition on left/top, or the card would lag the cursor */
  transition: transform .16s ease;
}
.can-drag .tile.is-dragging .tile__card {
  box-shadow:
    0 3px 6px    rgba(30, 28, 23, .16),
    0 26px 40px -12px rgba(30, 28, 23, .34);
}

/* hovering lifts the card off the paper. With the outline gone the shadow is
   the only thing marking the state, so it travels further than the old one —
   it has to carry the hover on its own. */
.tile:not(.tile--static):hover {
  transform: rotate(calc(var(--tilt, 0deg) * .45)) translateY(-8px) scale(1.02);
  z-index: 3;
}
.tile:hover .tile__card {
  box-shadow:
    0 3px 6px   rgba(30, 28, 23, .16),
    0 22px 34px -12px rgba(30, 28, 23, .34);
}

/* 4:3 landscape photo area */
.tile img {
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
}

/* ---- loose objects ----
   No polaroid at all: the cut-out sits directly on the graph paper, the way a
   pin would if you'd just put it down. Still a .tile, so dragging, the scroll
   reveal and the click-to-open all keep working untouched. */
.tile--loose .tile__card {
  background: none;
  padding: 0;
  border: 0;
  box-shadow: none;
}
/* `.tile:hover .tile__card` (0,3,0) and `.can-drag .tile.is-dragging
   .tile__card` (0,4,0) both put the polaroid's rectangular box-shadow back,
   and both out-specify the reset above (0,2,0) — which is what flashed a gray
   box around a loose pin the moment you touched it. Match their specificity
   to keep it off; the object's own drop-shadow does the lifting instead. */
.tile--loose:hover .tile__card,
.can-drag .tile--loose.is-dragging .tile__card {
  box-shadow: none;
}
.tile--loose img {
  aspect-ratio: 1 / 1;
  object-fit: contain;
  padding: 0;
  /* a card casts a rectangular shadow; an object casts its own silhouette,
     which is what drop-shadow (unlike box-shadow) follows */
  filter:
    drop-shadow(0 1px 1px rgba(30, 28, 23, .22))
    drop-shadow(0 7px 9px rgba(30, 28, 23, .16));
  transition: filter .3s ease;
}
.tile--loose:not(.tile--static):hover img {
  filter:
    drop-shadow(0 2px 2px rgba(30, 28, 23, .26))
    drop-shadow(0 13px 17px rgba(30, 28, 23, .24));
}
/* held: thrown further off the paper, so the shadow drops further too */
.can-drag .tile--loose.is-dragging img {
  filter:
    drop-shadow(0 3px 3px rgba(30, 28, 23, .28))
    drop-shadow(0 22px 26px rgba(30, 28, 23, .3));
}
/* picked up a little more eagerly than a card — it's smaller and lighter */
.tile--loose:not(.tile--static):hover {
  transform: rotate(calc(var(--tilt, 0deg) * .3)) translateY(-5px) scale(1.07);
}

/* Nothing is written on a loose pin, so the name arrives on hover instead —
   as a lime chip rather than the handwritten caption the polaroids get. The
   pins are objects dropped on the board, not photos, so a label that reads
   like a game UI element suits them better than one that reads like ink. */
/* The pin rotates on hover but the label must not, so the caption carries the
   exact inverse of its parent's rotation in BOTH states, on the same .3s ease
   the tile uses. Matching the timing is the point: cancel only the hover value
   and the label would visibly swing while the tile turns under it. */
.tile--loose .tile__cap {
  left: -20%;
  right: -20%;
  bottom: -1.5rem;
  justify-content: center;
  align-items: center;
  transform: rotate(calc(var(--tilt, 0deg) * -1));
  transition: transform .3s ease;
}
.tile--loose:hover .tile__cap {
  /* .tile--loose:hover sits at --tilt * .3 (see the hover rule above) */
  transform: rotate(calc(var(--tilt, 0deg) * -.3));
}

/* The chip itself. Two elements wear it: the in-tile caption (touch screens,
   where it just sits under the pin) and .board__tag (the cursor-following
   label on a real pointer). Same look, different carrier. */
.tile--loose .tile__cap strong,
.board__tag {
  font-family: var(--round);   /* the UX face, not the handwriting */
  font-weight: 700;
  /* scales with the board, because the pins do. A fixed-px chip is what made
     it read as oversized at 640px and undersized at 1440px. */
  font-size: clamp(.66rem, 1.05vw, .82rem);
  letter-spacing: .085em;
  text-transform: uppercase;
  line-height: 1;
  color: var(--ink);           /* 12.9:1 on lime */
  white-space: nowrap;

  /* The hard offset shadow is painted as a second background layer rather than
     with box-shadow, so it lives INSIDE the border box — clip-path clips an
     element's box-shadow too, which would have sliced the shadow off the right
     edge at the end of the wipe. The extra right/bottom padding is the gap the
     offset layer shows through.
     ORDER MATTERS: the first layer listed paints on TOP, so the lime face has
     to come first. Ink first covers the lime and leaves a 2px lime edge. */
  --chip-off: 2px;
  padding: .5em calc(.72em + var(--chip-off)) calc(.45em + var(--chip-off)) .72em;
  background:
    linear-gradient(var(--lime), var(--lime)) no-repeat left  top    / calc(100% - var(--chip-off)) calc(100% - var(--chip-off)),
    linear-gradient(var(--ink),  var(--ink))  no-repeat right bottom / calc(100% - var(--chip-off)) calc(100% - var(--chip-off));
}
.tile--loose .tile__cap strong {
  overflow: visible;
  transform: none;             /* cancel the caption's handwritten tilt */
  /* Still hover-gated here. This path only runs on a coarse pointer between
     641px and the mobile stack — a touch tablet — where leaving every chip
     permanently on would pile them into the overlapping pins below. */
  clip-path: inset(0 100% 0 0);
  transition: clip-path .28s cubic-bezier(.22, .9, .3, 1);
}
.tile--loose:hover .tile__cap strong { clip-path: inset(0); }

/* The arrow is an affordance, not decoration — it says "this opens". Pins only:
   the cards keep their handwritten caption clean. It's set in --round because
   Quicksand's Latin subset doesn't ship U+2192, so the glyph comes from a
   fallback and should fall back from a sans rather than from the handwriting.
   The chip wipes in as one piece, so the arrow needs no entrance of its own. */
.board__tag::after {
  content: "\2192";
  font-family: var(--round);
  letter-spacing: 0;
  margin-left: .5em;
}

/* The label rides the pointer with its bottom-left corner on the cursor:
   translate to the point, then back up by its own height. Positioned with a
   transform rather than left/top so following the mouse never triggers layout. */
.board__tag {
  position: absolute;
  left: 0;
  top: 0;
  z-index: 6;                  /* above a hovered tile, which sits at 3 */
  pointer-events: none;
  transform: translate3d(var(--tag-x, 0px), var(--tag-y, 0px), 0) translateY(-100%);
  /* unrolls left to right. no fade — a wipe alone reads as a label being
     pulled out; crossfading it at the same time just makes it look blurry. */
  clip-path: inset(0 100% 0 0);
  /* The closing transition, since it's the one declared on the state being
     moved TO. Deliberately quicker and sharper than the open: moving between
     two pins costs a close plus an open, and at .28s each that's over half a
     second of label before you can read the new name. CLOSE_MS in script.js
     is paired with this — change one, change the other. */
  transition: clip-path .15s ease-in;
}
.board__tag.is-on {
  clip-path: inset(0);
  transition: clip-path .28s cubic-bezier(.22, .9, .3, 1);
}

/* On a real pointer the tag does the labelling, so the in-tile caption steps
   aside. Below 641px it takes over again as a static caption under the pin. */
@media (hover: hover) and (pointer: fine) and (min-width: 641px) {
  .tile--loose .tile__cap { display: none; }
}

/* a tile with no detail page behind it: still draggable, but nothing to click,
   so the cursor stays an arrow until dragging is available */
.tile--static { cursor: default; }
.can-drag .tile--static { cursor: grab; }
.can-drag .tile--static.is-dragging { cursor: grabbing; }

/* written along the bottom lip of the polaroid, always visible */
.tile__cap {
  position: absolute;
  left: 14px;
  right: 14px;
  bottom: 9px;
  display: flex;
  align-items: baseline;
  justify-content: center;
  pointer-events: none;
}
/* the title still truncates rather than overflowing the lip when a card is
   narrow — a centerd flex item needs this to be allowed to shrink */
.tile__cap strong { min-width: 0; }
.tile__cap strong {
  font-family: var(--hand);
  font-weight: 600;
  font-size: clamp(1rem, 1.5vw, 1.35rem);
  line-height: 1.1;
  color: var(--ink);
  /* a hand doesn't write dead level */
  transform: rotate(-.7deg);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* the credit lives in the detail overlay now — kept for screen readers */
.tile__credit {
  position: absolute;
  width: 1px; height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}



/* Sizes come from the layout mockup. The polaroid's own geometry pins the
   scale down exactly: its height is 0.75(w - 24) + 59, where the 24 and 59
   are fixed pixel padding that doesn't scale. Only one card width satisfies
   the mockup's measured boxes — 272 CSS px — so the mockup is 1:1 and its
   board is ~670px wide. That puts a card at 40.5% and a pin at 14-16.5%,
   about 1.45x their old size.
   Pins keep their individual sizes rather than sharing one: they're
   different objects, and a row of identical squares reads as a grid. */
.tile { width: 40.5%; }
.tile--loose { width: 15%; }

/* Positions are the mockup's, as a fraction of the board: cards alternating
   down the left and right with the pins piled opposite them. Cards are
   verified never to touch anything from 641px up. Pins are ALLOWED to overlap
   each other — that's what makes them read as a pile and not a grid — so
   they're ordered here by depth: later in the DOM sits on top. */
.tile--1  { left: 10.0%; top:  7.0%; }                 /* Tiny Travel Journal */
.tile--2  { left: 61.8%; top:  6.4%; width: 16.4%; }   /* pin — party shiba   */
.tile--4  { left: 75.1%; top: 16.0%; width: 16.4%; }   /* pin — hot dog       */
/* sits just below the party shiba, clear of it rather than overlapping. The
   dird is a much narrower drawing than the dog pins (0.55 vs their 0.72-0.87),
   so at 15% it stands about as tall as they do while reading far slimmer —
   that's the pin's actual shape, not a sizing mistake.
   Only the desktop board uses this; below 640px the stack ignores top/left. */
.tile--11 { left: 58.5%; top: 19.0%; width: 15.0%; }   /* pin — dird          */
.tile--3  { left: 54.9%; top: 36.3%; }                 /* London Postcards    */
/* Second pile, tighter than the party/hot dog pair top right — an +11.5% x /
   +8.5% y step rather than +13.3/+9.6, so these two properly overlap instead
   of merely touching corners. Both larger than the dog pins.
   35% is as high as the pair goes: at 641px the Tiny Travel Journal card above
   is proportionally taller (its 46px lip doesn't scale with the board), and
   its bottom edge closes to ~23px of the pin — about the room the hover lift
   needs. Anything higher and they touch. */
.tile--6  { left: 10.0%; top: 35.0%; width: 18.5%; }   /* pin — Totally Legit */
.tile--10 { left: 21.5%; top: 43.5%; width: 19.0%; }   /* Experiments         */
.tile--8  { left:  8.8%; top: 71.0%; }                 /* Ramojamo            */
.tile--7  { left: 74.1%; top: 72.0%; width: 14.6%; }   /* pin — ramen pug     */
.tile--5  { left: 60.4%; top: 76.7%; width: 14.6%; }   /* pin — bull terrier  */
.tile--9  { left: 72.9%; top: 84.9%; width: 13.9%; }   /* pin — watermelon    */

/* each thing sits at its own angle — cards barely off square, loose pins
   thrown down more carelessly */
.tile--1  { --tilt: -2.2deg; }
.tile--2  { --tilt:  6deg;   }
.tile--3  { --tilt:  1.8deg; }
.tile--4  { --tilt: -8deg;   }
.tile--5  { --tilt:  5deg;   }
.tile--6  { --tilt:  5.5deg; }
.tile--7  { --tilt: -6deg;   }
.tile--8  { --tilt: -2.8deg; }
.tile--9  { --tilt:  9deg;   }
.tile--10 { --tilt: -7.5deg; }
.tile--11 { --tilt: -4.5deg; }

/* the pair overlaps at a corner, and the clickable one should be the one on
   top — DOM order alone would put Experiments (which opens nothing) there */
.tile--6 { z-index: 1; }


/* ---------------- About ---------------- */

.about { padding: clamp(3.5rem, 7vw, 7rem) 0 clamp(3.5rem, 8vw, 6.5rem); }

.about__inner {
  display: grid;
  grid-template-columns: 420px 1fr;
  gap: clamp(2.5rem, 7vw, 6rem);
  align-items: start;
}

.about__media { position: relative; }

.portrait {
  background: #fff;
  aspect-ratio: 420 / 563;
  overflow: hidden;
}
.portrait img { width: 100%; height: 100%; object-fit: cover; }

.deco--zig2 { width: 36px; top: -64px; left: 14%; }
/* straddles the bottom edge of the portrait, as in the mockup */
.deco--blob { width: 68px; top: 91%; left: 70%; }
/* turns slowly, like the asterisk in the hero. Gated on :not([data-reveal]) so
   it can't fight the pop-in transform, and set to a different period and
   direction so the two never fall into step. */
.deco--blob:not([data-reveal]) {
  animation: deco-spin 29s linear infinite reverse;
}

.about__copy { padding-top: 1.4rem; }

.about__title {
  font-family: var(--display);
  font-weight: 500;
  font-size: clamp(1.65rem, 2.7vw, 2.2rem);
  letter-spacing: -.005em;
  margin: 0 0 2.6rem;
}
.about__copy p {
  color: var(--body);
  max-width: 46ch;
  margin: 0 0 1.15rem;
}

/* The only outbound link in the body copy: purple at rest, lime on hover, no
   underline. Purple carries the resting state because it's 3.12:1 on cream
   against lime's 1.45:1 — lime simply hasn't the contrast to be found in a
   paragraph. It still reads as an accent on hover, once you've already
   located the link.
   3.12:1 is short of the 4.5:1 WCAG AA wants for text this size; #6D7312
   (4.93:1) is a darkened lime that passes, if that ever matters more than the
   color. */
.about__copy a {
  color: var(--lilac);
  text-decoration: none;
  transition: color .18s ease;
}
.about__copy a:hover { color: var(--lime-d); }

/* ---------------- Contact ---------------- */

.contact {
  position: relative;
  /* first frame of the shadow sequence; script.js cycles the rest once they've
     loaded, so this is what shows if the animation never starts */
  background: #A2947A url("assets/shadow/shadow_01.jpg") center / cover no-repeat;
  padding: clamp(3.5rem, 7vw, 5.5rem) 0 clamp(4rem, 8vw, 6.5rem);
}

/* the animated frames, painted over the CSS still */
.contact__canvas {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  pointer-events: none;
  opacity: 0;
  transition: opacity .5s ease;
}
.contact__canvas.is-on { opacity: 1; }

/* The photo behind this section is a mid-tone: measured across all 12 frames,
   the brightest patch under the copy leaves the text at only 2:1, where 4.5:1
   is the readable threshold. No amount of text-shadow or extra font weight
   changes that ratio — the background itself has to come down, so this scrim
   darkens it to reach 4.5:1. Lower the alpha to let more of the photo through;
   .35 gives 3.9:1, .28 gives 3.4:1.
   ::after rather than ::before so it paints above the canvas (same stacking
   level, later in tree order); .contact__inner sits above it at z-index 1. */
.contact::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  background: rgba(30, 28, 23, .42);
  pointer-events: none;
}

.contact__inner {
  position: relative;   /* keeps the form above the canvas */
  z-index: 1;
  display: flex;
  justify-content: center;
}

.contact__form {
  width: min(100%, 620px);
  display: flex;
  flex-direction: column;
  gap: 1.05rem;
}

.contact__title {
  font-family: var(--display);
  font-weight: 500;
  font-size: clamp(1.8rem, 2.9vw, 2.35rem);
  letter-spacing: -.005em;
  color: #fff;
  margin: 0 0 .5rem;
  text-shadow: 0 1px 14px rgba(40, 38, 28, .35);
}

/* No font-size here on purpose: this is reading copy, so it inherits the body
   size exactly like the About paragraphs do. It used to be pinned at .95rem,
   which is root-relative — so it stayed at 15.2px while the body moved to
   17px and ended up visibly smaller than the same kind of text elsewhere. */
.contact__intro {
  /* The form is a flex column with a 1.05rem gap, and in flex layout margins
     ADD to the gap rather than collapsing into it. So the real spacing is
     this margin + 1.05rem: the title sits 1.15rem above (a tight pair, since
     heading and intro are one block of copy) and the first field now sits
     2.55rem below, which separates the reading from the doing. */
  margin: -.4rem 0 1.5rem;
  max-width: 46ch;
  color: #F2EEE4;
  /* 500 rather than the body's 400: more ink per letter over a photograph.
     Fraunces is variable and 500 is already in the font request, so this
     costs nothing to load. */
  font-weight: 500;
  /* a tight shadow to separate the letterforms from the grain, plus a wider
     soft one for the halo. The scrim does the real work; these just clean up
     the edges. */
  text-shadow:
    0 1px 2px  rgba(24, 22, 16, .55),
    0 1px 12px rgba(24, 22, 16, .45);
}

.contact__form input,
.contact__form textarea {
  font: inherit;
  font-family: var(--round);
  font-size: 1.02rem;
  color: var(--ink);
  background: #fff;
  /* border stays in the box model so nothing shifts — it's just invisible
     until you hover or focus the field */
  border: var(--line) solid transparent;
  /* explicit 0 rather than removing the property — Safari rounds form controls
     by default, so it has to be overridden, not just left unset */
  border-radius: 0;
  padding: .72rem .85rem;
  transition: border-color .18s ease;
}
.contact__form textarea { resize: vertical; min-height: 200px; }
.contact__form input::placeholder,
.contact__form textarea::placeholder { color: #6C6A63; opacity: 1; }
.contact__form input:hover,
.contact__form textarea:hover,
.contact__form input:focus,
.contact__form textarea:focus { border-color: var(--lilac); }

/* keyboard focus gets a ring as well, so it reads differently from hover.
   Lilac rather than the global olive — olive disappears against the photo. */
.contact__form input:focus-visible,
.contact__form textarea:focus-visible {
  outline: 2px solid var(--lilac);
  outline-offset: 2px;
}

/* invalid wins over hover and focus, hence the extra selectors */
.contact__form .is-invalid,
.contact__form .is-invalid:hover,
.contact__form .is-invalid:focus { border-color: #C0483F; }

.contact__form .btn { align-self: flex-start; margin-top: .4rem; }

.form__status {
  margin: 0;
  min-height: 1.2em;
  font-size: .92rem;
  color: #fff;
  text-shadow: 0 1px 10px rgba(40, 38, 28, .6);
}
.form__status.is-error { color: #FFD9D5; }
/* a lightened --lime, so success reads in the same family as the CTA */
.form__status.is-ok { color: #E6EF9B; }

/* The honeypot. Clipped rather than display:none, because some bots skip
   fields they can tell are hidden. Absolutely positioned so it isn't a flex
   item and doesn't eat one of the form's gaps.
   Deliberately not .sr-only — that pattern means "hidden but announced to
   screen readers", which is the exact opposite of what a honeypot wants. */
.contact__form .hp {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}

/* ---------------- Footer ---------------- */

.footer {
  position: relative;
  text-align: center;
  padding: clamp(3.5rem, 8vw, 6rem) 1.25rem clamp(2.5rem, 5vw, 4rem);
}
.ring {
  position: relative;
  width: 240px;
  margin: 0 auto 1.4rem;
}
.ring__svg { width: 100%; height: auto; overflow: visible; }
.ring__svg text {
  font-family: var(--round);
  font-size: 15px;
  letter-spacing: .01em;
  fill: var(--ink);
}
/* the dots are <image> inside the SVG now, so script.js can walk them along
   the same path as the text */
.ring__svg { display: block; }

/* the vertical logo sits in the middle of the orbiting text */
.ring__logo {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  height: 148px;
  width: auto;
}
.footer__note   { margin: 0 0 .5rem; font-size: .95rem; }
/* The ring's own bottom margin is only 1.4rem, which sat the copyright line
   right under the animation. This pushes it clear, and scales so the gap
   doesn't collapse on a phone. */
.footer__legal {
  margin: clamp(2.25rem, 5vw, 4rem) 0 0;
  font-size: .82rem;
  color: #8A8676;
}

/* ---------------- Full-screen menu (mobile + tablet) ----------------
   Desktop never sees this. The header sits above it at z-60, so the logo
   stays put and the hamburger becomes the close button. */

.menu { display: none; }

@media (max-width: 860px) {
  .menu {
    display: block;
    position: fixed;
    inset: 0;
    z-index: 50;
    overflow-y: auto;
    background: var(--cream);
    opacity: 0;
    visibility: hidden;
    pointer-events: none;
    transition: opacity .4s ease, visibility .4s;
  }
  .menu.is-open {
    opacity: 1;
    visibility: visible;
    pointer-events: auto;
  }

  /* the same hand-ruled paper as the projects board, dialled back */
  .menu::before {
    content: "";
    position: absolute;
    inset: 0;
    background: url("assets/graph-paper.svg") center / auto 130% no-repeat;
    opacity: .45;
    pointer-events: none;
  }

  .menu__inner {
    position: relative;
    z-index: 1;
    min-height: 100%;
    display: flex;
    flex-direction: column;
    justify-content: center;
    padding: 7.5rem 0 3rem;
  }

  .menu__nav {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    /* scales on vw like the type above it does, so the rhythm holds from a
       small phone up to the 860px breakpoint. Sized to still fit four links
       and the footer inside an iPhone SE without scrolling. */
    gap: clamp(1.1rem, 5vw, 2.2rem);
  }

  .menu__link {
    position: relative;
    font-family: var(--round);   /* nav is UX, so it stays sans */
    font-weight: 500;
    font-size: clamp(2.4rem, 12vw, 4.25rem);
    line-height: 1.14;
    letter-spacing: -.02em;
    text-decoration: none;
    color: var(--ink);
    padding-bottom: 6px;
    opacity: 0;
    transform: translateY(28px);
    transition: opacity .5s ease, transform .6s cubic-bezier(.22, .61, .36, 1);
  }
  .menu.is-open .menu__link { opacity: 1; transform: none; }
  .menu.is-open .menu__link:nth-child(1) { transition-delay: .10s; }
  .menu.is-open .menu__link:nth-child(2) { transition-delay: .17s; }
  .menu.is-open .menu__link:nth-child(3) { transition-delay: .24s; }

  /* the same drawn stroke, heavier for the big type */
  .menu__link::after {
    content: "";
    position: absolute;
    left: -4px;
    right: -6px;
    bottom: 0;
    /* the drawn stroke fills far more of its box than the synthetic one it
       replaced, so this is well below the 16px that used to sit here */
    height: 10px;
    background-color: var(--ink);
    -webkit-mask: url("assets/underline.svg") no-repeat center / 100% 100%;
            mask: url("assets/underline.svg") no-repeat center / 100% 100%;
    clip-path: inset(0 100% 0 0);
    transition:
      clip-path .45s cubic-bezier(.36, .8, .3, 1),
      background-color .25s ease .45s;
    pointer-events: none;
  }
  .menu__link:hover::after,
  .menu__link:focus-visible::after,
  .menu__link.is-active::after { clip-path: inset(0 0 0 0); }
  .menu__link:hover::after,
  .menu__link:focus-visible::after {
    background-color: var(--lime-d);
    transition:
      clip-path .45s cubic-bezier(.36, .8, .3, 1),
      background-color .1s ease 0s;
  }



  .menu__deco {
    opacity: 0;
    transform: scale(.75) rotate(-12deg);
    transition: opacity .5s ease, transform .7s cubic-bezier(.34, 1.56, .64, 1);
  }
  .menu.is-open .menu__deco { opacity: 1; transform: none; }
  .menu.is-open .menu__deco--a { transition-delay: .30s; }
  .menu.is-open .menu__deco--b { transition-delay: .40s; }
  .menu.is-open .menu__deco--c { transition-delay: .50s; }
  .menu.is-open .menu__deco--d { transition-delay: .58s; }

  /* These sizes were set for the old inline doodles; the assets have different
     proportions, so the tall zigzag in particular rendered ~300px and landed
     on top of the links. The first three now sit in the right-hand margin,
     clear of the text column, and behind it; the zigzag sits low and left
     (see below), well below where the links end. */
  .menu__deco   { z-index: 0; }
  /* 16%/15% is as far down and in as the star goes before it runs into the
     squiggle below it on a short phone (568px tall), where the percentage
     positions compress hardest */
  .menu__deco--a { width: 72px; top: 16%;    right: 15%; }  /* star      */
  .menu__deco--b { width: 98px; top: 33%;    right: 2%;  }  /* squiggle  */
  /* the layer the decorations live in — clips anything hanging off the edge so
     it can't extend .menu's scrollable area */
  .menu__decos {
    position: absolute;
    inset: 0;
    overflow: hidden;
    pointer-events: none;
    z-index: 0;
  }

  .menu__deco--c { width: 60px; bottom: 11%; right: 15%; }  /* spiky dot */
  /* The dot idles with a slow spin the whole time the menu is open. Same
     trick as the hero decorations (deco-spin, up top), but gated on .is-open
     instead of [data-reveal] — closing the panel removes the rule, so the
     animation stops rather than running behind a hidden overlay.
     The 1.25s delay clears the entrance first: .50s transition-delay + .7s
     duration = 1.2s. It picks up from transform:none, which is exactly where
     the entrance lands, so there's no jump into the loop. */
  .menu.is-open .menu__deco--c { animation: deco-spin 8s linear 1.25s infinite; }

  /* The zigzag is turned on the diagonal and cropped by the bottom edge.
     It's 46 x 131, so rotating 45deg gives a ~125px square visual footprint;
     the visible height works out to 128px - (the bottom offset), so -38px
     leaves about 70% of it showing. The 23px in `left` is half the element's
     own width, so the ROTATED shape centers on 38% rather than the layout
     box; the extra 10px is a nudge further left, by eye. */
  .menu__deco--d {
    width: 46px;
    right: auto;
    left: calc(38% - 23px - 10px);
    bottom: -38px;
  }
  /* the entrance animates from -12deg, so the closed state is 45 - 12 */
  .menu__deco--d           { transform: scale(.75) rotate(33deg); }
  .menu.is-open .menu__deco--d { transform: rotate(45deg); }

  /* The overlay carries its own brand + close button, so the header steps
     aside. It has to: locking the page with overflow:hidden stops the viewport
     scrolling, which kills position:sticky — the bar would drop back to the top
     of the document and sit off-screen whenever the menu opened mid-page. */
  .menu-open .nav { opacity: 0; pointer-events: none; }

  .menu__top {
    position: absolute;
    top: 1.5rem;
    left: 50%;
    transform: translateX(-50%);
    z-index: 2;
    display: flex;
    align-items: center;
    justify-content: space-between;
    opacity: 0;
    transition: opacity .3s ease .1s;
  }
  .menu.is-open .menu__top { opacity: 1; }

  .menu__brand { height: 44px; width: auto; }

  .menu__close {
    position: relative;
    display: grid;
    place-items: center;
    width: 42px;
    height: 38px;
    background: var(--lime);
    border: none;
    border-radius: 4px;
    cursor: pointer;
    transition: background .18s ease;
  }
  .menu__close:hover { background: var(--lime-d); }
  .menu__close span {
    position: absolute;
    width: 19px;
    height: 2px;
    border-radius: 2px;
    background: var(--ink);
  }
  .menu__close span:first-child { transform: rotate(45deg); }
  .menu__close span:last-child  { transform: rotate(-45deg); }
}

/* stop the page behind the overlay from scrolling */
.menu-open, .menu-open body { overflow: hidden; }

/* ---------------- Project detail overlay ---------------- */

.modal {
  position: fixed;
  inset: 0;
  z-index: 80;              /* above the header (60) and the menu (50) */
  background: var(--cream);
  opacity: 0;
  transition: opacity .35s ease;
}
.modal.is-open { opacity: 1; }
.modal[hidden] { display: none; }

.modal__scroll {
  height: 100%;
  overflow-y: auto;
  overscroll-behavior: contain;   /* don't chain scroll to the page behind */
  padding: 6rem 0 4rem;
  -webkit-overflow-scrolling: touch;
}

.modal__close {
  position: fixed;
  top: 1.6rem;
  right: max(var(--gutter), 1.5rem);
  z-index: 2;
  display: grid;
  place-items: center;
  width: 42px;
  height: 38px;
  background: var(--lime);
  border: none;
  border-radius: 4px;
  cursor: pointer;
  transition: background .18s ease;
}
.modal__close:hover { background: var(--lime-d); }
.modal__close span {
  position: absolute;
  width: 19px;
  height: 2px;
  border-radius: 2px;
  background: var(--ink);
}
.modal__close span:first-child { transform: rotate(45deg); }
.modal__close span:last-child  { transform: rotate(-45deg); }

.modal__grid {
  display: grid;
  grid-template-columns: 1.15fr .85fr;
  gap: clamp(2rem, 5vw, 4.5rem);
  align-items: start;
}
/* The copy comes FIRST in the DOM so that stacked — on mobile, and for anyone
   reading linearly — you get the project's name and description before seven
   image descriptions. Side by side the images still belong on the left, so
   both cells are placed explicitly.
   The row matters as much as the column: with only grid-column set, the copy
   would take row 1 and the media, coming later but pointing at an earlier
   column, would drop to row 2 rather than back up. */
.modal__media { grid-column: 1; grid-row: 1; }
.modal__copy  { grid-column: 2; grid-row: 1; }

/* images stack and scroll; the copy stays put beside them */
.modal__media {
  display: grid;
  gap: clamp(1rem, 2.5vw, 1.8rem);
  /* Lines the first image's top edge up with the first line of body copy
     opposite, leaving the title above it in the margin. The top of an image is
     a hard edge while the top of text is not, so this is the sum of everything
     stacked above that first line. Written as a sum rather than one number
     because the title is a clamp, so the distance shrinks on narrower screens
     and a fixed value would drift:
       .40rem  padding on .modal__copy
       title   its clamped size x 1.12 line-height
       1.40rem title margin-bottom
       .58rem  half-leading + ascent-to-cap of the paragraph's own first line
       -9px    optical trim — the arithmetic lands a touch low by eye
     The eyebrow used to sit in here too and contributed another 1.96rem
     (1.36 line box + .60 margin); it came out when the eyebrows did.
     For the shorter alternative: 1.98rem aligns with the title instead. */
  padding-top: calc(2.38rem - 9px + clamp(1.9rem, 3.2vw, 2.8rem) * 1.12);
}

/* One width, and no frame of its own: every image fills the column and sets
   its own height from its own proportions. Nothing is cropped, so a label at
   the edge of a map or a spread survives.
   There are deliberately no --wide/--tall/--object variants any more — a fixed
   aspect-ratio here means object-fit has to crop or letterbox whatever doesn't
   match it. The `background` only covers the moment before an image paints. */
.shot { background: #fff; }
.shot img { display: block; width: 100%; height: auto; }

.modal__copy {
  position: sticky;
  top: 6rem;
  padding-top: .4rem;
}
.modal__title {
  font-family: var(--display);
  font-weight: 500;
  font-size: clamp(1.9rem, 3.2vw, 2.8rem);
  line-height: 1.12;
  letter-spacing: -.01em;
  margin: 0 0 1.4rem;
}
.modal__copy p { color: var(--body); max-width: 44ch; margin: 0 0 1.1rem; }

/* The disciplines line that replaced the Context/Output/Role list. Same rule
   above it and same uppercase --round as the old <dt>s, so the block still
   closes the copy the way the fact list did — it's just one line now, and it
   carries what the work actually was rather than three invented fields. */
/* Scoped to .modal__copy for specificity, not for style. The tags line is a
   <p>, so `.modal__copy p` above is (0,1,1) and a bare `.modal__tags` is
   (0,1,0) — the paragraph rule wins and quietly reimposes its own max-width
   and margin. (0,2,0) here takes it back. Don't unscope this. */
.modal__copy .modal__tags {
  margin: 2rem 0 0;
  padding-top: 1.4rem;
  border-top: var(--line) solid var(--lilac);
  /* The rule runs the whole column at every width, so it lines up with the
     edge of the images rather than with the text measure. This is why the
     selector has to out-specify .modal__copy p — that rule's 44ch would
     otherwise pull it in short. */
  max-width: none;
  font-family: var(--round);
  font-size: .78rem;
  letter-spacing: .1em;
  text-transform: uppercase;
  line-height: 1.7;
  color: var(--body);
}

/* prev / next */
.modal__nav {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1rem;
  margin-top: clamp(3rem, 7vw, 6rem);
  padding-top: 2rem;
  border-top: var(--line) solid var(--lilac);
}
/* No box and no icon — these read as links, with the same hand-drawn pencil
   underline the nav uses. Flush with the page gutter on both sides. */
.modal__step {
  display: grid;
  gap: .4rem;
  justify-items: start;
  padding: 0;
  background: none;
  border: none;
  text-align: left;
  cursor: pointer;
}
.modal__step--next { justify-items: end; text-align: right; }

.modal__step-label {
  font-family: var(--round);
  font-size: .74rem;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--body);
}
.modal__step-title {
  position: relative;
  display: inline-block;
  font-family: var(--display);
  font-weight: 500;
  font-size: clamp(1.55rem, 2.7vw, 2.15rem);
  line-height: 1.14;
  letter-spacing: -.005em;
  color: var(--ink);
  padding-bottom: 9px;
}
.modal__step-title::after {
  content: "";
  position: absolute;
  left: -4px;
  right: -6px;
  bottom: 0;
  height: 10px;
  /* only ever visible on hover or focus — the clip-path keeps it hidden at
     rest — so this is the hover color, matching the nav underlines */
  background-color: var(--lime-d);
  -webkit-mask: url("assets/underline.svg") no-repeat center / 100% 100%;
          mask: url("assets/underline.svg") no-repeat center / 100% 100%;
  clip-path: inset(0 100% 0 0);
  transition: clip-path .45s cubic-bezier(.36, .8, .3, 1);
  pointer-events: none;
}
.modal__step:hover .modal__step-title::after,
.modal__step:focus-visible .modal__step-title::after { clip-path: inset(0 0 0 0); }

/* the page behind an open overlay must not scroll */
.modal-open, .modal-open body { overflow: hidden; }

@media (max-width: 860px) {
  .modal__scroll { padding-top: 5rem; }
  .modal__grid { grid-template-columns: 1fr; gap: 2.2rem; }
  .modal__copy { position: static; }
  /* one column, so drop the explicit placement and let them stack in DOM
     order: copy, then images. The prev/next nav is a sibling after the grid,
     so it stays last on its own. */
  .modal__media, .modal__copy { grid-column: auto; grid-row: auto; }
  /* one column now, so the images sit above the copy rather than beside it —
     that optical nudge would just be a stray gap at the top of the stack */
  .modal__media { padding-top: 0; }
  /* stays a two-up row at every width — the pair reads as prev/next, and
     stacking it was what made the alignment feel off. Below 560px the titles
     get small enough to share the line. */
  .modal__nav { gap: 1.2rem; align-items: start; }
}

@media (max-width: 560px) {
  .modal__step-label { font-size: .66rem; letter-spacing: .12em; }
  .modal__step-title { font-size: 1.08rem; line-height: 1.25; padding-bottom: 7px; }
  .modal__step-title::after { height: 7px; }
}

/* ---------------- Reveal on scroll ----------------
   Elements are tagged by script.js, so the markup stays clean.
   --d is the per-element stagger delay. */

[data-reveal] {
  opacity: 0;
  transform: translateY(22px);
  transition:
    opacity   .85s cubic-bezier(.22, .61, .36, 1) var(--d, 0ms),
    transform .85s cubic-bezier(.22, .61, .36, 1) var(--d, 0ms);
}

/* images get a touch more travel and settle out of a slight zoom */
[data-reveal="img"] {
  transform: translateY(30px) scale(.965);
  transition-duration: 1s, 1s;
}

/* the graph paper just fades — moving it would drag the whole board */
[data-reveal="fade"] { transform: none; }

/* header items: a shorter, quicker drop so the chrome settles before the hero */
[data-reveal="sm"] {
  transform: translateY(-10px);
  transition-duration: .7s, .7s;
}

[data-reveal].is-in { opacity: 1; transform: none; }
/* tiles settle at their own angle rather than square */
.tile[data-reveal].is-in { transform: rotate(var(--tilt, 0deg)); }

/* decorations pop in rather than draw — they're flat images now */
[data-reveal="deco"] { transform: scale(.6) rotate(-14deg); }

/* ---------------- Responsive ---------------- */

/* ---- tablet: the vw-based clamps bottom out here, so open the spacing up ---- */
@media (max-width: 1080px) {
  :root { --board-gutter: var(--gutter); }  /* board lines up with the page gutter */

  .hero     { padding: 3.5rem 0 5rem; }
  .projects { padding-bottom: 5.5rem; }
  .about    { padding: 5.25rem 0 5.5rem; }
  .contact  { padding: 5rem 0 6rem; }
  .footer   { padding: 5.5rem var(--gutter) 3.5rem; }

  .hero__title { margin-bottom: 2.6rem; }

  .about__inner { grid-template-columns: 300px 1fr; gap: 3.25rem; }
  .about__copy  { padding-top: .5rem; }
  .about__title { margin-bottom: 2rem; }

  /* These two bleed into the page gutter. A fixed -40px is wider than the
     gutter once it shrinks below 40px (it's 29px at 768), which pushed them
     off the edge. Tie the offset to the gutter instead and they stop 4px
     inside the viewport at every width in this range. */
  .deco--squig { left: calc(4px - var(--gutter)); top: 70px; }
  .deco--zig   { right: calc(4px - var(--gutter)); }
}

@media (max-width: 860px) {
  .nav__toggle { display: flex; }
  .nav__links  { display: none; }   /* replaced by the full-screen menu below */

  .deco--aster { display: none; }

  /* stacked now, so the columns need real vertical separation */
  .about__inner { grid-template-columns: 1fr; gap: 3.5rem; }
  .about__media { max-width: 420px; }
  .about__copy  { padding-top: 0; }
  .deco--blob  { left: auto; right: 4%; }

  .contact__form  { gap: 1.25rem; }
}

@media (max-width: 640px) {
  body { font-size: 15.5px; }

  .hero     { padding: 2.5rem 0 3.5rem; }
  .projects { padding-bottom: 6.5rem; }   /* holds the paper overhang */
  .about    { padding: 6rem 0 4rem; }
  .contact  { padding: 3.5rem 0 4.5rem; }
  .footer   { padding: 4rem var(--gutter) 3rem; }

  /* The free-placed board becomes a stack — but two columns wide, not one.
     Cards take a whole row to themselves; the pins pair up, which is what they
     do on desktop anyway. Eight pins in four rows instead of eight cuts about
     800px off the scroll.
     Auto-placement does the pairing on its own: the pins sit in consecutive
     runs in the DOM (2+11, 4+5, 6+7, 9+10), and a full-width card forces the
     next row. Nothing needs placing by hand.
     Keep the base width rule so tiles aren't full-bleed to the screen edge;
     the row gap carries the captions, always visible without hover. */
  .board {
    aspect-ratio: auto;
    display: grid;
    grid-template-columns: 1fr 1fr;
    column-gap: 4%;
    row-gap: 2.4rem;
  }
  .tile        { grid-column: 1 / -1; }   /* cards span both columns */
  .tile--loose { grid-column: auto; }     /* pins take one each */

  /* The stacked board is ~2000px tall, and `auto 100%` scales the paper to
     that — which blew the cells up to ~65px. Pin the draw height instead so
     the cells stay a sensible size, and tile it down the column. */
  .board__paper {
    /* run the paper past the stack so the cards aren't flush with its edges */
    top: -32px;
    bottom: -88px;
    background-size: auto 1024px;   /* 48 x (1024/1536) = 32px cells */
    background-repeat: repeat-y;
    /* tile from the top, and script.js rounds this element's height to a whole
       number of 32px cells — so the bottom edge lands exactly on a rule and
       the grid closes cleanly instead of stopping mid-cell.
       The `bottom` above stays as the no-JS fallback. */
    background-position: center top;
  }
  .tile {
    position: relative;   /* keeps the cast shadow anchored to the tile */
    /* the board's left/top percentages become *offsets* once the tile is
       relative rather than absolute — they have to be cleared here */
    top: auto;
    left: auto;
    width: 100% !important;
    height: auto !important;
    aspect-ratio: auto;   /* each illustration sets its own height */
    min-height: 160px;    /* so an empty placeholder card still has presence */
  }
  /* narrower than the column, alternating side to side, so the stack keeps
     some of the scatter it has on desktop */
  .tile { width: 84% !important; }
  /* Loose pins stay small down here too, or they stop reading as objects
     dropped between the photos and start looking like more cards. This is a
     percentage of the COLUMN now, not the board — 80% of a 48% column is the
     same ~38% of the board they were before the split. */
  .tile--loose  { width: 80% !important; }

  /* Cards alternate side to side, as before. Each pin leans toward its
     partner, so a pair reads as one pile with a gap around it rather than two
     evenly spaced items in a grid.
     Listed in DOM (= placement) order: 1 | 2 11 | 3 | 4 5 | 6 7 | 8 | 9 10 */
  .tile--1                              { justify-self: start; }
  .tile--3, .tile--8                    { justify-self: end; }
  .tile--2, .tile--4, .tile--6, .tile--9  { justify-self: end; }    /* left of pair  */
  .tile--11, .tile--5, .tile--7, .tile--10 { justify-self: start; } /* right of pair */
  .tile__card { padding: 9px 9px 40px; }

  /* full artwork, no cropping — the tiles aren't a fixed grid down here */
  .tile img { height: auto; }
  /* ...except the pins, which keep their square frame and stay contained */
  .tile--loose img { aspect-ratio: 1 / 1; }

  /* no hover on a touch screen, so a loose pin would never be labeled */
  /* No hover here, so the chip is simply always on — both the wipe and the
     counter-rotation have to be cleared, since the hover state that would
     finish them never fires on a touch screen. The stack doesn't tilt the
     tiles either, so there's nothing to counter. */
  .tile--loose .tile__cap {
    opacity: 1;
    position: static;
    margin-top: .55rem;
    transform: none;
  }
  /* Two pins to a row leaves no slack beside a chip, and the longest names run
     wider than their own tile — "SMALLTALK DOG PIN" is ~144px against a 131px
     pin, so centered it would reach within a pixel of the chip next door.
     Wrapping keeps every chip inside its own column. */
  .tile--loose .tile__cap strong {
    clip-path: none;
    white-space: normal;
    text-align: center;
    line-height: 1.25;   /* line-height 1 collides once there are two lines */
  }
  /* Phones: nothing hangs into the gutter any more, because there isn't one to
     speak of — 24px. Every decoration is repositioned inside the wrap and into
     the two bands the text doesn't use: the hero's 40px of top padding, and
     the empty space to the right of the CTA button.
     They're smaller too. squig is 142x145 — near square, not the thin line the
     name suggests — and at full size it was half the screen wide. */
  .deco--dome  { width: 28px; top: -30px; left: 10%; }
  .deco--star  { width: 32px; top: -38px; left: auto; right: 8%; }
  /* Moved up into the top band to clear the bottom-right corner for the
     zigzag. At 44px it's 45px tall against 40px of padding, so it dips ~5px
     into the headline's first line box — that's inside the leading, above the
     cap height, so it never touches a letter. */
  .deco--squig { width: 44px; top: -40px; left: 26%; right: auto; bottom: auto; }
  /* Bottom right, sitting a little below the CTA: the button is ~178px wide
     and bottom-left, so the whole right of that row is empty. -22px puts the
     zigzag's middle just under the button's baseline. */
  .deco--zig   { display: block; width: 64px; top: auto; bottom: -22px; left: auto; right: 4%; }
  /* .deco--aster stays hidden from the 860px block above */

  /* This one has no positioning rule of its own anywhere, so it lands at its
     static position — the top-left corner of the board. On desktop that's bare
     paper beside the first card, but once the board becomes a stack the card
     starts at that same corner and the rainbow sits on top of it. */
  .deco--arch { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: .001ms !important;
    transition-duration: .001ms !important;
    /* Without this, the looping decorations (deco-spin and friends) don't stop
       under Reduce Motion — they run .001ms iterations forever, which is a
       hidden animation frame every tick. Capping the count actually ends them. */
    animation-iteration-count: 1 !important;
  }
  .reveal { opacity: 1; transform: none; }
}
