/* ───────────────────────────────────────────────────────────
   samidesign.nl — landing
   One rule holds the whole page together: nothing is coloured
   except the dot. Break that rule and the idea dies.
   ─────────────────────────────────────────────────────────── */

:root {
  --paper:  #FBFAF8;   /* white, warmed a hair so it doesn't glare */
  --ink:    #0E0E0C;
  --muted:  #86847C;
  --rule:   #E4E1DA;
  --rule-2: #CFCBC1;   /* the hairline, one step up, for hover */
  --tint:   #F1EFEA;   /* the only fill on the page: a highlighted menu row */
  --live:   #FF3B00;   /* swap for #1500FF if you want ultramarine instead */

  --pad:    clamp(20px, 3.4vw, 46px);

  /* Helvetica Neue is the fallback that carries this design on macOS.
     If you license a grotesque (Diatype, Söhne, Neue Montreal), drop it
     in front — the tracking below is already tuned for that class. */
  --sans: "Helvetica Neue", "Neue Haas Grotesk Display Pro", Inter,
          -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
  --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, "Roboto Mono", monospace;

  --ease: cubic-bezier(.16, 1, .3, 1);
}

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

/* The page is exactly one screen. Locking both elements to the viewport and
   hiding overflow is what makes that literally true on iOS: without it Safari
   measures against the *large* viewport (toolbars retracted), the layout comes
   out taller than what you can actually see, and the email ends up below the
   fold with a scrollbar you were never meant to need. */
html, body {
  height: 100%;
  overflow: hidden;
}

html { -webkit-text-size-adjust: 100%; }

body {
  margin: 0;
  background: var(--paper);
  color: var(--ink);
  /* kills the ~300ms double-tap-zoom delay so taps register immediately.
     Deliberately not `none`: that would also disable pinch-zoom, and the
     touch fallbacks in script.js already cover the gesture-stealing case. */
  touch-action: manipulation;

  /* The page is exactly one viewport tall, so there is nothing to scroll —
     but a vertical drag can still trigger rubber-band overscroll, and the
     browser then claims the gesture and cancels the pointer stream
     mid-drag. This tells it there's nothing to hand the gesture to, so a
     thumb swipe reaches the dot instead of bouncing the page. */
  overscroll-behavior: none;
  font-family: var(--sans);
  font-synthesis: none;                  /* no faux bold on fallback faces */
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

::selection { background: var(--live); color: var(--paper); }

/* Cursor stays native — the dot is a companion, not a replacement.
   Hiding the real cursor is the tell of a portfolio that's showing off. */

/* ── stage ─────────────────────────────────────────────────
   Weight sits low. The air at the top is the composition,
   not empty space waiting to be filled.                      */

.stage {
  /* svh = the viewport with the browser chrome showing, which is the only
     height guaranteed to be visible. dvh matches it here (nothing scrolls, so
     the toolbars never retract) and keeps the fit exact if they ever do. vh
     is the pre-2022 fallback. */
  height: 100vh;
  height: 100svh;
  height: 100dvh;

  /* viewport-fit=cover lets the page fill the whole screen, which means the
     notch and home-indicator clearance is ours to handle. max() keeps the
     normal padding everywhere it's already enough, and yields to the inset
     only where the hardware actually intrudes. env() is 0 elsewhere. */
  padding:
    max(var(--pad), env(safe-area-inset-top))
    max(var(--pad), env(safe-area-inset-right))
    max(var(--pad), env(safe-area-inset-bottom))
    max(var(--pad), env(safe-area-inset-left));

  display: grid;
  grid-template-rows: auto auto 1fr auto auto;   /* top, rule, statement, rule, bottom */
  gap: 0;
}

.row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: clamp(24px, 6vw, 96px);
  padding-block: 0 14px;
}

/* the profile hangs off this row, and needs it as an anchor. No offsets, so
   it costs the layout nothing. */
.row--top { position: relative; }

.row--bottom {
  align-items: flex-end;
  padding-block: 18px 0;
}

.rule {
  display: block;
  height: 1px;
  background: var(--rule);
  transform-origin: left;
}

.js .rule { transform: scaleX(0); }

/* ── wordmark ──────────────────────────────────────────────
   Tight, low-contrast, all lowercase. The dot is a real
   element so it has a real position to fly home to.          */

.wordmark {
  font-size: clamp(19px, 2.1vw, 25px);
  font-weight: 700;
  letter-spacing: -0.045em;
  color: var(--ink);
  text-decoration: none;
  display: inline-flex;
  align-items: baseline;
  line-height: 1;
}

.wordmark__dot {
  width: 0.235em;
  height: 0.235em;
  margin-left: 0.085em;
  border-radius: 50%;
  background: var(--live);
  visibility: hidden;          /* placeholder: reserves space, marks home */
}

/* ── the tittle ────────────────────────────────────────────
   The dot on the i, rebuilt as an element so it can be knocked
   off. Every number here was measured off Helvetica Neue Bold's
   own i at 400px and divided down, so this is the glyph's
   geometry rather than an approximation of it.

   It contributes nothing to the flow: the negative left margin
   pulls it back over the stem and the right margin gives the
   space back, so the period after it lands where it always did.
   Vertical placement is `position: relative` rather than a
   margin, for the same reason — baseline alignment already puts
   its bottom edge on the baseline, and shifting from there is
   the one offset that doesn't depend on the font's ascent. */

.wordmark__stem { font-style: normal; }

.wordmark__tittle {
  position: relative;
  top: -0.5975em;            /* bottom edge, this far above the baseline */
  width: 0.1425em;
  height: 0.1175em;
  margin-left: -0.1555em;    /* stem advance (0.258) + tracking (-0.045), less
                                the tittle's own inset from the stem (0.0575) */
  margin-right: 0.013em;     /* whatever's left, so the flow nets out to zero */
  background: currentColor;
}

/* Knocked clean off. The dot arrives from below and to the right, so this
   goes up and to the left, tumbling, and is gone by the end — `forwards`
   keeps it gone, because the period is about to stand in for it and two dots
   on one i is one dot too many. All in em, so the throw scales with the type.
   `top` above does the positioning, which leaves transform free for this. */
@keyframes tittle-off {
  0%   { transform: none; opacity: 1; }
  35%  { transform: translate(-0.21em, -0.40em) rotate(-18deg); opacity: 1; }
  100% { transform: translate(-0.62em, -0.10em) rotate(-52deg) scale(0.45); opacity: 0; }
}

/* And back, extruded out of the stem it belongs to. It starts tucked inside
   the i — below the x-height, squashed to a sliver — so there's nothing to
   see until it clears the top of the stem, which is why it needs no fade.
   `both` holds that first frame through the delay; without it the tittle
   would sit at full size for a beat before growing. */
@keyframes tittle-back {
  0%   { transform: translateY(0.11em) scaleY(0.12); }
  55%  { transform: translateY(-0.03em) scaleY(1.14); }
  100% { transform: none; }
}

.wordmark__tittle.is-kicked { animation: tittle-off 0.5s var(--ease) forwards; }

.wordmark__tittle.is-back {
  transform-origin: 50% 100%;      /* grows upward, off its own base */
  animation: tittle-back 0.52s var(--ease) 0.12s both;
}

/* ── status ────────────────────────────────────────────────
   Real time in a real timezone. Liveness you can verify.     */

.status {
  margin: 0;
  display: flex;
  gap: 1.15em;
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--muted);
  white-space: nowrap;
}

.status__time {
  color: var(--ink);
  font-variant-numeric: tabular-nums;   /* seconds tick without shifting */
}

/* ── language select ───────────────────────────────────────
   shadcn's Select, rebuilt without React: trigger + chevron,
   popover, rows with a check indicator, the same ARIA and
   keyboard contract.

   Two deliberate departures from the stock theme. No shadow —
   this page has none anywhere, and a floating card would be the
   one thing on it pretending to have depth; a hairline on paper
   separates it well enough. And the radius is kept, because 6px
   on a 26px control reads as "this is a control", which is the
   opposite problem from a rounded content card. */

.meta {
  display: flex;
  align-items: center;
  gap: clamp(10px, 2vw, 18px);
}

.lang { position: relative; }

html:not(.js) .lang { display: none; }   /* it would do nothing without JS */

.lang__trigger {
  display: inline-flex;
  align-items: center;
  gap: 0.5em;
  height: 26px;
  padding: 0 0.5em 0 0.65em;
  margin: 0;

  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink);

  background: transparent;
  border: 1px solid var(--rule);
  border-radius: 6px;
  cursor: pointer;
  transition: border-color 0.18s var(--ease), background-color 0.18s var(--ease);
}

.lang__trigger:hover                  { border-color: var(--rule-2); }
.lang__trigger[aria-expanded="true"]  { border-color: var(--ink); }

.lang__chevron {
  width: 11px;
  height: 11px;
  opacity: 0.5;
  transition: transform 0.2s var(--ease);
}

.lang__trigger[aria-expanded="true"] .lang__chevron { transform: rotate(180deg); }

.lang__content {
  position: absolute;
  top: calc(100% + 6px);
  right: 0;                    /* anchored to the right edge — it's the last
                                  thing in the row and would otherwise
                                  overflow the viewport on a phone */
  z-index: 40;
  min-width: 168px;
  padding: 4px;

  background: var(--paper);
  border: 1px solid var(--rule);
  border-radius: 6px;

  display: none;
}

.lang__content[data-state="open"] {
  display: block;
  animation: lang-in 0.15s var(--ease);
}

@keyframes lang-in {
  from { opacity: 0; transform: translateY(-4px); }
  to   { opacity: 1; transform: none; }
}

.lang__item {
  display: flex;
  align-items: center;
  gap: 0.6em;
  padding: 7px 9px;
  border-radius: 4px;

  font-family: var(--sans);
  font-size: 13px;
  letter-spacing: -0.005em;
  color: var(--ink);

  cursor: pointer;
  user-select: none;
}

/* One highlight, driven by the keyboard as well as the mouse, so arrowing
   down and hovering can't disagree about which row is active. */
.lang__item[data-active="true"] { background: var(--tint); }

.lang__check {
  width: 12px;
  height: 12px;
  flex: none;
  color: var(--live);
  opacity: 0;
}

.lang__item[aria-selected="true"] .lang__check { opacity: 1; }

.lang__native {
  margin-left: auto;
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.1em;
  color: var(--muted);
}

/* ── the profile ───────────────────────────────────────────
   Same card as the language menu — hairline on paper, 6px,
   no shadow — because it opens from the same page and should
   read as the same system, not as a second idea.

   The wordmark is the trigger, which is why it now carries a
   cursor. It's the one place on the page where the name is
   written, so it's the one place a profile belongs. */

.wordmark { cursor: pointer; }

.card {
  position: absolute;
  top: calc(100% + 6px);
  left: 0;
  z-index: 45;

  width: 272px;
  max-width: calc(100vw - 2 * var(--pad));
  padding: 15px 16px;

  display: none;
  gap: 15px;

  background: var(--paper);
  border: 1px solid var(--rule);
  border-radius: 6px;
}

.card[data-state="open"] {
  display: flex;
  animation: card-in 0.2s var(--ease);
}

@keyframes card-in {
  from { opacity: 0; transform: translateY(-5px); }
  to   { opacity: 1; transform: none; }
}

/* ── the avatar ────────────────────────────────────────────
   An empty square with a centre, and nothing drawn in it. The
   picture is the dot, which flies here and grows to fill it —
   so the only thing this has to be is the right size in the
   right place. */

.card__figure {
  position: relative;
  flex: none;
  width: 40px;
  height: 40px;
  align-self: center;
}

.card__head {
  position: absolute;
  inset: 0;
  visibility: hidden;
}

.card__body { min-width: 0; }

.card__name {
  margin: 0 0 9px;
  font-size: 15px;
  font-weight: 700;
  letter-spacing: -0.02em;
  line-height: 1.1;
}

/* label / value, on the page's two voices: mono for the label the way the
   clock and the language button are set, sans for the thing itself */
.card__rows {
  margin: 0;
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 4px 10px;
  align-items: baseline;
}

.card__rows dt {
  font-family: var(--mono);
  font-size: 9.5px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--muted);
  white-space: nowrap;
}

.card__rows dd {
  margin: 0;
  font-size: 12.5px;
  letter-spacing: -0.005em;
  line-height: 1.35;
  color: var(--ink);
  text-wrap: pretty;
}

/* ── statement ─────────────────────────────────────────────
   Bottom-aligned in its row so the type hangs off the rule
   below it rather than floating mid-viewport.                */

.statement {
  align-self: end;
  margin: 0;
  /* 0.14em keeps a rotating quote ending in g/p/y off the rule below */
  padding-block: clamp(48px, 9vh, 120px) 0.14em;
  /* 8vw keeps the longest line ("nobody screenshots.") off a wrap — the
     three-line break is the composition, not an accident. The 15vh cap is
     what stops a short viewport (any phone turned sideways) from pushing
     the footer off the bottom of the screen. */
  font-size: clamp(2.4rem, min(8vw, 15vh), 9rem);
  font-weight: 700;
  line-height: 0.88;
  letter-spacing: -0.045em;

  /* slow enough to read as the colour arriving, not as a state flipping */
  transition: color 0.45s var(--ease);
}

/* The second and last place the "only the dot is coloured" rule bends, and
   for the same reason the email's underline does: the colour is on loan from
   the dot. When it knocks the tittle off the i it isn't visiting the
   wordmark, it's standing in it — so the headline takes its colour for as
   long as it stays, and gives it back the moment it climbs down. Set and
   unset alongside the perch itself, so the two can't disagree. */
.statement.is-live { color: var(--live); }

/* The mask has to be taller than the line box, or overflow:hidden shaves
   the tails off g / p / y — at line-height 0.88 the glyphs genuinely sit
   outside their own box. Padding opens the clip region; the matching
   negative margin hands the space straight back, so the 0.88 rhythm
   between lines is untouched. */
.line {
  --over:  0.09em;              /* headroom above the ascenders */
  --under: 0.22em;              /* room below the baseline for descenders */

  display: block;
  overflow: hidden;             /* the mask the text rises out of */
  padding-block: var(--over) var(--under);
  margin-block: calc(var(--over) * -1) calc(var(--under) * -1);
}

.line__in { display: block; }

/* has to clear 100% + --under, or the text peeks into the padding
   before the reveal fires */
.js .line__in { transform: translateY(140%); }

.statement br.s { display: none; }   /* re-enabled in the narrow query */

.is-ready .line__in {
  transform: none;
  transition: transform 0.72s var(--ease);
  transition-delay: calc(var(--i) * 70ms);
}

/* ── footer ────────────────────────────────────────────────  */

.bio {
  margin: 0;
  max-width: 42ch;
  font-size: clamp(13.5px, 1.05vw, 15px);
  line-height: 1.5;
  letter-spacing: -0.005em;
  color: var(--muted);
  text-wrap: pretty;
}

.bio__aside {
  display: block;
  margin-top: 0.5em;
  color: var(--ink);
}

.mail {
  font-size: clamp(13.5px, 1.05vw, 15px);
  letter-spacing: -0.01em;
  color: var(--ink);
  text-decoration: none;
  white-space: nowrap;
  position: relative;
}

/* underline wipes from the left rather than fading in */
.mail::after {
  content: "";
  position: absolute;
  left: 0; right: 0; bottom: -3px;
  height: 1px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: right;
  transition: transform 0.4s var(--ease), background-color 0.45s var(--ease);
}

/* Set when the dot finishes its drop and settles into the smiley beside the
   address (touch only). The line the ball landed on takes the dot's colour —
   the one place the "nothing is coloured except the dot" rule bends, and only
   because the dot is what put it there. */
.mail.is-lit::after { background: var(--live); }

.mail:hover::after,
.mail:focus-visible::after {
  transform: scaleX(1);
  transform-origin: left;
}

.mail__arrow {
  display: inline-block;
  margin-left: 0.4em;
  transition: transform 0.4s var(--ease);
}

.mail:hover .mail__arrow { transform: translate(2px, -2px); }

:focus-visible {
  outline: 2px solid var(--live);
  outline-offset: 4px;
}

/* ── the collection ────────────────────────────────────────
   Six things, as postage stamps. Same panel as the language menu and the
   profile — hairline on paper, 6px, no shadow — because it opens from the
   same row and belongs to the same system.

   The stamps themselves are ink on paper. Real definitives are printed in
   one or two inks, so that isn't a compromise forced by the page's rule —
   it's what a stamp actually looks like. The one exception is the value in
   the corner, which takes the dot's colour: a second ink is the one thing
   every printed stamp does have. */

.stamps { position: relative; }

html:not(.js) .stamps { display: none; }   /* the sheet is generated */

/* deliberately identical to .lang__trigger: two controls in one row that
   behave the same way should not be two designs */
.stamps__trigger {
  display: inline-flex;
  align-items: center;
  height: 26px;
  padding: 0 0.65em;
  margin: 0;

  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink);

  background: transparent;
  border: 1px solid var(--rule);
  border-radius: 6px;
  cursor: pointer;
  white-space: nowrap;
  transition: border-color 0.18s var(--ease);
}

.stamps__trigger:hover                 { border-color: var(--rule-2); }
.stamps__trigger[aria-expanded="true"] { border-color: var(--ink); }

/* Anchored to the row, not to the button. The sheet is far wider than its
   trigger, and hanging it off a 70px pill on a 375px screen is how a panel
   ends up half off the side. */
.sheet {
  position: absolute;
  top: calc(100% + 6px);
  right: 0;
  z-index: 44;

  width: 520px;
  max-width: calc(100vw - 2 * var(--pad));
  padding: 15px 16px 17px;

  /* The one panel on the page that isn't paper. The other two hold text and
     rows and belong on the same white as everything else; this one holds
     objects, and white stamps on white paper meant the perforations — the
     single thing that makes a stamp look like a stamp — sat at under two per
     cent of contrast and simply weren't there. On the tint they read all
     the way round. It's an album page, not a card. */
  background: var(--tint);
  border: 1px solid var(--rule);
  border-radius: 6px;

  /* the page never scrolls; this can, but only when a viewport is too short
     to hold the sheet at all — a phone on its side, essentially */
  max-height: calc(100svh - 2 * var(--pad) - 56px);
  overflow-y: auto;
  overscroll-behavior: contain;

  display: none;
}

.sheet[data-state="open"] {
  display: block;
  animation: card-in 0.2s var(--ease);
}

.sheet__title {
  margin: 0 0 13px;
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--muted);
}

.sheet__grid {
  margin: 0;
  padding: 0;
  list-style: none;

  /* Flex rather than grid, because there are five. A grid leaves the last row
     hanging against the left edge; wrapping and centring puts the odd two in
     the middle, which is how an album page with an odd number on it looks. */
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 12px;
}

/* The wrapper carries the shadow and the tilt; the faces inside carry the
   clip. They have to be separate elements: a filter on the clipped box would
   be clipped along with it, and there would be nothing left to cast. */
.stamp {
  flex: 0 0 calc(33.333% - 8px);
  aspect-ratio: 4 / 5;
  /* Tight and close rather than soft and wide. A diffuse shadow under a shape
     this small blurs straight over the perforations; a contact shadow half a
     pixel down is what makes each notch cast its own. */
  filter: drop-shadow(0 0.5px 0.5px rgb(14 14 12 / 30%))
          drop-shadow(0 3px 6px rgb(14 14 12 / 14%));
  transform: rotate(var(--tilt, 0deg));
  transition: transform 0.32s var(--ease);
}

/* Each one sits a fraction off square, so six of them read as a collection
   somebody laid out rather than as a grid a computer filled. Picking one up
   straightens it — under a cursor while it's there, and held from the moment
   it's clicked, which is the click's answer while the dot is still on its way
   over. On a phone that held state is the only acknowledgement there is. */
.stamp:hover,
.stamp.is-picked { transform: rotate(0deg) translateY(-3px) scale(1.03); }

/* ── on a phone, a row instead of a grid ───────────────────
   Six stamps stacked two-by-two filled two thirds of the screen, and a panel
   that size stops being a panel — the page it belongs to disappears behind
   it. One row that scrolls sideways is a third of the height, which leaves
   the statement and the address where they were, and leaves the dot room to
   go on doing what it does down there.

   The neighbours deliberately show at both edges. A single stamp filling the
   width would be tidier and would say nothing about there being five more. */
@media (max-width: 640px) {
  .sheet__grid {
    --card: min(62vw, 200px);

    flex-wrap: nowrap;
    justify-content: flex-start;

    overflow-x: auto;
    overscroll-behavior-x: contain;   /* a swipe past the end stays here */
    scroll-snap-type: x mandatory;
    touch-action: pan-x;              /* sideways is this list's gesture */

    /* half a screen of air either side, so the first and last stamp can reach
       the middle like every other one. scroll-padding is the same figure,
       because snapping measures from the scrollport, not from the content. */
    padding-inline: calc((100% - var(--card)) / 2);
    scroll-padding-inline: calc((100% - var(--card)) / 2);

    scrollbar-width: none;

    /* The neighbours are cut off dead at the panel's edge otherwise, and a
       perforated edge sliced down the middle reads as a torn stamp rather
       than as one continuing off-screen. Fades them instead, shadow and all.
       The stops sit well clear of the centred stamp, which never dims. */
    -webkit-mask-image: linear-gradient(to right,
      transparent 0, #000 26px, #000 calc(100% - 26px), transparent 100%);
    mask-image: linear-gradient(to right,
      transparent 0, #000 26px, #000 calc(100% - 26px), transparent 100%);
  }

  .sheet__grid::-webkit-scrollbar { display: none; }

  .stamp {
    flex-basis: var(--card);
    scroll-snap-align: center;
  }
}

/* The whole stamp is the control. A real button, so Enter and Tab come free
   and the sheet stops being a dialog with nothing focusable in it. */
.stamp__btn {
  display: block;
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  color: inherit;
  cursor: pointer;
  perspective: 620px;
}

/* Outside the perforated edge on purpose: a focus ring that followed the
   notches would read as part of the stamp rather than as a ring around it. */
.stamp__btn:focus-visible {
  outline: 2px solid var(--live);
  outline-offset: 3px;
}

.stamp__flip {
  position: relative;
  display: block;
  width: 100%;
  height: 100%;
  transform-style: preserve-3d;
  transition: transform 0.62s var(--ease);
}

.stamp.is-turned .stamp__flip { transform: rotateY(180deg); }

.stamp__face {
  position: absolute;
  inset: 0;
  padding: 7px;

  /* the white the perforations are cut out of — the only part of the paper
     the picture doesn't cover */
  background: #FFFFFF;
  clip-path: var(--perf);
  backface-visibility: hidden;
}

.stamp__face--back {
  transform: rotateY(180deg);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 15px 13px;
}

/* The one piece of writing on this page that isn't the headline or the bio,
   so it gets the page's own face rather than the mono the labels use. */
.stamp__note {
  font-size: clamp(10.5px, 1.05vw, 12.5px);
  line-height: 1.35;
  letter-spacing: -0.005em;
  text-align: center;
  text-wrap: pretty;
  color: var(--ink);
}

.stamp__art {
  display: block;
  width: 100%;
  height: 100%;
  /* The files are cut to 4:5 already, so this crops nothing — except Arnhem,
     which is square and anchored left by object-position. */
  object-fit: cover;
  background: var(--rule);      /* the frame holds its shape while it loads */
}

/* White type on a picture is only legible if something is put behind it, and
   these are sunsets — bright exactly where the text sits. Two gradients, top
   and bottom, over the picture and under the words. Nothing in the middle, so
   the image itself is untouched where you actually look at it. */
.stamp__face--front::after {
  content: "";
  position: absolute;
  inset: 7px;
  pointer-events: none;
  background:
    linear-gradient(to bottom, rgb(10 8 14 / 60%), transparent 28%),
    linear-gradient(to top,    rgb(10 8 14 / 66%), rgb(10 8 14 / 22%) 17%, transparent 38%);
}

.stamp__value,
.stamp__name {
  position: absolute;
  z-index: 1;                   /* over the scrim, which is over the picture */
  color: #FFFFFF;
  font-family: var(--mono);
  line-height: 1;

  /* The scrims handle most of it, but two of these pictures have near-white
     sky exactly where the value sits, and a gradient dark enough to fix that
     everywhere would be dark enough to spoil the ones that don't need it.
     A shadow costs the other four nothing. */
  text-shadow: 0 1px 2px rgb(10 8 14 / 60%);
}

.stamp__value {
  top: 13px;
  right: 13px;
  font-size: 10px;
  letter-spacing: 0.04em;
}

.stamp__name {
  left: 9px;
  right: 9px;
  bottom: 13px;
  font-size: 8.5px;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  text-align: center;
}

/* ── the cancellation ──────────────────────────────────────
   Above everything, because it is the last thing that happened to the stamp.
   Position and angle come off the button as custom properties, so each mark
   sits where its own stamp says rather than all six landing identically.

   It arrives in the dot's colour and settles, over the next second, into the
   white the rest of the type on these pictures already uses. The colour was
   only ever on loan — the same borrowing the lit underline at the address
   does, for the same reason: the dot put it there.

   White rather than ink, and this was measured rather than chosen: ink is
   what a real canceller uses, and four of these six pictures are night. A
   black ring on them is invisible. White with the same shadow the value and
   the name carry reads on all six, including the one with a white sky.

   `is-fresh` carries no transition so the first frame is the full colour;
   dropping the class is what starts it settling. */
.stamp__mark {
  position: absolute;
  z-index: 2;
  left: var(--mx, 50%);
  top: var(--my, 45%);
  width: 54%;
  aspect-ratio: 1;
  transform: translate(-50%, -50%) rotate(var(--mrot, 0deg));
  color: #FFFFFF;
  opacity: 0;
  pointer-events: none;
  filter: drop-shadow(0 1px 1.5px rgb(10 8 14 / 55%));
  transition: color 1.15s var(--ease);
}

/* never quite solid, because a canceller never presses evenly */
.stamp.is-cancelled .stamp__mark { opacity: 0.82; }

.stamp__mark.is-fresh { color: var(--live); transition: none; }

.stamp__mark-city,
.stamp__mark-date {
  fill: currentColor;
  font-family: var(--mono);
  text-anchor: middle;
}

.stamp__mark-city { font-size: 11px; letter-spacing: 1.4px; }
.stamp__mark-date { font-size: 10px; letter-spacing: 0.4px; }

/* ── the dot ───────────────────────────────────────────────
   position + size are driven from JS so it lands exactly on
   the placeholder in the wordmark.                           */

.dot {
  position: fixed;
  top: 0; left: 0;
  /* The largest the dot is ever drawn — the profile avatar — and every other
     state scales DOWN from it. Must stay in step with BASE in script.js.
     Bumped from 32 when the avatar arrived: a promoted layer scaled past 1
     goes soft, so the biggest use has to set the size, not the smallest. */
  width: 44px;
  height: 44px;
  border-radius: 50%;
  background: var(--live);
  pointer-events: none;
  z-index: 10;
  will-change: transform;
  opacity: 0;
}

.dot.is-visible { opacity: 1; }

/* The dot sits under the panels (10 against their 40 and 45) so it can't
   scribble over an open menu on its way past. The profile is the exception:
   there it isn't passing over the card, it's part of what the card is
   drawing, so for that one job it comes to the front. */
.dot.is-portrait { z-index: 50; }

/* And the same exception while the collection is open, for the same reason:
   there it isn't passing over the sheet, it is working on it. Without this the
   dot spends the whole visit to the panel behind it — the flight, the press
   and the squash all happen, out of sight, under 44. */
.dot.is-front { z-index: 50; }

/* ── the face ──────────────────────────────────────────────
   Knocked out of the dot in paper, so the page still has
   exactly one colour in it.                                  */

.dot__face {
  display: block;
  width: 100%;
  height: 100%;
  opacity: 0;
  transition: opacity 0.28s ease-out;
}

.dot__eye {
  fill: var(--paper);
  transform: scale(0);
  transform-origin: center;
  transform-box: fill-box;        /* origin per-shape, not per-SVG */
  transition: transform 0.18s var(--ease);
}

/* The gaze is deliberately small: at portrait size it should feel like a
   quick glance, not like the face is sliding around inside its own head.
   These variables stay at zero everywhere except the open profile card. */
.dot__eyes {
  transform: translate(var(--look-x, 0), var(--look-y, 0));
  transition: transform 0.68s cubic-bezier(.22, 1, .36, 1);
}

.dot.is-looking .dot__eyes { will-change: transform; }

/* Following a cursor is tracking, not glancing, and the glance timing reads
   as lag when there's something to keep up with. Same gaze, a quarter of the
   travel time. */
.dot.is-tracking .dot__eyes {
  transition-duration: 0.16s;
  will-change: transform;
}

/* ── blinking ──────────────────────────────────────────────
   The eyes open once and then hold. For the three seconds the smiley spends
   beside the address that's fine; in the profile card it is a portrait
   staring without pause for as long as the card is up, and staring is the
   thing that makes a drawn face read as drawn.

   The origin is stated in viewBox units rather than derived from the group's
   own box: y=12 is the line both eyes are centred on, so the lids close onto
   it exactly, and nothing depends on how a browser computes the bounding box
   of a <g>. */

.dot__lids { transform-origin: 16px 12px; }

@keyframes lids {
  0%, 100% { transform: scaleY(1); }
  42%, 58% { transform: scaleY(0.06); }
}

.dot.is-blinking .dot__lids { animation: lids 0.17s ease-in-out; }

/* The wink is the one asymmetric expression, so it can't ride the lid group —
   it takes over the left eye's own transform instead. Composing by hand is
   safe here because the base is always scale(1): a wink only ever happens
   with the face already open. Letting go hands the eye back to the pop curve
   above, so it springs open rather than sliding. */
.dot.is-winking .dot__eye:nth-of-type(1) {
  transform: scale(1) scaleY(0.06);
  transition: transform 0.11s ease-in-out;
}

/* ── the head turning ──────────────────────────────────────
   A circle has no front, so turning it can only be shown by what is drawn
   on it: the features slide across and the whole face narrows as they go.
   That narrowing is the entire trick — without it the face slides over a
   flat disc, with it the disc becomes a ball. Both numbers come out of
   faceTo(), which takes them off a sphere rather than easing them by feel.

   The origin is the middle of the head, in viewBox units, so it holds at
   every size the dot is ever drawn at. */
.dot__turn {
  transform-origin: 16px 16px;
  transform: translate(var(--turn-x, 0), var(--turn-y, 0))
             scale(var(--turn-sx, 1), var(--turn-sy, 1));
  transition: transform 0.78s cubic-bezier(.22, 1, .36, 1);
}

/* Deliberately slower than the eyes, which run at 0.16s: eyes arrive first
   and the head comes after them. That order is most of what makes it read
   as looking at something rather than as one rigid piece swinging round. */
.dot.is-tracking .dot__turn {
  transition-duration: 0.26s;
  will-change: transform;
}

/* ── how much of a smile ───────────────────────────────────
   The mouth is drawn once, at its most restrained, and this deepens it. The
   corners are the pivot, so they stay put and only the middle drops — which
   is what a widening smile does, and it means one number covers the whole
   range from polite to pleased.

   It gets its own group so it keeps its own transition. .dot__mouth's is
   already spoken for twice over by the draw-on, and those rules replace the
   whole `transition` property rather than adding to it. */
.dot__smile {
  transform-origin: 16px 17.4px;
  transform: scaleY(var(--smile, 1));
  transition: transform 0.45s var(--ease);
}

/* The two moments it means something. A wink is told to someone, and the
   landing on the address is the dot having just done its one trick — both
   earn more mouth than sitting in a card does. */
.dot.is-winking,
.dot.is-pleased { --smile: 1.34; }

.dot__mouth {
  fill: none;
  stroke: var(--paper);
  /* Thinner than it was, so there is curve showing between the two round
     caps rather than the caps being most of the shape. */
  stroke-width: 2.1;
  stroke-linecap: round;
  stroke-dashoffset: var(--len, 20);   /* JS sets --len from getTotalLength() */
  transition: stroke-dashoffset 0.2s var(--ease);
}

/* Arriving is playful, leaving is quick — the eyes pop past 1 and settle,
   then the smile draws itself on. Being the profile portrait also guarantees
   a face: it must never depend on landing within one animation frame. */
.dot.is-smiling .dot__face,
.dot.is-portrait .dot__face { opacity: 1; }

.dot.is-smiling .dot__eye,
.dot.is-portrait .dot__eye {
  transform: scale(1);
  transition: transform 0.42s cubic-bezier(.34, 1.32, .64, 1);
}

.dot.is-smiling .dot__eye:nth-of-type(2),
.dot.is-portrait .dot__eye:nth-of-type(2) { transition-delay: 0.06s; }

.dot.is-smiling .dot__mouth,
.dot.is-portrait .dot__mouth {
  stroke-dashoffset: 0;
  transition: stroke-dashoffset 0.38s var(--ease) 0.12s;
}

/* ── entrance ──────────────────────────────────────────────  */

.js [data-reveal] {
  opacity: 0;
  transform: translateY(9px);
}

.is-ready [data-reveal] {
  opacity: 1;
  transform: none;
  transition: opacity 0.6s var(--ease), transform 0.6s var(--ease);
  transition-delay: 0.28s;
}

.is-ready [data-rule] {
  transform: scaleX(1);
  transition: transform 0.9s var(--ease);
  transition-delay: 0.1s;
}

/* ── narrow ────────────────────────────────────────────────  */

/* Narrow AND tall. The min-height matters: this layout stacks the type into
   five or six lines, which is right on a phone held upright and disastrous on
   the same phone turned sideways. Short viewports fall through to the block
   below instead. 480px is a portrait iPhone SE with Safari's chrome taken off
   the top — still a phone held upright, so it belongs in here. */
@media (max-width: 640px) and (min-height: 480px) {
  /* A tall phone bottom-anchored leaves a dead void up top. Move the
     slack below the statement instead: type sits high, footer stays
     pinned to the bottom edge, and the gap between them is deliberate. */
  .stage { grid-template-rows: auto auto auto 1fr auto; }

  .statement {
    align-self: start;
    padding-block: clamp(28px, 7svh, 96px) 0.14em;
    /* Six stacked lines is the worst case in the rotation, and 11.6vw alone
       sizes them off the width with no idea how much height is left. The svh
       cap is what keeps that worst case inside the screen instead of pushing
       the footer past the bottom edge — which, now that nothing scrolls,
       would clip it rather than hide it. */
    font-size: clamp(2rem, 11.6vw, 4rem);
    font-size: clamp(2rem, min(11.6vw, 7svh), 4rem);
  }

  .statement br.s { display: block; }

  /* the 1fr now lands on the lower rule's row — drop it to the bottom
     so it keeps hugging the footer rather than the statement */
  .rule { align-self: end; }

  .row--bottom {
    flex-direction: column;
    align-items: flex-start;
    gap: 20px;
  }
}

/* ── short viewport ────────────────────────────────────────
   Any phone turned sideways. Everything gives back a little
   vertical space so the email — the only action on the page —
   stays above the fold. Overriding --pad rather than padding
   keeps the safe-area logic in one place. */

@media (max-height: 560px) {
  :root { --pad: clamp(14px, 2.4vw, 30px); }

  .row--top    { padding-block: 0 8px; }
  .row--bottom { padding-block: 10px 0; }

  .statement { padding-block: clamp(14px, 4svh, 60px) 0.14em; }

  .bio        { font-size: 12.5px; line-height: 1.45; }
  .bio__aside { margin-top: 0.35em; }

  /* the stacked footer's gap is the last easy 10px when height is scarce */
  .row--bottom { gap: 12px; }
}

/* Short AND wide — a phone on its side, where the type runs full-width in
   three long lines instead of stacking. The 2.4rem floor on the base size is
   a sensible minimum for a page that can scroll; this one can't, so here the
   type is allowed to give way. Clipped copy is worse than small copy. The
   width guard keeps this off the portrait layout above, which needs its
   larger stacked size. */
@media (max-height: 560px) and (min-width: 641px) {
  .statement {
    font-size: clamp(1.5rem, min(8vw, 13vh), 9rem);
    font-size: clamp(1.5rem, min(8vw, 13svh), 9rem);
  }
}

/* Past this there's no phone left — it's a browser window dragged down to a
   letterbox. Nothing gets dropped, everything just gets tighter, because the
   alternative is now clipping rather than scrolling. */
@media (max-height: 340px) {
  :root { --pad: 12px; }

  .row--top    { padding-block: 0 6px; }
  .row--bottom { padding-block: 8px 0; }

  .bio        { font-size: 11.5px; line-height: 1.4; max-width: 46ch; }
  .bio__aside { margin-top: 0.2em; }

  .lang__trigger { height: 22px; padding: 0 0.4em 0 0.55em; }
  .lang__item    { padding: 6px 8px; font-size: 12.5px; }

  /* a letterboxed window has no room for the drawing; the words still fit */
  .card { padding: 11px 12px; gap: 11px; }
  .card__figure { display: none; }
}

/* ── no hover ──────────────────────────────────────────────
   Input capability, not screen width — a narrow desktop window
   still has a cursor and doesn't belong in here.

   Anything that only existed on :hover is unreachable on a
   phone, so the affordance has to be there without it. */

@media (hover: none) {
  .mail::after {
    transform: scaleX(1);       /* the underline can't wipe in — just show it */
    transform-origin: left;
  }
}

/* ── reduced motion ────────────────────────────────────────
   Everything arrives already in place. The clock still ticks
   and the dot still docks — neither is motion for its own sake. */

@media (prefers-reduced-motion: reduce) {
  /* these have to carry .js to outrank the entrance's start states above,
     otherwise reduced motion means a permanently blank page */
  .js .line__in,
  .is-ready .line__in { transform: none; transition: none; }

  .js [data-reveal] { opacity: 1; transform: none; }
  .is-ready [data-reveal] { opacity: 1; transform: none; transition: none; }

  .js .rule { transform: scaleX(1); }
  .is-ready [data-rule] { transform: scaleX(1); transition: none; }

  .mail__arrow { transition: none; }

  /* the underline still takes the dot's colour — a colour is not motion, so
     it keeps the crossfade rather than snapping */
  .mail::after { transition: background-color 0.28s linear; }

  /* ── the performance, with the journeys taken out ──────────
     Everything the dot does on a phone still happens here, on the same
     schedule; none of it crosses the screen. It fades out where it stands
     and fades in where it was going, and the tittle it displaces fades with
     it. JS drives the timing — see `blink()` — and these are the two ends of
     each swap. Specificity has to beat .dot.is-visible, hence the three
     classes. */
  .dot { transition: opacity 0.28s linear; }
  .dot.is-visible.is-gone { opacity: 0; }

  /* the face arrives with the fade instead of assembling itself */
  .dot__face, .dot__eye, .dot__mouth { transition: none; }
  .dot__eyes { transition: none; }

  /* Blinking, winking and following a cursor with the eyes are all gated off
     in JS as well — see `runBlink()` and `gazeAt()`. This is the backstop, so
     a stale class can't leave a lid shut or an eye halfway. */
  .dot.is-blinking .dot__lids { animation: none; }
  .dot.is-winking .dot__eye:nth-of-type(1) { transform: scale(1); transition: none; }
  .dot.is-tracking .dot__eyes { transition: none; }

  /* the head faces forward and stays there, and the mouth is whatever it is
     without easing between sizes */
  .dot__turn { transform: none; transition: none; }
  .dot__smile { transition: none; }

  .wordmark__tittle { transition: opacity 0.28s linear; }

  /* knocked off becomes simply absent; coming back, simply present */
  .wordmark__tittle.is-kicked { animation: none; transform: none; opacity: 0; }
  .wordmark__tittle.is-back   { animation: none; }

  /* the menu and the profile appear rather than slide; the chevron still
     turns, because that's state, not decoration */
  .lang__content[data-state="open"] { animation: none; }
  .card[data-state="open"] { animation: none; }
  .sheet[data-state="open"] { animation: none; }

  /* The stamps keep the tilt they were laid down with — a fixed angle is a
     position, not motion — but picking one up no longer straightens it. */
  .stamp { transition: none; }
  .stamp:hover { transform: rotate(var(--tilt, 0deg)); }

  /* Hovering is incidental and gives nothing back here, but a click still has
     to answer — so the picked-up state stays. It snaps rather than lifts,
     which is a change of state and not a thing in motion. */

  /* The stamp turns over without turning: the back is simply what is there
     now. The cancellation still dries into the paper, because a colour
     changing is not something moving — the same line this file already takes
     with the lit underline at the address. */
  .stamp__flip { transition: none; }
}
