/* ---------------------------------------------------------------- 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;
}

/* Sized by `applyCam`, not by `inset: 0`. A fixed box pinned to all four sides
   is the *layout* viewport, and every number in app.js comes from
   `innerHeight`, which is the *visual* one — on a phone whose browser bar
   overlays the page those are two different heights, so the whole composition
   ends up centred on a taller box than the one it was measured for and anything
   held against the bottom of the window lands below the bottom you can see.
   Giving this box the same height the maths uses makes them one rectangle
   again. `100dvh` is only the value before script runs. */
#fit {
  position: fixed;
  left: 0;
  top: 0;
  width: 100%;
  height: 100dvh;
  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;
  /* Scale, then pan: the pan is applied inside the scale so it is written in
     the drawing's own units. `--cam-*` is how far the drawing moves to put the
     point the camera is on in the middle of the window — see `fit` in app.js. */
  transform: translate(-50%, -50%) scale(var(--scale))
             translate(var(--cam-x, 0px), var(--cam-y, 0px));
  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; }
/* The & page's picture is the two halves behind this box, not inside it, so it
   cannot lay its own black over them. The stage under it is black anyway, which
   is what the page's background is for everywhere else. */
#page.page-and { background: none; }
/* A page's plate is the whole drawing, never a crop of it: a member stands
   against one edge of his own frame and the & is two faces at both edges, so
   there is nothing here that can be cut off the sides the way the landing's
   arms can. `--pg-*` is that drawing fitted inside the window (see `fit` in
   app.js) — and everything registered to it below goes through the same three
   numbers, so type stays nailed to the photograph it was measured against. */
#page-img {
  position: absolute;
  left: var(--pg-x, 0px);
  top: var(--pg-y, 0px);
  width: calc(1920px * var(--pg-k, 1));
  height: calc(1080px * var(--pg-k, 1));
  image-rendering: pixelated;
}
#and-copy, .member-copy {
  transform-origin: 0 0;
  transform: translate(var(--pg-x, 0px), var(--pg-y, 0px)) scale(var(--pg-k, 1));
}

/* 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. The edge
   is the plate's own, `--pg-*`, not the window's: the arm is cut off by the
   picture it reaches out of, and on a window shaped differently from the
   drawing there is black between the two. */
#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;
}
/* `pointer-events: none` is what makes this control work with a finger. The
   beckon below hides whichever drawing is not the current one, and on touch the
   element under the finger at touchstart *becoming hidden before touchend* is
   how a browser decides no click happened — so pressing the hands themselves
   started the beckon and then went nowhere, while pressing the padding beside
   them or the gap between them worked, because those target something the
   beckon never touches. The drawings are not the control; the button is. (The
   landing overlay says the same thing about its own art, in `#ui img`.) */
.hand img {
  display: block;
  image-rendering: pixelated;
  position: absolute;
  left: 0;
  top: 0;
  visibility: hidden;
  pointer-events: none;
}
.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: var(--pg-x, 0px);
  top: 50%;
  transform: translateY(-50%);
}
#page.back-left .h-salva { display: block; }

#page.back-right #back {
  left: auto;
  right: var(--pg-x, 0px);
  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; }

/* The & is the only page the landing sits below, and its plate now fills the
   stage, so the hands hang off the stage's own bottom edge — clamped to the
   window on anything short enough to crop it. */
#page.back-bottom #back {
  left: 50%;
  top: auto;
  bottom: max(0px, var(--view-b, 0px));
  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: calc(var(--pg-y, 0px) - 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; }

/* Placed by `layoutAndFaces` in app.js — the word belongs between the two
   halves of the frame, and where that is depends on where they are. */
#and-word {
  position: absolute;
  display: none;
  /* the drawing's own size for the word: its three frames are stacked on top of
     each other inside it, so the box has to be given the size they are */
  width: 521px;
  height: 223px;
  transform-origin: 0 0;
}
#page.page-and #and-word { display: block; }
.magic img { position: absolute; left: 0; top: 0; width: 521px; height: 223px; opacity: 0; animation: boil3 .39s steps(1, end) infinite; }
.magic img:nth-child(2) { animation-delay: .13s; }
.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. */
/* The frame as two halves. Side by side at their drawn size they are the plate
   exactly — the seam falls in the black gap between the two faces, so there is
   nothing there to give it away — and on a portrait window they travel to
   opposite corners. Both transforms are written by `layoutAndFaces` in app.js;
   each half carries its own copy of the plate, cropped to its half by the
   overflow, and its own eye. */
#and-faces { position: absolute; inset: 0; display: none; pointer-events: none; }
/* the halves take the plate's place for the whole of the & move, so the frame
   can come apart inside it — see `setAndOpen` in app.js */
#stage.and-open #and-faces { display: block; }
#stage.and-open #plate { visibility: hidden; }
#page.page-and #page-img { display: none; }
.and-face {
  position: absolute;
  left: 0;
  top: 0;
  width: 960px;
  height: 1080px;
  overflow: hidden;
  transform-origin: 0 0;
}
/* each half holds only its own half of the frame, so the two of them together
   paint exactly what the plate paints */
.af-plate {
  position: absolute;
  left: 0;
  top: 0;
  width: 960px;
  height: 1080px;
  image-rendering: pixelated;
}

.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: 783px; top: 417px; }   /* 1743 in the frame, less its half */
.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-faces.dressed[data-l="star"]  .eye-l .s-star,
#and-faces.dressed[data-r="star"]  .eye-r .s-star,
#and-faces.dressed[data-l="heart"] .eye-l .s-heart,
#and-faces.dressed[data-r="heart"] .eye-r .s-heart,
#and-faces.dressed[data-l="flame"] .eye-l .s-flame,
#and-faces.dressed[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.

     24 units of a frame scaled to the window is 24px only on a window the
     frame's own size. On a small landscape one it is eleven, so the size has a
     floor in real screen pixels (`--inv` is the stage's scale inverted, so
     `14.5px * var(--inv)` is 14.5px on the glass) and a ceiling above it: past
     29 units the block outgrows the negative space it was measured into. */
  font-size: clamp(24px, calc(14.5px * var(--inv, 1)), 29px);
  line-height: 1.17;     /* 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: clamp(21px, calc(12.7px * var(--inv, 1)), 25px);
  line-height: 1.19;
}
.member-cv dt {
  float: left;
  /* 138 units at the CV's drawn 21px — kept in em so the gutter grows with the
     type when the floor above raises it on a small window */
  width: 6.571em;
  letter-spacing: .076em;
  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 6.571em;
}
.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;
}

/* ------------------------------------------------- member: portrait */
/* A window taller than it is wide has no negative space to set a CV into: the
   whole point of the two member frames is a figure standing on one side of a
   1.9:1 photograph, and on a phone that photograph is a strip. So the frame is
   reopened rather than shrunk: the camera travels to the figure (`memberCam` in
   app.js, and the move that opens the page carries it there, so nothing snaps
   on arrival) and the bio and CV run under him as one column that scrolls — the
   same copy, in the same voice, read the way a phone is read.

   Sizes are written `calc(Npx * var(--inv))`: the stage is scaled to the window
   and `--inv` is that scale inverted, so N is N real pixels on the glass. That
   is what lets type inside the stage be set at a reading size instead of at
   whatever the drawing's scale happens to leave it. */
body.narrow #pita-copy,
body.narrow #salva-copy {
  transform: none;
  left: var(--view-x);
  top: var(--copy-top, calc(var(--view-y) + var(--view-h) * .42));
  width: var(--view-w);
  height: var(--copy-h, calc(var(--view-h) * .58));
  /* The measured insets that hold each column clear of its own figure are a
     fact about the 1920 frame and mean nothing here. What replaces them is a
     screen edge and a measure: 22px of margin on a phone, and on anything wider
     whatever it takes to hold the column to 560px, so a tablet gets a line
     someone can read across rather than one that runs the width of the glass. */
  padding: calc(20px * var(--inv))
           max(calc(22px * var(--inv)),
               calc((var(--view-w) - 560px * var(--inv)) / 2))
           calc(48px * var(--inv));
  overflow-y: auto;
  overscroll-behavior: contain;
  font-size: calc(clamp(15px, 4.1vw, 19px) * var(--inv));
  line-height: 1.34;
}
/* his own silhouette is not the right margin of anything any more */
body.narrow .member-shape { display: none; }
body.narrow #page.page-salva #salva-copy { display: block; }
body.narrow .member-bio { margin-bottom: calc(26px * var(--inv)); }

body.narrow .member-cv {
  font-size: calc(clamp(14px, 3.7vw, 17px) * var(--inv));
  line-height: 1.32;
}
/* The gutter the labels hang in is 138 units of a 1920 frame; there is no
   gutter on a phone, so the label sits over its entries instead of beside
   them — the same colophon, stacked. */
body.narrow .member-cv dt {
  float: none;
  width: auto;
  margin-bottom: calc(3px * var(--inv));
}
body.narrow .member-cv dd { margin-left: 0; }
body.narrow .cv-group + .cv-group { margin-top: calc(15px * var(--inv)); }

/* The hand reaching in for the way back. On a portrait page the frame it
   reaches out of is the window, not the plate — the camera is pointed at a
   corner of the drawing and the plate's own edge is somewhere off screen — so
   it is held against the window's edge, at the height of the photograph rather
   than the middle of a page that is now mostly type. */
body.narrow #page.page-pita #back,
body.narrow #page.page-salva #back {
  top: calc(var(--view-y) + var(--view-h) * .24);
}
body.narrow #page.page-pita #back { left: var(--view-x); }
body.narrow #page.page-salva #back { right: var(--view-r); }
/* At the scale that frames a figure on a phone the drawn hand is a 56x36
   control, which is smaller than the thumb going for it. Sized off `--back-hand`
   instead — the palm width every one of these controls shares — which for this
   pair, cut lengthwise, means multiplying it back out to the drawing's 150x96. */
body.narrow #page.page-pita .hand img,
body.narrow #page.page-salva .hand img {
  width: calc(var(--back-hand, 48px) * 150 / 96 * var(--inv));
  height: auto;
}

/* On the & the picture is laid out above a strip of black kept clear for this
   (AND_FOOT in app.js) and the hands stand at the top of it: centred, on
   nothing, and hung from the bottom edge of the picture rather than from the
   bottom of the window. That last part is the whole point — the bottom of the
   window on a phone is where the browser draws its own bar, and a control
   sitting there is one you have to reach past a toolbar to press. Held to the
   picture instead, the rest of the strip stays clear underneath it.
   Sized in real pixels, because at the scale that fits both faces on a phone
   the drawn hand is nineteen of them. */
body.narrow #page.page-and #back {
  top: calc(var(--and-foot) + 26px * var(--inv));   /* BACK_GAP in app.js */
  bottom: auto;
}
/* Cut across rather than lengthwise, so here the palm is the width. The strip
   of black this stands in is measured off the same number, so the two cannot
   drift apart. */
body.narrow #page.page-and .hand img {
  width: calc(var(--back-hand, 48px) * var(--inv));
  height: auto;
}
body.narrow #page.page-and .back-in { gap: calc(16px * var(--inv)); }

/* ------------------------------------------------------------ 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. */
/* Sized by `applyCam` alongside #fit, and for the same reason: a fixed box
   pinned to all four sides is the layout viewport, which on a phone whose
   browser bar overlays the page is taller than the one you can see. A control
   at the top of this layer then sits somewhere other than where it looks like
   it is. `100dvh` is only the value before script runs. */
#project {
  position: fixed;
  left: 0;
  top: 0;
  width: 100%;
  height: 100dvh;
  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;
  /* the close control is the first thing in here, and on a phone with a notch
     the first thing in here is otherwise under it */
  padding-top: env(safe-area-inset-top, 0px);
  padding-bottom: env(safe-area-inset-bottom, 0px);
}

#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; }
/* Every project renders the reaching hands in its nav, and that is the way out
   of a project — so the cross is the one for a page that somehow has no hands.
   Stated once, off the hands themselves, rather than page by page: the shared
   COMING SOON page was the one nobody remembered to write a rule for, and it
   was the only page on the site offering two ways to close itself. */
#project:has(.proj-nav-hands) #project-close { display: none; }

.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: 1000px) {
  /* 24 to match the three built pages: the close hands are hung off the top of
     the page by cancelling exactly this, so it has to be one number */
  .proj { padding: 24px 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). */
/* Three columns with the outer two equal, not a flex row spread apart: with
   `space-between` the close control lands wherever the two labels leave it, so
   it sits off centre by however much "ads from trash" differs from "numpad
   jam". The hands are the middle of this page and belong in the middle of it. */
.proj-nav {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  font-size: clamp(13px, 1.6vw, 17px);
  color: #f2f2ea;
}
.proj-nav > :first-child { grid-column: 1; justify-self: start; }
.proj-nav-hands { grid-column: 2; }
.proj-nav > .next { grid-column: 3; justify-self: end; }
/* a ring end with no neighbour renders an empty span; it must not take a cell */
.proj-nav > span:empty { display: none; }
.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; }

/* `.proj-arrow` is every hand-drawn pointer doodle on a project page — the ones
   trailing a caption into its media, the ones that crossed a gutter, the curl
   onto the QR. On a phone the page is one column and each caption already sits
   right against the thing it points at, so the arrows do no work and only add
   height. Drop them all below the breakpoint; the in-project nav (its own
   chevrons, not `.proj-arrow`) and the emphasis circles stay. (!important
   because a couple sit in boxes whose own rule sets `svg { display: block }`.) */
@media (max-width: 1000px) { .proj-arrow { display: none !important; } }

/* 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. */
.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: 18px; transform: rotate(180deg); }
/* One control at one size wherever it appears (see the #back note): the pair
   cut across, so the palm is the width — `--back-hand`, the same number the &
   and the member pages use. No project gives it a size of its own. */
.proj-nav-hands .hand { display: block; }
.proj-nav-hands .hand img { width: var(--back-hand, 48px); 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. */
/* shown at the size it is on the landing — the campaign label's own drawn
   width over the 1920 stage (ui.json w / 19.2, in vw) — not grown. Each
   project sets its own; this is the fallback for the shared COMING SOON. */
.proj-title-art { display: block; width: 18vw; min-width: 180px; 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 */

/* 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-title-art { width: 23.385vw; 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, and it is still only
   14 at 1000px, which is where the copy would have to start shrinking below
   what anyone reads — so the whole page falls off the drawing's proportions and
   back onto readable px here. */
@media (max-width: 1000px) {
  .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-title-art { width: min(40vw, 344px); }
  .bis-subtitle { max-width: 100%; }
  .bis-sticker-row { margin: 0; }
  .bis-desc { margin: 26px 0 18px; gap: 10px; }
  .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 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-qr { width: 96px; }
  /* every pointer arrow on the page is dropped here (.proj-arrow) — the
     emphasis circle on "this" stays; each caption sits right on its media */
  .bis-caption { white-space: normal; justify-content: flex-start; }
}

/* ------------------------------------------------ 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; }
.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; }
/* 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: 16.719vw; 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 {
  /* 19px @1920. The floor is what keeps this a label rather than a texture
     on a laptop: below about 1300px the vw size drops under the smallest a
     player's own track title is ever set. */
  font-size: max(0.99vw, 13px);
  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: max(0.677vw, 11px);   /* 13px @1920, floored — see .np-jam-name */
  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: max(0.625vw, 10px);   /* 12px @1920, floored */
  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: max(0.677vw, 11px);   /* 13px @1920, the store's own size, floored */
  color: #666;
  text-align: center;
}
.np-shop-price {
  margin-top: 0.25vw;
  font-family: Arial, "Helvetica Neue", Helvetica, sans-serif;
  font-size: max(0.552vw, 10px);   /* 10.6px @1920, the store's own size, floored */
  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: 1000px) {
  .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-title-art { width: min(29vw, 246px); }
  .numpad-idea { max-width: 100%; }
  .proj-numpad .np-b-case { margin-top: 26px; }
  .np-case { margin: 0 0 18px; gap: 10px; }
  .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; }
.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; }
/* 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: 20.052vw; 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: 1000px) {
  .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-title-art { width: min(34vw, 292px); }
  .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, and down to about
     720 a cell is still 200px, which is enough to read the headline on the
     garment. Below that it is not; see the next block. */
  .aft-grid { gap: 8px; }
}

/* One at a time, the width of the glass. Three across is 118px a side on a
   phone: a thumbnail of an ad rather than an ad, and the headline printed on
   the garment is the whole work. So on a phone the ring is unwound into a
   column in `--m`'s order — each garment hung, then the stitching on it read
   close, then the next, and the pile of all four of them last — full-bleed out
   through the page's own margin. The DOM stays in ring order, so nothing about
   the wide layout moves. The scroll runs free — no snap: settling the column
   on each photograph in turn just fought the reader moving down the page. */
@media (max-width: 720px) {
  .proj-aft .aft-b-grid { margin-left: -6vw; margin-right: -6vw; }
  .aft-grid { grid-template-columns: 1fr; gap: 10px; }
  .aft-cell { order: var(--m, 0); }
}

/* 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: 100%; }
.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;
}

/* ----------------------------------------------- project: surf the spike */
/* Set to update/surf_the_spike/layout.pdf. That file is drawn at 1920 wide,
   which is the width these pages are laid out against, so its measurements are
   used directly: 1 unit = 1px at 1920 = 1/19.2 vw. The column is 1610 units
   inside 155-unit margins (8.073vw), and every block in it is that full width
   — one caption, one piece of media, down the page — with the placements last
   as a flush 2x2.

   Each caption's arrow is placed by its own numbers rather than a shared rule.
   They are not decoration in a row of text: each one leaves the words at a
   particular point and lands on a particular part of the picture under it, and
   in the layout that means each sits at its own height against its own line.

   The vertical rhythm is regularised, which the layout is not: measured off it
   the gaps between one block's picture and the next block's caption came out
   124, 152 and 71, and the page read as unevenly distributed rather than as
   deliberately varied. Two numbers now do all of it — `--sts-gap` between
   blocks and `--sts-lead` from a caption to the picture it introduces — with
   one exception, the caption under the placements, which hugs them because its
   hooks reach back UP into the photographs. */
.proj-sts {
  --sts-ink: #f2f2ea;
  --sts-copy: 1.411vw;
  --sts-gap: 5.2vw;          /* between blocks: last picture to next caption */
  --sts-lead: 1.406vw;       /* a caption to the picture directly under it */
  font-family: 'CossetteTexte', "Helvetica Neue", Helvetica, Arial, sans-serif;
  max-width: none;
  padding: 0 8.073vw 6.3vw;
  font-size: var(--sts-copy);
}
.proj-sts .proj-head { margin-top: 3.07vw; }
.proj-sts .proj-nav { font-size: var(--sts-copy); margin: 0 -0.729vw; }
.proj-sts .proj-nav-link { gap: 0.573vw; padding: 0 0.104vw; }
.proj-sts .proj-nav-link svg { width: 2.292vw; height: 1.771vw; }
/* Sized off the artwork, not off the string — ~0.094vw per unit of its own
   width, the rule the other three titles carry, which is what puts every
   campaign title on the site at the same stage scale whatever it says.
   SURF THE SPIKE is 437 units wide, hence 41.1vw. The second drawn line's
   art is taller than the first's for the same apparent size (a finer,
   more condensed hand — see tools/build_titles.py), so this title's letters
   stand taller than BACK IN SMOOTHLY's while reading level with them, which
   is exactly what they do on the landing. */
.proj-sts .proj-title-art { width: 20.48vw; margin: 0 auto; }  /* balanced to the l1 titles' cap height, not camp_l2's taller 100u */

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

/* The shared .proj-media speckle edge is off here, the same call the other
   three project pages make: it is tuned for the site's own 1-bit art on
   black, and around a bright still — a white app screen, a lit shop window —
   it reads as a dirty border rather than a printed one. */
.proj-sts .proj-media { mask-image: none; }

/* the caption line: words and the arrow that leaves them, aligned to their
   tops rather than centred, because the arrow's job is to hang below the line
   and drop into the picture, not to sit level with the words */
.sts-cap {
  display: flex;
  align-items: flex-start;
  gap: 0.885vw;              /* 17 units, the layout's own gap either side */
  flex-wrap: nowrap;
  font-size: var(--sts-copy);
  line-height: 1.2;
  color: var(--sts-ink);
  text-align: left;
  margin: 0;
}
.sts-cap svg { flex: none; overflow: visible; color: var(--sts-ink); }
.sts-cap span { flex: none; }

.proj-sts .proj-block { margin: 0; }
.proj-sts .sts-b-case { margin-top: 4.58vw; }
.proj-sts .sts-b-scan,
.proj-sts .sts-b-ui,
.proj-sts .sts-b-out { margin-top: var(--sts-gap); }
/* the closing nav is the page's last line and needs the same air the first
   block gets under the title — without this it sits straight under the
   placements' caption, reading as part of it */
.proj-sts > .proj-nav:last-child { margin-top: 4.58vw; }

.sts-cap-case { margin-bottom: var(--sts-lead); }
.sts-cap-case svg { width: 3.854vw; height: 1.615vw; margin-top: 0.208vw; }

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

/* The artwork is drawn inside a 1610x906 field with the three phones sitting
   in the middle of it, which put 123 units of empty black above them and 109
   below. Over a black page that is not margin, it is a hole: it doubled the
   gap after the video case and left the caption stranded in the middle of it.
   So the file is trimmed to the phones (plus 6 units of air) and the caption
   comes out of the artwork and back onto the line above it, where every other
   caption on this page sits. Right-aligned, because the arrow has to leave the
   words travelling left to land on the third phone. */
.sts-scan img { display: block; width: 100%; height: auto; }
.sts-cap-scan { justify-content: flex-end; margin-bottom: var(--sts-lead); }
.sts-cap-scan svg { width: 8.75vw; height: 4.583vw; }

.sts-cap-ui { margin-bottom: var(--sts-lead); }
.sts-cap-ui svg { width: 6.667vw; height: 3.021vw; margin-top: 0.313vw; }

/* the clip is shown whole, at the ratio the UI was recorded in */
.sts-clip video { width: 100%; height: auto; display: block; }

/* both grid captions are centred on the page, with an arrow off each end */
.sts-cap-shops, .sts-cap-vend { justify-content: center; }
.sts-cap-shops { margin-bottom: var(--sts-lead); }
.sts-cap-shops svg:first-child { width: 5.833vw; height: 2.396vw; margin-top: 0.781vw; }
.sts-cap-shops svg:last-child { width: 6.563vw; height: 2.5vw; margin-top: 0.833vw; }

/* Flush, deliberately: no gutter between the four. The two shops and the two
   machines are one wall of places the message was put, and a gap between them
   would make them four pictures of four places instead. */
.sts-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0; }
.sts-cell { margin: 0; }
.sts-cell img { display: block; width: 100%; height: auto; }

/* and this one sits right on the bottom edge of the photographs, its hooks
   reaching back up into them — hence the negative offsets */
.sts-cap-vend { margin-top: 0.677vw; }
.sts-cap-vend svg:first-child { width: 3.229vw; height: 2.292vw; margin-top: -0.625vw; }
.sts-cap-vend svg:last-child { width: 2.396vw; height: 2.292vw; margin-top: -0.573vw; }

@media (max-width: 1000px) {
  .proj-sts { padding: 24px 6vw 110px; --sts-copy: 14px; }
  .proj-sts .proj-head { margin-top: 26px; }
  .proj-sts .proj-nav { margin: 0; }
  .proj-sts .proj-nav-link { gap: 9px; }
  .proj-sts .proj-nav-link svg { width: 22px; height: 17px; }
  .proj-sts .proj-title-art { width: min(34.6vw, 300px); }
  .sts-idea { max-width: 100%; }
  .proj-sts { --sts-gap: 34px; --sts-lead: 12px; }
  .proj-sts .sts-b-case { margin-top: 30px; }
  .proj-sts > .proj-nav:last-child { margin-top: 34px; }

  /* Every caption is just its line of type here (the arrows are dropped, see
     .proj-arrow) and the long ones have to wrap — at 390 the longest runs to
     three lines, and a span that cannot shrink runs off the right edge. */
  .sts-cap { flex-wrap: wrap; gap: 10px; align-items: center; }
  .sts-cap span { flex: 0 1 auto; min-width: 0; }
  .sts-cap-case { margin-bottom: var(--sts-lead); }
  .sts-cap-ui { margin-bottom: var(--sts-lead); }
  /* scan / shops / vend all carried arrows that pointed across the wide
     layout (back onto the third phone, out to a photo on each side). Stacked,
     every one of those captions sits directly against its picture, so the
     arrows are dropped (.proj-arrow) and the lines just left-set like the
     rest. */
  .sts-cap-scan { justify-content: flex-start; }
  .sts-cap-shops { margin-bottom: var(--sts-lead); }
  .sts-cap-vend { margin-top: 12px; }
  /* one at a time: a shop window is 170px wide in two columns on a phone, and
     the poster in that window is the work */
  .sts-grid { grid-template-columns: 1fr; }
}

/* ------------------------------------------------- project: tracking life */
/* Set to update/tracking_life/layout.pdf, read the same way Surf the Spike's
   is: the file is drawn at 1920, so 1 unit = 1px at 1920 = 1/19.2 vw, and the
   column is 1610 units inside 155-unit margins. Three blocks — the film, the
   pair of clips, the recap — each a caption and the media its arrow points at.

   The vertical rhythm is this page's sibling's, not this file's: measured off
   the PDF the blocks nearly touch (23 units under the film, 7 under the next
   caption), which reads as cramped on a screen and disagrees with the page
   next door. Same two numbers as Surf the Spike do all of it. */
.proj-tl {
  --tl-ink: #f2f2ea;
  --tl-copy: 1.411vw;
  --tl-gap: 5.2vw;           /* between blocks: last picture to next caption */
  --tl-lead: 1.406vw;        /* a caption to the picture directly under it */
  font-family: 'CossetteTexte', "Helvetica Neue", Helvetica, Arial, sans-serif;
  max-width: none;
  padding: 0 8.073vw 6.3vw;
  font-size: var(--tl-copy);
}
.proj-tl .proj-head { margin-top: 3.07vw; }
.proj-tl .proj-nav { font-size: var(--tl-copy); margin: 0 -0.729vw; }
.proj-tl .proj-nav-link { gap: 0.573vw; padding: 0 0.104vw; }
.proj-tl .proj-nav-link svg { width: 2.292vw; height: 1.771vw; }
/* TRACKING LIFE is 425 units wide at the site-wide ~0.094vw per unit */
.proj-tl .proj-title-art { width: 19.92vw; margin: 0 auto; }   /* same — matched to the l1 group's height */

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

/* as on every other project page: the speckle edge is tuned for 1-bit art on
   black and reads as a dirty border around a photograph */
.proj-tl .proj-media { mask-image: none; }

.tl-cap {
  display: flex;
  align-items: flex-start;
  gap: 0.885vw;
  flex-wrap: nowrap;
  font-size: var(--tl-copy);
  line-height: 1.2;
  color: var(--tl-ink);
  text-align: left;
  margin: 0;
}
.tl-cap svg { flex: none; overflow: visible; color: var(--tl-ink); }
.tl-cap span { flex: none; }

.proj-tl .proj-block { margin: 0; }
.proj-tl .tl-b-case { margin-top: 4.58vw; }
.proj-tl .tl-b-light,
.proj-tl .tl-b-recap { margin-top: var(--tl-gap); }
.proj-tl > .proj-nav:last-child { margin-top: 4.58vw; }

.tl-cap-case { margin-bottom: var(--tl-lead); }
.tl-cap-case svg { width: 3.438vw; height: 1.771vw; margin-top: 0.26vw; }

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

/* the clips are shown whole, at the ratio they were recorded in */
.tl-clip video { width: 100%; height: auto; display: block; }

/* centred over the pair, an arrow into each clip */
.tl-cap-light { justify-content: center; margin-bottom: var(--tl-lead); }
.tl-cap-light svg:first-of-type { width: 6.354vw; height: 2.708vw; margin-right: -0.469vw; }
/* This one is drawn coming out from under the words, not after them: it starts
   below the middle of the line and sweeps out to the right-hand clip. Hence the
   pull back over the text — 122 units of it, which is where the layout starts
   the stroke — and the drop below the line. */
.tl-cap-light svg:last-of-type { width: 5.625vw; height: 1.667vw; margin-left: -6.354vw; margin-top: 1.458vw; }

/* two 16:9 cells with the layout's own 20-unit gutter between them */
.tl-duo { display: grid; grid-template-columns: 1fr 1fr; gap: 1.042vw; }

/* set to the right of the column, because its arrow has to leave the words
   travelling left to come down on the recap */
.tl-cap-recap { justify-content: flex-end; margin-bottom: var(--tl-lead); }
.tl-cap-recap svg { width: 3.958vw; height: 2.188vw; margin-top: 0.677vw; }

@media (max-width: 1000px) {
  .proj-tl { padding: 24px 6vw 110px; --tl-copy: 14px; --tl-gap: 34px; --tl-lead: 12px; }
  .proj-tl .proj-head { margin-top: 26px; }
  .proj-tl .proj-nav { margin: 0; }
  .proj-tl .proj-nav-link { gap: 9px; }
  .proj-tl .proj-nav-link svg { width: 22px; height: 17px; }
  .proj-tl .proj-title-art { width: min(33.75vw, 292px); }
  .tl-idea { max-width: 100%; }
  .proj-tl .tl-b-case { margin-top: 30px; }

  /* every caption is just its line of type here (arrows dropped, see
     .proj-arrow); the long one has to wrap rather than run off the edge */
  .tl-cap { flex-wrap: wrap; gap: 10px; align-items: center; }
  .tl-cap span { flex: 0 1 auto; min-width: 0; }
  /* the light-up caption's two arrows and the recap arrow all pointed across
     the wide layout; stacked, each caption is already right against its clip,
     so they're dropped (.proj-arrow) and the lines left-set */
  .tl-cap-recap { justify-content: flex-start; }
  /* one clip at a time — half of 342 is a thumbnail of a phone screen */
  .tl-duo { grid-template-columns: 1fr; gap: 10px; }
}

/* ------------------------------------------------- project: pick a side */
/* Built to update/pick_a_side/layout.pdf, drawn at 1920 (1 unit = 1px =
   1/19.2 vw), same as Surf the Spike and Tracking Life. Four transparent
   animated-WebP loops (tools/build_pas.sh) sit on the page's own black — no
   .proj-media speckle, no fill behind them. Video case up top, then the two
   kiosk screens side by side, then the carton and the app. */
.proj-pas {
  --pas-ink: #f2f2ea;
  --pas-copy: 1.411vw;
  --pas-gap: 5.2vw;          /* last media to next caption */
  --pas-lead: 1.406vw;       /* a caption to the media under it */
  font-family: 'CossetteTexte', "Helvetica Neue", Helvetica, Arial, sans-serif;
  max-width: none;
  padding: 0 8.073vw 6.3vw;
  font-size: var(--pas-copy);
}
.proj-pas .proj-head { margin-top: 3.07vw; }
.proj-pas .proj-block { margin: 0; }
.proj-pas .pas-b-case { margin-top: 4.58vw; }
.proj-pas .pas-b-kiosk,
.proj-pas .pas-b-after { margin-top: var(--pas-gap); }
.proj-pas > .proj-nav:last-child { margin-top: 4.58vw; }
.proj-pas .proj-title-art { width: 17.083vw; margin: 0 auto; }  /* camp_l1_0, 328u / 19.2 */
.pas-idea {
  font-weight: 700;
  font-size: var(--pas-copy);
  line-height: 1.15;
  max-width: 40vw;
  margin: 0 auto;
  color: var(--pas-ink);
}

/* the loops carry their own alpha — kill the shared media dressing so they
   float on the page instead of sitting in a lit box */
.proj-pas .proj-media {
  background: transparent;
  mask-image: none;
  overflow: visible;
}
.pas-anim img { display: block; width: 100%; height: auto; }

.pas-cap {
  display: flex;
  align-items: flex-start;
  gap: 0.885vw;
  flex-wrap: nowrap;
  font-size: var(--pas-copy);
  line-height: 1.2;
  color: var(--pas-ink);
  text-align: left;
  margin: 0;
}
.pas-cap svg { flex: none; overflow: visible; color: var(--pas-ink); }
.pas-cap span { flex: none; }

/* video case: caption at the column edge, iframe full width */
.pas-cap-case { margin-bottom: var(--pas-lead); }
.pas-cap-case svg { width: 3.958vw; height: 1.979vw; margin-top: 0.26vw; }
.pas-yt { position: relative; aspect-ratio: 16 / 9; }
.pas-yt iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; }

/* the two kiosks: a column each, caption then screen. In the layout the left
   screen is ~514 units wide and its left edge is 119 units into the column;
   the right is ~530 wide, 935 units in — so a plain 1fr 1fr grid with each
   column padded to put its kiosk where the drawing has it. */
.pas-duo { display: grid; grid-template-columns: 1fr 1fr; gap: 3vw; }
.pas-col { display: flex; flex-direction: column; align-items: flex-start; }
.pas-col-order { padding-left: 6.2vw; }
.pas-col-checkout { padding-left: 5.28vw; }
.pas-anim-order { width: 26.77vw; margin-top: 0.5vw; }
.pas-anim-checkout { width: 27.6vw; margin-top: 0.5vw; }

/* the C-hook hangs off the near side of the words and stands its head into the
   screen below, so it is given its full 80-unit height and left to overhang */
.pas-cap-order svg,
.pas-cap-checkout svg { width: 2.917vw; height: 4.167vw; margin-top: -0.3vw; margin-bottom: -2.6vw; }
.pas-cap-checkout { margin-left: 3.5vw; }

/* the carton and the app are placed, not gridded (see the markup note): the
   carton hangs lower-left, the phone stands upper-right, its caption tucked in
   at the phone's lower-left. Positions are vw off the 1920 drawing. */
.pas-b-after { position: relative; height: 46vw; }
.proj-pas .pas-b-after { margin-top: 8vw; }
.pas-b-after > * { position: absolute; }
.pas-cap-reminder { left: 2.9vw; top: 0; }
.pas-cap-reminder svg { width: 4.792vw; height: 3.542vw; margin-top: 1.2vw; }
.pas-anim-reminder { left: 2.5vw; top: 4vw; width: 33vw; }
.pas-anim-app { left: 52vw; top: 0; width: 21.8vw; }
/* the phone's caption is tucked in at its lower-left: block flow so the line
   wraps the way the drawing has it, with the arrow trailing the last word */
.pas-cap-app { left: 35vw; top: 37vw; display: block; width: 14.5vw; line-height: 1.25; }
.pas-cap-app span { display: inline; }
.pas-cap-app svg { display: inline-block; vertical-align: middle; width: 7.292vw; height: 2.083vw; margin-left: 0.6vw; }

@media (max-width: 1000px) {
  .proj-pas { padding: 24px 6vw 110px; --pas-copy: 14px; --pas-gap: 34px; --pas-lead: 12px; }
  .proj-pas .proj-head { margin-top: 26px; }
  .proj-pas .proj-nav { margin: 0; }
  .proj-pas .proj-nav-link { gap: 9px; }
  .proj-pas .proj-nav-link svg { width: 22px; height: 17px; }
  .proj-pas .proj-title-art { width: min(30vw, 200px); }
  .pas-idea { max-width: 100%; }
  .proj-pas .pas-b-case { margin-top: 30px; }

  /* one column: the kiosk grid collapses, the placed after-block goes back to
     normal flow, arrows dropped (.proj-arrow). Each caption is just its line
     of type directly above the loop it names. */
  .pas-cap { flex-wrap: wrap; gap: 10px; align-items: center; text-align: center; justify-content: center; }
  .pas-cap span { flex: 0 1 auto; }
  .pas-cap-checkout, .pas-cap-order svg, .pas-cap-checkout svg { margin: 0; }

  .pas-duo { grid-template-columns: 1fr; gap: 0; }
  .pas-col { align-items: stretch; }
  .pas-col-order { padding-left: 0; }
  .pas-col-checkout { padding-left: 0; margin-top: var(--pas-gap); }

  .pas-b-after { position: static; height: auto; display: flex; flex-direction: column; }
  .proj-pas .pas-b-after { margin-top: var(--pas-gap); }
  .pas-b-after > * { position: static; width: auto; }
  .pas-cap-app { flex-wrap: wrap; align-items: center; order: 1; margin-top: var(--pas-gap); }
  .pas-anim-app { order: 2; }

  .pas-anim-order, .pas-anim-checkout,
  .pas-anim-reminder, .pas-anim-app {
    width: min(78vw, 440px); margin-left: auto; margin-right: auto; margin-top: 12px;
  }
}

/* Narrow: the same three parts, stacked. Side by side there is not room for
   two campaign titles, two arrows and the hands on one line at 390, so the
   labels wrap — and they wrap in the nav that carries the hands and not in the
   one that doesn't, which is the top of the page disagreeing with the bottom of
   it. Given its own line the control is centred on the page rather than on
   whatever the two labels leave, and both navs set their titles the same way:
   one line each, one row, whatever the campaign is called.

   Written last and through `.proj-nav` so it outranks the three built pages'
   own nav sizing: the nav is the one part of a project page that is not the
   project's, and it should be the same control on all four of them — including
   the shared COMING SOON page, which has no sizing of its own at all. */
@media (max-width: 1000px) {
  .proj-nav {
    grid-template-columns: auto auto;
    grid-template-areas:
      "hands hands"
      "prev  next";
    font-size: 14px;
  }
  .proj-nav > :first-child { grid-area: prev; justify-self: start; }
  .proj-nav > .next { grid-area: next; justify-self: end; }
  /* These are the hanging cut of the hand — meant to come in over a top edge
     with the wrists cut off by it — so they hang off the top of the page rather
     than floating in a band of black below it. The negative margin cancels the
     page's own top padding; the safe-area inset on #project-scroll is left
     alone, so the edge they are cut by is the top of the readable page and
     never a notch.

     Padding, not size, is what makes them easy to press: the drawing stays the
     size it is on every other page and the button grows around it, and it grows
     downward, past the fingertips, because that is the end a thumb coming up
     the screen arrives at. */
  .proj-nav .proj-nav-hands {
    grid-area: hands;
    justify-self: center;
    padding: 4px 24px 28px;
    margin: -28px 0 0;
  }
  .proj-nav .proj-nav-hands .hand { width: var(--back-hand, 48px); }
  .proj-nav .proj-nav-hands .back-in { gap: 18px; }
  /* one line, and a row tall enough to be aimed at with a thumb */
  .proj-nav .proj-nav-link { white-space: nowrap; gap: 9px; padding: 8px 2px; }
  .proj-nav .proj-nav-link svg { width: 22px; height: 17px; }
}

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