/* ---------------------------------------------------------------- base */
@font-face {
  font-family: 'CossetteTexte';
  src: url('fonts/CossetteTexte-Regular.ttf') format('truetype');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'CossetteTexte';
  src: url('fonts/CossetteTexte-Bold.ttf') format('truetype');
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

:root { --scale: 1; }

* { box-sizing: border-box; }

html, body {
  margin: 0;
  height: 100%;
  background: #000;
  overflow: hidden;
  color: #fff;
  font-family: 'CossetteTexte', "Helvetica Neue", Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
}

#fit {
  position: fixed;
  inset: 0;
  overflow: hidden;
}

/* the whole design lives in a fixed 1920x1080 coordinate space, scaled to fit
   so every element sits exactly where it sits in the keyframes */
#stage {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 1920px;
  height: 1080px;
  transform: translate(-50%, -50%) scale(var(--scale));
  transform-origin: center center;
  background: #000;
  user-select: none;
  -webkit-user-select: none;
}

#plate {
  position: absolute;
  inset: 0;
  width: 1920px;
  height: 1080px;
  display: block;
  image-rendering: pixelated;
  background: #000;
}

/* ------------------------------------------------------------- overlay */
#ui { position: absolute; inset: 0; }
#ui.hidden { visibility: hidden; }

#ui img { display: block; pointer-events: none; }

.nav, .icon, .camp {
  position: absolute;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
}

.nav { line-height: 0; }
.nav:hover img, .camp:hover img { animation: jitter .18s steps(2) infinite; }

@keyframes jitter {
  0%   { transform: translate(0, 0); }
  50%  { transform: translate(2px, -2px); }
  100% { transform: translate(-1px, 1px); }
}

.icon { touch-action: none; cursor: grab; }
.icon.grabbed { opacity: 0; }
#stage.dragging, #stage.dragging * { cursor: grabbing; }

#hint {
  position: absolute;
  left: 0;
  right: 0;
  top: 270px;
  text-align: center;
  font-size: 27px;
  letter-spacing: .5px;
  opacity: 0;
  transition: opacity .06s steps(1);
  pointer-events: none;
}
#hint.on { opacity: 1; }

/* Invisible geometric hit targets. Feedback comes from the dragged icon and
   the hands themselves; rectangular outlines fight the cutout art direction. */
.drop {
  position: absolute;
  pointer-events: none;
}

#drop-l { left: 0;     top: 210px; width: 900px;  height: 560px; }
#drop-r { left: 1020px; top: 210px; width: 900px; height: 560px; }

#ghost {
  position: absolute;
  left: 0; top: 0;
  pointer-events: none;
  display: none;
  z-index: 40;
}
#ghost.on { display: block; }

#campaigns { position: absolute; inset: 0; pointer-events: none; }
#campaigns .camp { pointer-events: auto; }

.campaign-fly-title {
  position: absolute;
  display: block;
  z-index: 30;
  pointer-events: none;
  transform-origin: center center;
  will-change: transform, filter;
}

/* ---------------------------------------------------------------- page */
#page {
  position: absolute;
  inset: 0;
  display: none;
  background: #000;
}
#page.on { display: block; }
#page-img {
  position: absolute;
  inset: 0;
  width: 1920px;
  height: 1080px;
  image-rendering: pixelated;
}

/* Back is a hand reaching in from the edge the landing is behind — the arm runs
   off the frame, so it sits flush against that edge rather than inset. */
#back {
  position: absolute;
  background: none;
  border: 0;
  padding: 0;
  line-height: 0;
  cursor: pointer;
}
.back-in {
  display: flex;
  align-items: flex-end;
  gap: 26px;
}
/* Three drawings, not one: the hand held open, its fingers halfway in, and
   drawn in over the palm. They are cut from a real gesture and registered on
   the back of the hand, so hovering plays something photographed rather than a
   rotation invented in CSS. Every frame is stacked on the first, which is the
   one that gives the span its size, and none of them fade — the site holds a
   drawing and then shows the next one, and a hand caught between two poses is a
   hand nobody's arm ever made. */
.hand {
  display: none;
  position: relative;
  line-height: 0;
}
.hand img {
  display: block;
  image-rendering: pixelated;
  position: absolute;
  left: 0;
  top: 0;
  visibility: hidden;
}
.hand img:first-child {
  position: relative;
  visibility: visible;
}
/* `data-beckon` is the frame the control is holding, stepped by app.js on the
   site's own jittered cadence. Both hands on a two-hand page beckon together:
   they are one control, and the pair reads as two people waving you over rather
   than as a crowd. */
[data-beckon="1"] .hand img:first-child,
[data-beckon="2"] .hand img:first-child { visibility: hidden; }
[data-beckon="1"] .hand img:nth-child(2),
[data-beckon="2"] .hand img:nth-child(3) { visibility: visible; }

/* Which hand, and where. The landing is behind one edge of every page — the
   camera travels left to reach Pita, so from Pita home is back out to the left;
   Salva is the mirror; the & is reached by pushing up into the faces, a campaign
   by dropping down to the titles. The hand on that edge is the other person's:
   his hand is out, waiting for the dap that takes you back. */
#page.back-left #back {
  left: 0;
  top: 50%;
  transform: translateY(-50%);
}
#page.back-left .h-salva { display: block; }

#page.back-right #back {
  left: auto;
  right: 0;
  top: 50%;
  transform: translateY(-50%);
}
#page.back-right .h-pita { display: block; }

/* Both of them on the & and on a project, turned to stand on the horizontal
   edge they come in from. */
#page.back-bottom .h-salva-v, #page.back-bottom .h-pita-v,
#page.back-top .h-salva-v, #page.back-top .h-pita-v { display: block; }

#page.back-bottom #back {
  left: 50%;
  top: auto;
  bottom: 0;
  transform: translateX(-50%);
}

/* On a campaign page they hang from the top edge instead, wrists cut off by the
   frame. Turning the pair rather than each hand keeps every wrist against the
   edge and puts them in the order they stand in on the landing. */
#page.back-top #back {
  left: 50%;
  top: -22px;
  transform: translateX(-50%);
}
#page.back-top .back-in { transform: rotate(180deg); }

/* --------------------------------------------------------- the & page */
/* The page's copy is one word: MAGIC, drawn three times, held in the gap
   between the two faces. A drawing held and then the next one — the same
   stop-motion the hands are shot in, not a tween. Every frame starts hidden
   and takes its own slot in the cycle. */
/* Stepped, not faded: each drawing holds its exact third or quarter of the
   cycle and hands straight over to the next. Written as a ramp it spends a
   sliver of every cycle part-way between two drawings — and where the last
   frame handed back to the first, the word blinked out in the seam. */
@keyframes boil3 { 0% { opacity: 1 } 33.333% { opacity: 0 } }
@keyframes boil4 { 0% { opacity: 1 } 25% { opacity: 0 } }

#and-copy {
  position: absolute;
  inset: 0;
  display: none;
  pointer-events: none;
}
#page.page-and #and-copy { display: block; }

#and-copy .magic { position: absolute; left: 699px; top: 428px; width: 521px; height: 223px; }
#and-copy .magic img { position: absolute; left: 0; top: 0; opacity: 0; animation: boil3 .39s steps(1, end) infinite; }
#and-copy .magic img:nth-child(2) { animation-delay: .13s; }
#and-copy .magic img:nth-child(3) { animation-delay: .26s; }

/* Every frame of every shooting star is stacked on the point it arrives at, so
   a frame is a swap and never a move. A slot is a star's place in the sky —
   streakLoop() throws it somewhere new each time it falls, which is why the
   box is sized and positioned from script rather than here. */
#streaks { position: absolute; inset: 0; }
/* the point a star arrives at, in the drawing's own box: rotating and flipping
   about it is what aims the star without moving where it lands */
.streak { position: absolute; transform-origin: 2.11% 97.18%; }
.streak img { position: absolute; left: 0; top: 0; width: 100%; height: 100%; display: none; }
.streak.on img { display: block; }

/* One symbol burning on each eye. They sit on the iris, and carry their own
   black outline, because the left face is white there and the right one is
   black. */
#and-eyes { position: absolute; inset: 0; display: none; pointer-events: none; }
#page.page-and #and-eyes { display: block; }
.eye { position: absolute; width: 62px; height: 62px; }
.eye img { width: 100%; height: 100%; }
.eye-l { left: 237px; top: 440px; }
/* Centred on the widest clear black inside each eye, found by distance
   transform on the plate rather than by eye — the right face is looking off to
   one side, so its dark sits high and left of where the eye looks like it is. */
.eye-r { left: 1743px; top: 417px; }
.sym { display: none; }
.sym img { position: absolute; left: 0; top: 0; opacity: 0; animation: boil4 .48s steps(1, end) infinite; }
.sym img:nth-child(2) { animation-delay: .12s; }
.sym img:nth-child(3) { animation-delay: .24s; }
.sym img:nth-child(4) { animation-delay: .36s; }
/* the two eyes never boil in lockstep */
.eye-r .sym img { animation-duration: .56s; }
.eye-r .sym img:nth-child(2) { animation-delay: .14s; }
.eye-r .sym img:nth-child(3) { animation-delay: .28s; }
.eye-r .sym img:nth-child(4) { animation-delay: .42s; }

#and-eyes[data-l="star"]  .eye-l .s-star,
#and-eyes[data-r="star"]  .eye-r .s-star,
#and-eyes[data-l="heart"] .eye-l .s-heart,
#and-eyes[data-r="heart"] .eye-r .s-heart,
#and-eyes[data-l="flame"] .eye-l .s-flame,
#and-eyes[data-r="flame"] .eye-r .s-flame { display: block; }

/* ------------------------------------------------------- member: bio + cv */
/* Both keyframes mock a block of lorem into the negative space beside each of
   them — 557 wide, on a 32.3px leading. That block is the bio and CV, so this
   sets real type into it: CossetteTexte, the voice a project page is set in,
   rather than the Futura the mocks were drawn with. The plate under it is
   `kf_*_plate`, the keyframe with the lorem erased, the same way the & page's
   plate has its own placeholder erased.

   One size for both halves, and no measure carried over from the mock: its 557
   rectangle sat in the middle of the negative space with black above, below and
   beside it. Each column now takes the space its own photograph leaves — a
   plain wide box on Salva's page, where the figure is small and its edge is
   nearly straight, and a shaped one on Pita's, where the copy runs the width of
   the frame beside his head and narrows as his elbow swings in under it. */
.member-copy {
  position: absolute;
  inset: 0;
  display: none;
  /* The bio's size, and the block's default. 27px is what every project page
     sets its copy at (`--bis-copy` and its siblings, 1.411vw = 27.1px at 1920);
     a member page is the same voice one step under it, and the colophon below
     is one step under that again — a bio is read, a CV is scanned. */
  font-size: 24px;
  line-height: 28px;     /* 1.15, the projects' own leading */
  /* copy, not chrome: the stage bans selection so a drag never highlights a
     hand, but a CV is text someone should be able to take with them */
  user-select: text;
  -webkit-user-select: text;
}

/* Each of them stands on the opposite side of his own frame, so the copy takes
   the other one, held 60px clear of the reaching "back" hand on that edge and
   60px clear of his own ink on the other.
     Pita  — hand ends x150, so the column opens at x210 and could run to x1180;
             his ink stops it at x1160 by his head and at x838 by his elbow, and
             `.member-shape` below is what actually cuts that edge.
     Salva — hand starts x1770, so the column ends at x1710, and his ink reaches
             x731 at its widest, so it opens at x880 — 150 clear of him, since
             at 60 the column read as leaning on him. His figure never intrudes
             any further than that, so his box is a rectangle: a shape there
             would manufacture a ragged left margin out of a body that is not
             actually in the way. */
#page.page-pita #pita-copy { display: block; }
#pita-copy {
  padding-left: 210px;
  padding-right: 740px;
  /* A shape traced off a photograph has to be nailed to that photograph: the
     moment the block centres itself, its own length decides where the shape
     lands and the copy slides off the body it is wrapping. So this is a
     measured number — the one that centres the block the shape produces (the
     copy runs y202-850 as it stands), and it has to be re-measured if his copy
     is ever rewritten at length. Expect it to settle a few px off dead centre:
     the shape feeds back on itself, since moving the block down pushes more of
     the CV into the narrow part under his elbow and grows it a line. */
  padding-top: 199px;
}
/* Salva's has no shape to stay in register with, so it centres itself the way
   the mock's did, whatever length his copy ends up being. */
#page.page-salva #salva-copy { display: flex; }
#salva-copy {
  padding-left: 880px;
  padding-right: 210px;
  flex-direction: column;
  justify-content: center;
}
#salva-copy .member-shape { display: none; }

/* The figure, as a hole in the copy: a float shaped like Pita's own silhouette,
   so his ink is the right margin of every line rather than something the copy
   is set beside. The polygon is the plate sampled every 40px down the frame and
   traced in the float's own coordinates; `shape-margin` is the safety margin
   between ink and type — the one number to touch if the copy ever reads as
   crowding the photograph. The trace is deliberately conservative rather than
   literal: each sample takes the nearest ink of the three around it, so a notch
   in the silhouette can never open a pocket a line would run into. */
.member-shape {
  display: block;
  float: right;
  width: 400px;
  height: 820px;
  shape-margin: 60px;
  shape-outside: polygon(400px 0px, 380px 0px, 380px 40px, 380px 80px, 382px 120px, 390px 160px, 398px 200px, 400px 240px, 392px 280px, 344px 320px, 284px 360px, 226px 400px, 166px 440px, 98px 480px, 72px 520px, 60px 560px, 58px 600px, 58px 640px, 58px 680px, 76px 720px, 400px 760px, 400px 800px, 400px 820px);
}

.member-bio, .member-cv { margin: 0; }
/* The bio is set at the CV's own size, so a member page reads as one column of
   copy rather than a headline with a footnote under it. It has no width of its
   own either: whatever bounds the column bounds it. */
.member-bio { margin-bottom: 36px; }

/* A colophon: a quiet label in its own gutter and the entries running on beside
   it, in whatever the column leaves them, one step under the bio. 138 is 119
   for "experience", the longest label, and 19 after it. Entries that outrun the line wrap onto the
   hanging indent below rather than getting their own column.
   Normal flow, not a grid: a grid box is a formatting context of its own, so it
   would step around Pita's shape as one rectangle instead of letting it cut
   each line. The label sits in its gutter as a float, which is also what holds
   the entries beside it — so the gutter travels with the line rather than with
   the block. */
.member-cv {
  font-size: 21px;
  line-height: 25px;
}
.member-cv dt {
  float: left;
  width: 138px;
  letter-spacing: 1.6px;
  opacity: .5;
}
/* Each group is its own box, so a label can never float up beside the last
   line of the group above it. */
.cv-group + .cv-group { margin-top: 11px; }
/* A date range is one word as far as the line breaker is concerned — without
   this a wrap can land inside it and strand "2026" on a line of its own. */
.member-cv .nw { white-space: nowrap; }

/* No hanging indent, because nothing here wraps by accident any more. An entry
   is a title and, under it, where and when — a break the markup makes, not one
   the measure happens to make — and the second line is told apart by its tone
   rather than by a step in from the left. */
.member-cv dd {
  margin: 0 0 0 138px;
}
.member-cv dd + dd { margin-top: 6px; }
.member-cv .at {
  display: block;
  opacity: .5;         /* the labels' own grey: this is the entry's colophon */
}
/* A run of items separated by "·" breaks between its items and never inside
   one — "Basic French" is one thing, and a line that ends on "Basic" reads as
   a mistake. The separator is inside the item before it, so a break can never
   strand a dot at the head of the next line either. */
.member-cv .li { white-space: nowrap; }

/* The only clickable text on the stage. Underlined at the same weight as the
   copy's own grey so it reads as a link without shouting, and it jitters on
   hover — the one hover the whole site has. */
/* inline-block so the hover jitter has a box to move, and text-indent reset
   because a block container inherits the hanging indent above and would drag
   each link back over the separator before it */
.member-cv a {
  display: inline-block;
  text-indent: 0;
  color: inherit;
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 3px;
  text-decoration-color: rgba(255, 255, 255, .38);
}
.member-cv a:hover {
  text-decoration-color: currentColor;
  animation: jitter .18s steps(2) infinite;
}

#page-extra {
  position: absolute;
  inset: 0;
  display: none;
  text-align: center;
}
#page.extra #page-extra { display: block; }
#page.extra #page-img { display: none; }
/* Clear of the hands hanging from the top edge. This is the fallback landing
   spot for a flown campaign title — `top` has to match CAMPAIGN_TITLE_TOP in
   app.js — used only when the destination page has no title art of its own to
   aim at; normally the flight ends on that page's real title instead. */
#page-extra .title {
  position: absolute;
  left: 50%;
  top: 175px;
  line-height: 0;
  transform: translateX(-50%);
}
#page-extra .title img {
  display: block;
  transform: scale(1.7);
  transform-origin: center top;
}
#page-extra .soon {
  position: absolute;
  left: 0;
  right: 0;
  top: 505px;
  font-size: 30px;
  letter-spacing: 2px;
  opacity: .75;
}

/* ------------------------------------------------------------ project */
/* A real project's content. Independent of the 1920x1080 stage: a normal
   scrolling page, laid out in viewport units, that opens on top of everything
   once a campaign's depth transition lands on black. */
#project {
  position: fixed;
  inset: 0;
  z-index: 60;
  display: none;
  background: #000;
}
#project.on { display: block; }
/* laid out but not shown — the campaign transition has to read the project
   page's real title position before it can fly a title to it */
#project.probing { display: block; visibility: hidden; }

#project-scroll {
  position: absolute;
  inset: 0;
  overflow-y: scroll;
  overflow-x: hidden;
  -webkit-overflow-scrolling: touch;
}

#project-close {
  position: fixed;
  top: max(22px, env(safe-area-inset-top));
  right: max(26px, env(safe-area-inset-right));
  z-index: 61;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
}
#project-close span {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 28px;
  height: 2px;
  background: #f2f2ea;
  transform-origin: center;
}
#project-close span:first-child { transform: translate(-50%, -50%) rotate(45deg); }
#project-close span:last-child  { transform: translate(-50%, -50%) rotate(-45deg); }
#project-close:hover span { background: #ff2f9f; }

.proj {
  color: #f2f2ea;
  font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
  max-width: 1180px;
  margin: 0 auto;
  padding: 90px 6vw 140px;
}

.proj-head {
  text-align: center;
  margin: 0 0 42px;
}
.proj-eyebrow {
  display: block;
  font-size: 15px;
  letter-spacing: .28em;
  text-transform: uppercase;
  color: #ff2f9f;
  margin: 0 0 14px;
}
.proj-title {
  font-weight: bold;
  text-transform: uppercase;
  letter-spacing: 0;
  line-height: 1;
  font-size: clamp(34px, 7vw, 78px);
  margin: 0 0 22px;
}
.proj-sub {
  font-size: clamp(15px, 2vw, 19px);
  line-height: 1.5;
  max-width: 640px;
  margin: 0 auto;
  color: #cfcfc6;
}

.proj-block { margin: 64px 0; }
.proj-block:first-of-type { margin-top: 0; }

.proj-duo {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 22px;
}
.proj-trio {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 2vw;
  flex-wrap: wrap;
  padding: 10px 0;
}
.proj-trio img { width: clamp(150px, 20vw, 260px); height: auto; }
.proj-trio .t2 { margin-top: 5vw; }

/* The edge is never a clean rectangle: the same noise.svg dither used for the
   scroll reveal below is unioned with a solid inset core, so the last ~24px
   of every media block is decided pixel-by-pixel by that same speckle instead
   of a hard cut — a rough, printed-halftone edge fading into the background.
   Permanent, independent of `--p`: on whether or not the block has resolved. */
.proj-media {
  position: relative;
  overflow: hidden;
  background: #0a0a0a;
  mask-image: linear-gradient(#000, #000), url("img/noise.svg");
  mask-size: calc(100% - 46px) calc(100% - 46px), 34px 34px;
  mask-position: center, center;
  mask-repeat: no-repeat, repeat;
  mask-composite: add, add;
}
.proj-media img, .proj-media video {
  display: block;
  width: 100%;
  height: auto;
}

.proj-caption {
  font-size: 14px;
  letter-spacing: .04em;
  color: #8a8a82;
  text-align: center;
  margin-top: 14px;
}

/* -------------------------------------------------- reveal / depth */
/* Every media block starts noisy and dark, exactly the site's own
   noise-plus-threshold dither look run at runtime instead of baked into a
   frame, and clears as it is scrolled into view. `--p` (0..1) is driven from
   JS on scroll; nothing here moves on its own. */
.reveal { --p: 0; }
.reveal .proj-media img,
.reveal .proj-media video,
.reveal .proj-media iframe,
.reveal.proj-solo {
  /* the exact contrast/brightness the campaign title's own dissolve plays on
     the way in, run backwards: crushed to black and thresholded at --p:0,
     clean at --p:1 */
  filter: contrast(calc(1 + 7 * (1 - var(--p))))
          brightness(max(0, calc(1 - 1.15 * (1 - var(--p)))));
  opacity: calc(.15 + .85 * var(--p));
}
.reveal .noise {
  position: absolute;
  inset: -20%;
  background-image: url("img/noise.svg");
  background-size: 140px 140px;
  opacity: calc((1 - var(--p)) * (1 - var(--p)));
  mix-blend-mode: screen;
  pointer-events: none;
}
.reveal-text {
  opacity: calc(.15 + .85 * var(--p));
  filter: blur(calc((1 - var(--p)) * 4px));
}

/* Depth: elements drift at different speeds as the page scrolls, the same
   layered read as the title flying in over the landing hands. `--y` is set
   from JS each frame; positive values are further back, negative sit closer
   and move more. */
.depth { transform: translate3d(0, var(--y, 0px), 0); will-change: transform; }

@media (prefers-reduced-motion: reduce) {
  .reveal { --p: 1 !important; }
  .depth { transform: none !important; }
}

@media (max-width: 720px) {
  .proj { padding: 76px 6vw 110px; }
  .proj-duo { grid-template-columns: 1fr; }
  .proj-trio { gap: 0; }
  .proj-trio .t2 { margin-top: 8vw; }
}

/* ------------------------------------------------------- project: nav */
/* Every project shares this chrome — prev/next around the same campaign
   ring the landing lays out, and a close control. Not one project's look:
   this is sitewide infrastructure, styled once here. */
/* no font-family here on purpose — nav is chrome, not content, so it wears
   whichever voice the project it's attached to declares (the site's own
   CossetteTexte by default, a project's own type where one sets one). */
.proj-nav {
  display: flex;
  align-items: center;
  justify-content: space-between;
  font-size: clamp(13px, 1.6vw, 17px);
  color: #f2f2ea;
}
.proj-nav-link {
  display: inline-flex;
  align-items: center;
  gap: 9px;
  color: inherit;
  background: none;
  border: 0;
  padding: 6px 2px;
  font: inherit;
  cursor: pointer;
}
.proj-nav-link:hover { animation: jitter .18s steps(2) infinite; }
/* 58x38 viewBox — every doodle on a project page is sized at its own viewBox
   aspect so the shared pen (stroke-width and filter, both in user units)
   scales identically across all of them. Changing one axis alone re-weights
   this arrow's line against every other arrow's. */
.proj-nav-link svg { width: 22px; height: 17px; overflow: visible; flex: none; }

/* The project's own close control — not a new drawing: the exact `#back`
   hands (the vertical cut, meant to hang from a top edge, wrists cut off by
   the frame), beckoning on hover the same way. `#back` itself can't show
   through here — #project sits in its own fixed stacking context above the
   whole stage — so this repeats its markup, and app.js drives the beckon by
   delegation so this copy needs no wiring of its own. Only the size is this
   control's own: the hands are drawn small here, so they are the one place on
   the site where the art is scaled in the browser rather than baked. */
.proj-nav-hands {
  background: none;
  border: 0;
  padding: 8px 14px;
  margin: -8px 0;
  cursor: pointer;
  line-height: 0;
}
.proj-nav-hands .back-in { display: flex; align-items: flex-end; gap: 26px; transform: rotate(180deg); }
.proj-nav-hands .hand { display: block; width: clamp(38px, 5vw, 60px); }
.proj-nav-hands .hand img { width: 100%; height: auto; }

/* the flown-in campaign title, repeated here so the page it opens onto never
   needs a font to fake it — every project's own copy of this art starts
   the page at its one definitive position, already settled. */
.proj-title-art { display: block; width: clamp(280px, 44vw, 560px); margin: 0 auto 26px; }

/* ------------------------------------------------ project: back in smoothly */
/* PlatanoMelón's own type, set once at the project root — everything under
   it, nav included, inherits it rather than repeating the font stack, so
   the nav always reads as the same voice as the copy it sits above. */
/* The mockup is a 1920-wide drawing and the rest of the site is a 1920x1080
   stage scaled to fit, so this page is sized the same way: every length below
   is the mockup's own pixel measurement divided by 19.2, in vw. No px caps —
   a max-width would hold the page at ~49% of a 1920 viewport where the
   mockup's content column is 86% of it. 27.1px of body copy at 1920 is the
   one text size the whole mockup uses: 1.41vw. */
.proj-bis {
  --bis-ink: #f2f2ea;
  --bis-copy: 1.411vw;      /* 27.1px @1920 — nav, desc, caption, all of it */
  font-family: 'CossetteTexte', "Helvetica Neue", Helvetica, Arial, sans-serif;
  max-width: none;
  padding: 0 7.031vw 6.3vw; /* content column x135->1784 of 1920 */
  font-size: var(--bis-copy);
}
.proj-bis .proj-head { margin-top: 3.07vw; }
.proj-bis .proj-block { margin: 0; }

/* the mockup's own close control is the pair of hands hanging off the top
   edge — the shared chrome's corner "x" is not in the drawing, and would be
   a second way to do the one thing the hands already do */
#project:has(.proj-bis) #project-close { display: none; }

/* nav sits a hair outside the content column in the mockup (arrow ink at
   x121 against a x135 column) and wears the same 27.1px as the copy */
.proj-bis .proj-nav { font-size: var(--bis-copy); margin: 0 -0.729vw; }
.proj-bis .proj-nav-link { gap: 0.573vw; padding: 0 0.104vw; }
.proj-bis .proj-nav-link svg { width: 2.292vw; height: 1.771vw; }
.proj-bis .proj-nav-hands { padding: 0; margin: 0; }
.proj-bis .proj-nav-hands .back-in { gap: 1.823vw; }
.proj-bis .proj-nav-hands .hand { width: 6.615vw; }
.proj-bis .proj-title-art { width: 42.14vw; margin: 0 auto; }
.proj-bis .proj-duo { gap: 2.604vw; }   /* 50px @1920 between the two spots */
/* the box is the mockup's box, not the video's: declaring the ratio up front
   means the page is laid out before a single frame of mp4 has arrived, so
   nothing below it jumps once the clips decode */
.proj-bis .proj-duo .proj-media { aspect-ratio: 16 / 9; }
.proj-bis .proj-duo .proj-media video { height: 100%; object-fit: cover; }

/* every video/image block in this project reads as a plain flat rectangle,
   like the mockup's own placeholder boxes — the shared .proj-media speckle
   mask is tuned for stills and reads as a broken/noisy frame on top of a
   playing video, so this project opts out of it entirely. */
.proj-bis .proj-media { mask-image: none; }

/* same size as the body copy below it, per the brand spec — bold is what
   sets it apart, not a bigger point size */
.bis-subtitle {
  font-weight: 700;
  font-size: var(--bis-copy);
  line-height: 1.15;
  max-width: 47vw;      /* the mockup breaks this line after "relaxant"; 870px is
                           exactly that line, so the cap sits just clear of it */
  margin: 0 auto;
  color: var(--bis-ink);
}
.bis-subtitle small { font-size: .65em; letter-spacing: .01em; }

/* the description hugs the left edge, its arrow immediately trailing the
   text rather than pinned to the far right; the caption is the mirror of
   that — a narrower column hugging the right edge, its arrow immediately
   leading the text. Both full white, the same ink as everything else. */
.bis-desc {
  display: flex;
  align-items: center;
  justify-content: flex-start;
  gap: 1.25vw;
  flex-wrap: wrap;
  font-size: var(--bis-copy);
  color: var(--bis-ink);
  text-align: left;
  margin: 4.58vw 0 1.77vw;
}
.bis-desc svg { width: 3.854vw; height: 1.615vw; overflow: visible; flex: none; color: var(--bis-ink); }

.bis-caption {
  display: flex;
  align-items: flex-start;
  justify-content: flex-end;
  flex-wrap: nowrap;
  gap: 1.042vw;
  font-size: var(--bis-copy);
  white-space: nowrap;
  color: var(--bis-ink);
}
/* the mockup's arrow is a long hook: it starts level with the line and drops
   ~100px past it, into the sticker below. The negative bottom margin keeps
   that overhang from adding its height to the caption row. */
.bis-caption svg {
  width: 4.115vw; height: 6.094vw;
  overflow: visible; flex: none; color: var(--bis-ink);
  margin: 0.677vw 0 -6.094vw;
}

/* measured off the mockup itself: the three stickers are NOT one shared
   width — each is placed at its own artwork's natural size at 1920 (525,
   ~500 and 545px wide), so they read as three different die-cuts rather
   than three things scaled to a grid. Center-aligned as a group, never on
   a shared baseline: their outlines are each a different height. */
.bis-sticker-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  /* the die-cuts break the content column in the mockup — their white
     outlines run a little past it on both sides rather than tucking in */
  margin: 0 -0.625vw 0 -1.719vw;
}
.bis-sticker-row img { height: auto; display: block; }
.bis-sticker-row img:nth-child(1) { width: 27.34vw; }
.bis-sticker-row img:nth-child(2) { width: 26.07vw; }
.bis-sticker-row img:nth-child(3) { width: 28.39vw; }

.bis-park-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0; background: #0a0a0a; }
.bis-park-row img { display: block; width: 100%; aspect-ratio: 4 / 5; object-fit: cover; }
.bis-park-row.reveal img {
  filter: contrast(calc(1 + 7 * (1 - var(--p)))) brightness(max(0, calc(1 - 1.15 * (1 - var(--p)))));
  opacity: calc(.15 + .85 * var(--p));
}

/* the mockup's vertical rhythm is not one repeated gap — every seam between
   blocks is its own measurement off the drawing, so they are set one by one
   rather than by a single .proj-block margin */
.proj-bis .bis-b-duo    { margin-top: 2.917vw; }   /* spot -> the two gifs */
.proj-bis .bis-b-cap    { margin-top: 3.125vw; }   /* gifs -> prints line */
.proj-bis .bis-b-stick  { margin-top: 2.239vw; }   /* prints line -> stickers */
.proj-bis .bis-park-row { margin-top: 3.490vw; }   /* stickers -> parking cams */
.proj-bis .bis-b-mobile { margin-top: 3.021vw; }   /* parking cams -> mobile */
.proj-bis .bis-b-mobile + .proj-nav { margin-top: 5.938vw; }

/* measured off the mockup: gif3, the center text/QR gap and gif4 are three
   genuinely equal thirds sharing the full content column, edge to edge with
   no gutter — the air around the center text is the center third itself */
.bis-mobile-row { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0; align-items: center; }
.bis-mobile-row .proj-media { aspect-ratio: 608 / 1080; }
.bis-mobile-row .proj-media img,
.bis-mobile-row .proj-media video { height: 100%; object-fit: cover; }
.bis-mobile-center { text-align: center; }

.bis-interactive {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 1.172vw;
  font-size: var(--bis-copy);
  color: var(--bis-ink);
}
.bis-interactive svg { width: 4.896vw; height: 1.719vw; overflow: visible; flex: none; }
/* the one line the mockup sets larger than the rest of the copy — 29px @1920 */
.bis-interactive p { margin: 0; line-height: 1.15; font-size: 1.514vw; }

/* the center column does not sit on the row's midline in the mockup — it
   rides ~6% of the clip's height below it */
.bis-mobile-center { position: relative; top: 2.97vw; }

/* Measured off the mockup: the two text lines and the QR share one center
   axis — this is a centered column, not a left-flush block. The arrow sits
   just outside the QR's top-left corner, hooking into it from up and to
   the left, independent of the (centered) text above. */
.bis-tap-wrap { display: flex; flex-direction: column; align-items: center; text-align: center; margin-top: 5.781vw; }
.bis-tap {
  display: block;
  font-size: var(--bis-copy);
  color: var(--bis-ink);
  line-height: 1.2;
  text-decoration: underline;
  text-underline-offset: .19em;
  cursor: pointer;
}
.bis-tap:hover { animation: jitter .18s steps(2) infinite; }
.bis-tap > span { display: block; }
.bis-circle { position: relative; display: inline-block; }
.bis-circle svg { position: absolute; left: 50%; top: 46%; transform: translate(-50%, -50%); width: 2.708vw; height: 2.344vw; overflow: visible; pointer-events: none; }

/* 27px @1920 between the "here to play" line and the QR, measured off the
   mockup — the arrow has to span that gap, tail level with "here" and tip
   landing on the code, so opening it up leaves the arrow pointing at nothing. */
.bis-qr-wrap { position: relative; display: inline-block; margin-top: 1.406vw; }
/* Anchored on the QR's top-left corner. The offsets are traced: the tip sits
   9px left of the code's left edge and 41px down from its top, and the tail
   comes out level with "here" above it. */
.bis-arrow-down {
  position: absolute; left: 0; top: 0;
  transform: translate(-77%, -48%);
  width: 2.813vw; height: 4.740vw; overflow: visible; color: var(--bis-ink);
}

.bis-qr { display: block; width: 9.74vw; }
.bis-qr img { display: block; width: 100%; height: auto; }

/* a real YouTube embed — the video's own custom thumbnail already is the
   mockup's yellow key art, and the play button/watch-on-YouTube chrome is
   YouTube's own player UI, not anything drawn here */
.bis-yt { position: relative; aspect-ratio: 16 / 9; }
.bis-yt iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; }

/* Below the mockup's own territory the vw scale stops being a size and starts
   being a legibility problem (1.41vw is 10px on a phone), so the whole page
   falls off the drawing's proportions and back onto readable px here. */
@media (max-width: 720px) {
  .proj-bis { padding: 24px 6vw 110px; --bis-copy: 14px; }
  .proj-bis .proj-head { margin-top: 26px; }
  .proj-bis .proj-nav { margin: 0; }
  .proj-bis .proj-nav-link { gap: 9px; }
  .proj-bis .proj-nav-link svg { width: 22px; height: 17px; }
  .proj-bis .proj-nav-hands .back-in { gap: 26px; }
  .proj-bis .proj-nav-hands .hand { width: 48px; }
  .proj-bis .proj-title-art { width: 72vw; }
  .bis-subtitle { max-width: 100%; }
  .bis-sticker-row { margin: 0; }
  .bis-desc { margin: 26px 0 18px; gap: 10px; }
  .bis-desc svg { width: 37px; height: 16px; }
  .bis-caption svg { width: 40px; height: 59px; margin: 4px 0 0; }
  .proj-bis .bis-b-duo, .proj-bis .bis-b-cap, .proj-bis .bis-b-stick,
  .proj-bis .bis-park-row, .proj-bis .bis-b-mobile { margin-top: 44px; }
  .proj-bis .bis-b-mobile + .proj-nav { margin-top: 56px; }
  .proj-bis .proj-duo { gap: 16px; }
  .bis-mobile-row { grid-template-columns: 1fr; gap: 20px; }
  .bis-mobile-row .proj-media { max-width: 260px; margin: 0 auto; }
  .bis-park-row { grid-template-columns: 1fr; }
  .bis-sticker-row { flex-wrap: wrap; gap: 5%; justify-content: center; }
  .bis-sticker-row img:nth-child(1),
  .bis-sticker-row img:nth-child(2),
  .bis-sticker-row img:nth-child(3) { width: 42%; }
  .bis-interactive { gap: 10px; }
  .bis-interactive svg { width: 47px; height: 17px; }
  .bis-interactive p { font-size: 15px; }
  .bis-mobile-center { top: 0; }
  .bis-tap-wrap { margin-top: 20px; }
  .bis-circle svg { width: 26px; height: 23px; }
  .bis-qr-wrap { margin-top: 14px; }
  .bis-arrow-down { width: 27px; height: 46px; }
  .bis-qr { width: 96px; }
  /* the mockup's single right-flush line only fits a screen that wide */
  .bis-caption { white-space: normal; justify-content: flex-start; align-items: center; }
  .bis-caption svg { margin: 0; }
}

/* ------------------------------------------------ project: numpad jam */
/* Same system as Back in Smoothly above — same type, same nav/title-art
   sizing — kept as its own block rather than shared with .proj-bis so each
   project can drift from the other's numbers once either one actually
   builds out. Numpad Jam has no content blocks yet, just the head. */
.proj-numpad {
  --np-ink: #f2f2ea;
  --np-copy: 1.411vw;
  font-family: 'CossetteTexte', "Helvetica Neue", Helvetica, Arial, sans-serif;
  max-width: none;
  padding: 0 7.031vw 6.3vw;
  font-size: var(--np-copy);
}
.proj-numpad .proj-head { margin-top: 3.07vw; }
#project:has(.proj-numpad) #project-close { display: none; }
.proj-numpad .proj-nav { font-size: var(--np-copy); margin: 0 -0.729vw; }
.proj-numpad .proj-nav-link { gap: 0.573vw; padding: 0 0.104vw; }
.proj-numpad .proj-nav-link svg { width: 2.292vw; height: 1.771vw; }
.proj-numpad .proj-nav-hands { padding: 0; margin: 0; }
.proj-numpad .proj-nav-hands .back-in { gap: 1.823vw; }
.proj-numpad .proj-nav-hands .hand { width: 6.615vw; }
/* Narrower than Back in Smoothly's 42.14vw, to come out the same size.
   Matching the two titles' WIDTHS is what made them look inconsistent: they
   are the same hand at the same size in the artwork, but "NUMPAD JAM" is a
   much shorter string than "BACK IN SMOOTHLY", so filling the same width
   blows its letters up — 0.153 of the width against 0.111, half again as
   tall. What has to match is the lettering, so this is the width at which
   this title's letters stand as tall as that one's. */
.proj-numpad .proj-title-art { width: 30.75vw; margin: 0 auto; }

.numpad-idea {
  font-weight: 700;
  font-size: var(--np-copy);
  line-height: 1.15;
  max-width: 34vw;    /* tuned to break the sentence into two similar-length lines */
  margin: 0 auto;
  color: var(--np-ink);
}

.proj-numpad .np-b-case { margin-top: 4.58vw; }

/* text hugging the left edge, arrow immediately trailing it — same read as
   Back in Smoothly's bis-desc, pointing down into the video under it */
.np-case {
  display: flex;
  align-items: center;
  justify-content: flex-start;
  gap: 1.25vw;
  flex-wrap: wrap;
  font-size: var(--np-copy);
  color: var(--np-ink);
  text-align: left;
  margin: 0 0 1.77vw;
}
.np-case svg { width: 3.854vw; height: 1.615vw; overflow: visible; flex: none; color: var(--np-ink); }

.np-yt { position: relative; aspect-ratio: 16 / 9; }
.np-yt iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; }

/* The clip keeps the shared .proj-media speckle mask turned off — it is
   tuned for stills and reads as a broken frame on top of playing video.

   One clip now, not two: it sits on the left at its own native ~2.93:1
   (208/71), uncropped, because all ten timeline covers being visible is
   what the shot is for. The columns are sized so the clip gets the width
   its aspect needs and the invitation takes the rest — 2.93fr against 1fr
   puts the clip at about 62vw and leaves ~21vw for the ring, which is
   wider than the ring itself so it can centre in its own column. */
.proj-numpad .np-b-duo { margin-top: 2.917vw; }
.np-duo {
  display: grid;
  grid-template-columns: 2.93fr 1fr;
  gap: 2.604vw;
  align-items: center;
}
.np-duo-clip { aspect-ratio: 208 / 71; }
/* contain, not cover: the box matches the footage's own aspect, so this is
   a no-op that guards against the two ever drifting apart and cropping. */
.np-duo-clip video { height: 100%; object-fit: contain; }

/* The ringed invitation. The ring is a fixed-aspect box (350x220 viewBox at
   the page's own unit-≈-px-at-1920 scale, i.e. viewBox/19.2 in vw) because
   every doodle on this page has to be: stretch one to a different aspect
   and its stroke width stretches with it, and it stops being the same pen
   as the arrows. So the text sizes itself to fit the ring rather than the
   ring growing to fit the text. */
/* the cell, so the arrow can be placed against the ring rather than against
   the grid: the ring is centred in a taller column, and what the arrow has
   to aim at is the ring's own lower-left corner */
.np-duo-try {
  position: relative;
  display: flex;
  justify-content: center;
}
/* Box is the svg's own viewBox at the page's unit-≈-px-at-1920 scale
   (150/19.2 x 90/19.2, in vw) — like every other doodle here, sized any
   other way it stretches its own stroke.

   It starts ON the clip, not in the gutter beside it. The gutter is ~60px
   and the arrow is ~116px long, so anything that fits between the two
   either shrinks the doodle out of the family or ends up aimed at the ring
   from on top of it. Overlapping costs nothing here: white ink on the part
   of the frame that is black anyway — below the timeline strip, right of
   the drawn keyboard — so the stroke reads as cleanly over the clip as it
   does over the page. Left and bottom put the tail in that dead corner,
   pointing out of the keyboard's side of the frame, and leave the head
   short of the ring's lower-left arc rather than crossing it. */
.np-try-arrow {
  position: absolute;
  left: -9vw;
  bottom: -1.65vw;
  width: 7.81vw;
  height: 4.69vw;
  color: var(--np-ink);
  pointer-events: none;
}
.np-try-arrow svg { display: block; width: 100%; height: 100%; overflow: visible; }

.np-try {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 18.23vw;    /* 350 units */
  height: 11.46vw;   /* 220 units */
  color: var(--np-ink);
  text-decoration: none;
}
.np-try-ring {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none;   /* the whole box is the link; the ring is just ink */
}
/* 62% of the ring's width: inside a circle the usable line length is well
   short of the full diameter, and this is what keeps the three lines clear
   of the stroke on both sides. */
.np-try-label {
  position: relative;
  max-width: 62%;
  text-align: center;
  font-size: var(--np-copy);
  line-height: 1.2;
  text-decoration: underline;
  text-underline-offset: 0.16vw;
}
/* the site's one hover read: the same stepped vibration every nav and
   campaign title uses, on the whole link so ring and words shake together */
.np-try:hover { animation: jitter .18s steps(2) infinite; }

.proj-numpad .proj-media { mask-image: none; }

/* ---- community jams: the player is the campaign's own, not a generic
   one — the case study's numpadSim already shows a track playing inside a
   Spotify-shaped mini player (dark rounded card, cover, number-string
   title, artist under it, round white button, a thin white bar with its
   times at either end and a green ring while it plays), so these cards are
   that same player with a community handle where the mockup's artist line
   sits. Its numbers, its type — Arial standing in for Circular, the same
   stand-in the shop cards below make for the store's own font — rather
   than the page's --np-copy/CossetteTexte ink, because the point is the
   campaign's output showing up inside somebody else's product. */
.proj-numpad .np-b-jams { margin-top: 4.58vw; }
.np-jams-caption { margin-bottom: 1.25vw; }
/* the doodle dips below its own baseline, so it needs the same optical
   nudge down that puts its head level with the line of text it trails */
.np-jams-caption svg { transform: translateY(0.16vw); }

.np-jams {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1.2vw;   /* same gap as the shop row below, so the two blocks rhyme */
}

.np-jam {
  --jam-p: 0;   /* 0..1 playhead, written by app.js each frame while playing */
  background: #141414;
  /* a ring, not a border swap: transparent at rest and green while playing,
     so the card never changes size between the two states */
  border: 0.104vw solid transparent;
  border-radius: 0.833vw;
  padding: 0.833vw;
  font-family: Arial, "Helvetica Neue", Helvetica, sans-serif;
  transition: background .2s, border-color .2s;
}
.np-jam:hover { background: #1c1c1c; }
.np-jam.on { border-color: #1ed760; }

.np-jam-top {
  display: flex;
  align-items: center;
  gap: 0.83vw;
}

.np-jam-art {
  width: 3.646vw;   /* 70px @1920 */
  aspect-ratio: 1 / 1;
  flex: none;
  border-radius: 0.417vw;
  overflow: hidden;
  background: #000;
}
.np-jam-art img { display: block; width: 100%; height: 100%; object-fit: cover; }

/* min-width:0 so a long title ellipsises inside the flex row instead of
   pushing the play button off the card's right edge */
.np-jam-body { flex: 1 1 auto; min-width: 0; }
/* the jam's title IS the keys it presses, set the way the album sets its
   track titles: one run of digits, tabular so the run stays even, tracked
   out so it reads as presses rather than as one long number */
.np-jam-name {
  font-size: 0.99vw;   /* 19px @1920 */
  font-weight: 700;
  color: #fff;
  letter-spacing: 0.04em;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.np-jam-by {
  margin-top: 0.16vw;
  font-size: 0.677vw;   /* 13px @1920 */
  color: #b3b3b3;
  letter-spacing: 0.02em;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.np-jam-row {
  display: flex;
  align-items: center;
  gap: 0.73vw;
  margin-top: 0.42vw;
}
/* the hit target, not the line: 8px of padding above and below a 4px bar so
   scrubbing doesn't demand pixel aim. The line itself is .np-jam-track. */
.np-jam-bar {
  flex: 1 1 auto;
  min-width: 0;
  padding: 0.417vw 0;
  cursor: pointer;
}
/* overflow stays visible so the knob can sit centred on the fill's end
   instead of being clipped in half by its own track */
.np-jam-track {
  position: relative;
  height: 0.208vw;
  border-radius: 0.208vw;
  background: #3f3f3f;
}
.np-jam-fill {
  height: 100%;
  width: calc(var(--jam-p) * 100%);
  background: #fff;
  border-radius: inherit;
}
.np-jam-knob {
  position: absolute;
  top: 50%;
  left: calc(var(--jam-p) * 100%);
  width: 0.573vw;
  height: 0.573vw;
  margin: 0;
  border-radius: 50%;
  background: #fff;
  transform: translate(-50%, -50%);
  pointer-events: none;
}
/* green while this card is the one sounding — the same tell the mockup's
   own player uses for the track you are hearing */
.np-jam.on .np-jam-fill, .np-jam.on .np-jam-knob { background: #1ed760; }

.np-jam-time {
  flex: none;
  font-size: 0.625vw;   /* 12px @1920 */
  color: #b3b3b3;
  font-variant-numeric: tabular-nums;   /* so the clock doesn't jitter */
}

.np-jam-play {
  flex: none;
  width: 2.396vw;   /* 46px @1920 */
  height: 2.396vw;
  border: 0;
  border-radius: 50%;
  background: #fff;
  color: #000;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  padding: 0;
  transition: transform .1s, background .2s;
}
.np-jam-play:hover { background: #1ed760; transform: scale(1.06); }
.np-jam-play svg { width: 42%; height: 42%; fill: currentColor; display: block; }
/* one button, two icons, swapped on the card's own playing state — no
   markup rewrite per frame and no icon to keep in sync from JS */
.np-jam-play .np-jam-i-pause,
.np-jam.on .np-jam-play .np-jam-i-play { display: none; }
.np-jam.on .np-jam-play .np-jam-i-pause { display: block; }

/* position:relative anchors .np-shop-arrow to this block's own width — the
   row of 5 equal cards, same box the caption spans — in percentages, not
   pixels, so both stay correct if the row ever reflows. */
.proj-numpad .np-b-shop { margin-top: 4.58vw; position: relative; }

.np-shop-caption {
  font-size: var(--np-copy);
  color: var(--np-ink);
  text-align: right;
  white-space: nowrap;
  margin: 0 0 1.77vw;
}

/* width/height are the svg's own viewBox (410x70) at the same
   unit-≈-px-at-1920 scale as every other doodle's box on the page
   (viewBox-units/19.2, in vw) — see the long comment on the svg itself in
   app.js for why: a box sized any other way stretches the curve and the
   stroke width off the drawn proportions. Because the box's own size is
   fixed by that, only `left` and `top` are free to place it, so only one
   end of the curve can be solved for exactly — that's the start, put
   0.948 (mirrored from local x15/410) of the box's width from `left` so
   it lands beside the caption text, the same 1.25vw gap as np-case's own
   arrow. The caption is right-aligned and nowrap, so its left edge — the
   end this gap is measured against — moves whenever the line's wording
   changes: `left` has to be re-solved with the copy, and 50.78% was the
   answer for a line four characters shorter than the one there now.
   The vertex (mirrored local x372/410) falls out of that same left+width
   wherever it falls, which is over the hat card (card 3 of 5) but not
   dead-centered on it, which reads as a natural hand-aimed drop rather
   than a measured one.
   Vertically, local y20 (the start) lands level with the middle of the
   "t" in "the", and local y52 (the vertex) comes out ~18px clear above
   the card row's own top edge — comfortably not touching it — because
   top solves for the start alone: top+0.286×height=0.91vw. */
.np-shop-arrow {
  position: absolute;
  left: 47.65%;
  top: -0.13vw;
  width: 21.35vw;
  height: 3.65vw;
  color: var(--np-ink);
  pointer-events: none;
  transform: scaleX(-1);
}
.np-shop-arrow svg { display: block; width: 100%; height: 100%; overflow: visible; }

/* Traced 1:1 off shop.jpegmafia.net's own product tile: white square,
   square corners (no radius), soft drop shadow, Arial name in store grey,
   Arial price in store blue — not the site's own CossetteTexte/proj-numpad
   ink, this card is a screenshot of someone else's UI living on the page. */
.np-shop {
  display: flex;
  gap: 1.2vw;
  /* no margin-top here on purpose: the caption's own margin-bottom (1.77vw)
     is the only gap, same tight rhythm as Back in Smoothly's caption-into-
     content spacing. The arrow's drop-shadow (above) is what keeps it
     legible landing on a card, not extra dead air above the row. */
}
.np-shop-card {
  flex: 1 1 0;
  min-width: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
}
.np-shop-tile {
  width: 100%;
  aspect-ratio: 1 / 1;
  background: #fff;
  box-shadow: 0 8px 9px -2px rgba(0, 0, 0, .2);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 4%;
  box-sizing: border-box;
}
.np-shop-tile img { display: block; max-width: 100%; max-height: 100%; object-fit: contain; }
.np-shop-name {
  margin-top: 0.9vw;
  font-family: Arial, "Helvetica Neue", Helvetica, sans-serif;
  font-size: 0.677vw;   /* 13px @1920, the store's own size */
  color: #666;
  text-align: center;
}
.np-shop-price {
  margin-top: 0.25vw;
  font-family: Arial, "Helvetica Neue", Helvetica, sans-serif;
  font-size: 0.552vw;   /* 10.6px @1920, the store's own size */
  color: #1aaef1;
  text-align: center;
}

/* ---- the product, closing the page. Copy left, one steady turn right: the
   clip is 768x560 and runs at its own aspect, and the two columns are sized so
   the copy gets a comfortable measure while the pad still reads at speed. */
.proj-numpad .np-b-pad { margin-top: 4.58vw; }
/* Three across, hinged on the arrow. The outer columns are equal, so the
   arrow's own column lands dead centre of the block whatever the line and the
   clip happen to measure; the line is pushed to the end of the column left of
   it and the clip to the start of the column right of it, which leaves both
   of them hugging the arrow instead of drifting off to the margins. */
.np-pad {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  gap: 1.25vw;
  align-items: center;
}
/* text and arrow as one row, the arrow centred against the whole block of
   copy rather than trailing its last line — same read as np-case, turned
   sideways because what it points at is beside it, not under it */

.np-pad-copy {
  justify-self: end;
  margin: 0 2.6vw 0 0;   /* sits off the arrow rather than up against it */
  font-size: var(--np-copy);
  line-height: 1.25;
  color: var(--np-ink);
}
.np-pad-arrow {
  flex: none;
  width: 3.854vw;    /* the svg's own 74x31 viewBox at unit-≈-px-at-1920 */
  height: 1.615vw;
  color: var(--np-ink);
}
.np-pad-arrow svg { display: block; width: 100%; height: 100%; overflow: visible; }
/* the clip's own ground is crushed to #000 in the render, so the box
   under it has to be too or its edges show as a panel */
.np-pad-clip { justify-self: start; width: 30vw; aspect-ratio: 768 / 560; background: #000; }
.np-pad-clip video { height: 100%; object-fit: contain; }

@media (max-width: 720px) {
  .proj-numpad { padding: 24px 6vw 110px; --np-copy: 14px; }
  .proj-numpad .proj-head { margin-top: 26px; }
  .proj-numpad .proj-nav { margin: 0; }
  .proj-numpad .proj-nav-link { gap: 9px; }
  .proj-numpad .proj-nav-link svg { width: 22px; height: 17px; }
  .proj-numpad .proj-nav-hands .back-in { gap: 26px; }
  .proj-numpad .proj-nav-hands .hand { width: 48px; }
  .proj-numpad .proj-title-art { width: 52.5vw; }   /* 72vw scaled the same way */
  .numpad-idea { max-width: 100%; }
  .proj-numpad .np-b-case { margin-top: 26px; }
  .np-case { margin: 0 0 18px; gap: 10px; }
  .np-case svg { width: 37px; height: 16px; }
  .proj-numpad .np-b-duo { margin-top: 44px; }
  /* clip over invitation at phone width: side by side, the ring would be
     about 90px across and the clip would lose the timeline covers that are
     the whole reason it runs uncropped */
  .np-duo { grid-template-columns: 1fr; gap: 22px; }
  /* the gap it crosses on desktop doesn't exist once the row stacks */
  .np-try-arrow { display: none; }
  .np-try { width: 250px; height: 157px; }
  .np-try-label { font-size: 14px; text-underline-offset: 3px; }
  /* one column: two of these side by side at phone width leaves the name
     with about a word of room before it ellipsises */
  .proj-numpad .np-b-jams { margin-top: 44px; }
  .np-jams-caption { margin-bottom: 18px; }
  .np-jams-caption svg { transform: none; }
  .np-jams { grid-template-columns: 1fr; gap: 10px; }
  .np-jam { padding: 12px; border-radius: 12px; border-width: 2px; }
  .np-jam-top { gap: 12px; }
  .np-jam-art { width: 54px; border-radius: 6px; }
  .np-jam-name { font-size: 16px; }
  .np-jam-by { font-size: 12px; margin-top: 2px; }
  .np-jam-row { gap: 10px; margin-top: 8px; }
  .np-jam-bar { padding: 8px 0; }
  .np-jam-track { height: 4px; border-radius: 4px; }
  .np-jam-knob { width: 11px; height: 11px; }
  .np-jam-time { font-size: 11px; }
  .np-jam-play { width: 38px; height: 38px; }

  .proj-numpad .np-b-shop { margin-top: 44px; }
  .np-shop-caption { text-align: left; margin: 0 0 18px; }
  /* the gap it targets on desktop isn't a fixed spot once the row scrolls —
     simpler to drop the arrow here than chase a moving target */
  .np-shop-arrow { display: none; }
  /* five tiles cannot read at phone width — scroll the row instead of
     crushing each card past the point its own name/price still fit */
  .np-shop { overflow-x: auto; gap: 14px; padding-bottom: 6px; -webkit-overflow-scrolling: touch; }
  .np-shop-card { flex: 0 0 128px; }
  .np-shop-name { font-size: 12px; margin-top: 8px; }
  .np-shop-price { font-size: 11px; margin-top: 2px; }

  /* copy over product once the row stacks; the arrow pointed across a gap
     that no longer exists, so it goes rather than turning to point down */
  .proj-numpad .np-b-pad { margin-top: 44px; }
  .np-pad { grid-template-columns: 1fr; gap: 18px; }
  .np-pad-copy { justify-self: start; }
  .np-pad-clip { width: 100%; }
  .np-pad-arrow { display: none; }
}

/* ---------------------------------------------- project: ads from trash */
/* Same system as the two project pages above — same type, same nav/title-art
   sizing, its own block so any of the three can drift from the others. */
.proj-aft {
  --aft-ink: #f2f2ea;
  --aft-copy: 1.411vw;
  font-family: 'CossetteTexte', "Helvetica Neue", Helvetica, Arial, sans-serif;
  max-width: none;
  padding: 0 7.031vw 6.3vw;
  font-size: var(--aft-copy);
}
.proj-aft .proj-head { margin-top: 3.07vw; }
#project:has(.proj-aft) #project-close { display: none; }
.proj-aft .proj-nav { font-size: var(--aft-copy); margin: 0 -0.729vw; }
.proj-aft .proj-nav-link { gap: 0.573vw; padding: 0 0.104vw; }
.proj-aft .proj-nav-link svg { width: 2.292vw; height: 1.771vw; }
.proj-aft .proj-nav-hands { padding: 0; margin: 0; }
.proj-aft .proj-nav-hands .back-in { gap: 1.823vw; }
.proj-aft .proj-nav-hands .hand { width: 6.615vw; }
/* Sized off the lettering, not off the width — the same rule Numpad Jam's
   title carries: all three titles are the same hand at the same size in the
   artwork, so what has to match between pages is how tall the letters stand,
   which means each title's width scales with its own artwork's width.
   BACK IN SMOOTHLY is 449 units wide at 42.14vw and NUMPAD JAM 321 at
   30.75vw — ~0.094vw per unit. ADS FROM TRASH is 385, hence 36.5vw. */
.proj-aft .proj-title-art { width: 36.5vw; margin: 0 auto; }

.aft-idea {
  font-weight: 700;
  font-size: var(--aft-copy);
  line-height: 1.15;
  max-width: 34vw;
  margin: 0 auto;
  color: var(--aft-ink);
}

.proj-aft .aft-b-grid { margin-top: 4.58vw; }

/* Nine equal squares. The columns are `1fr` and each cell is forced to
   aspect-ratio 1, so the grid stays square at every width instead of
   inheriting whatever the images happen to measure — they are already
   cropped square, and this is what keeps that true if one is ever
   swapped for a shot that isn't. */
.aft-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 1.25vw;
}
.aft-cell {
  margin: 0;
  aspect-ratio: 1 / 1;
  overflow: hidden;
  background: #0a0a0a;
}
/* cover, not contain: the crop is already the composition, so this only
   absorbs the sub-pixel difference between the box and the file */
.aft-cell img { display: block; width: 100%; height: 100%; object-fit: cover; }

@media (max-width: 720px) {
  .proj-aft { padding: 24px 6vw 110px; --aft-copy: 14px; }
  .proj-aft .proj-head { margin-top: 26px; }
  .proj-aft .proj-nav { margin: 0; }
  .proj-aft .proj-nav-link { gap: 9px; }
  .proj-aft .proj-nav-link svg { width: 22px; height: 17px; }
  .proj-aft .proj-nav-hands .back-in { gap: 26px; }
  .proj-aft .proj-nav-hands .hand { width: 48px; }
  .proj-aft .proj-title-art { width: 61.5vw; }   /* 72vw scaled the same way */
  .aft-idea { max-width: 100%; }
  .proj-aft .aft-b-grid { margin-top: 26px; }
  /* still three across: the grid IS the layout here — the ring of corners,
     sides and centre only reads as one while it stays 3x3 */
  .aft-grid { gap: 8px; }
}

/* a campaign reached by prev/next before it has a project of its own —
   the same title art + COMING SOON the landing shows, replayed inside
   #project so lateral nav never has to know whether the target is built */
.proj-soon { display: flex; flex-direction: column; min-height: 100vh; }
.proj-soon-body { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; }
.proj-soon-body img { width: clamp(220px, 34vw, 420px); height: auto; }
.proj-soon .soon {
  margin-top: 26px;
  font-size: 22px;
  letter-spacing: 2px;
  color: #f2f2ea;
  opacity: .75;
}

/* --------------------------------------------------------------- boot */
body.booting #stage { opacity: 0; }
