/* ──────────────────────────────────────────────────────────────────────────
   paperlike — photographs.

   The third surface. `.spread` is writing's two-column reading layout and this
   is its sibling: ONE full-width scroller, because a photo essay is a column
   you fall down rather than a text you read beside an index.

   Both live inside the sheet and both are hidden until js/paper.js routes to
   them, so with scripting off `/` is still the page it always was.

   What is in here:

     • the index of collections, and the loose frames under it
     • a written collection: a cover banner, a lede set like the prose, and the
       stamps
     • the STAMPS — one per day, perforated in CSS, cycling their own
       photographs on hover
     • the day view: the stamp is repainted into the photograph it was drawn
       from, with that frame's real EXIF printed beside it

   ⚠️ Kept out of styles.css deliberately, the same way intro.css is. This file
   only ever adds a surface; it changes nothing the landing page uses.
   ────────────────────────────────────────────────────────────────────────── */

:root {
  /* The stamp's own stock. ⚠️ NOT --paper-*: a stamp is a separate object
     lying ON the sheet, and drawn in the sheet's own tone the perforation has
     nothing to bite into — the card disappears and the engraving looks like it
     was printed straight onto the page. It stays cream in both themes for the
     same reason a photograph does. */
  --stamp-paper: #f2ebdd;
  --stamp-edge: rgba(58, 48, 32, 0.16);

  --stamp-w: clamp(132px, 15.5vw, 250px);
  --stamp-gap: clamp(18px, 4.6vw, 72px);
  /* Hole radius and pitch for the perforation. The pitch is what has to divide
     into the card at least roughly, or the last notch on an edge is a sliver. */
  --perf-r: 3.6px;
  --perf-pitch: 11px;

  /* ⚠️ The WHOLE filter value, not a colour — the same reason --curl-shadow is
     one. A stamp casts a tight contact shadow plus a wider, softer one, and
     both alphas have to move together between themes. */
  --stamp-shadow:
    drop-shadow(0 1px 1px rgba(28, 34, 38, 0.22))
    drop-shadow(0 8px 16px rgba(28, 34, 38, 0.14));
  --stamp-shadow-lift:
    drop-shadow(0 2px 3px rgba(28, 34, 38, 0.26))
    drop-shadow(0 18px 34px rgba(28, 34, 38, 0.2));

  /* The scan. A warm developing light in both themes — it is a lamp being
     passed over the print, not a UI accent. */
  --scan-glow: rgba(255, 246, 222, 0.92);
  --scan-spark: rgba(255, 250, 235, 0.95);

  --plate-edge: rgba(18, 23, 26, 0.16);
  --plate-shadow: 0 0 0 0 transparent;   /* photographs are flat: no shadow */
}

@media (prefers-color-scheme: dark) {
  :root {
    --stamp-paper: #e6ddcb;
    --stamp-edge: rgba(0, 0, 0, 0.3);
    --stamp-shadow:
      drop-shadow(0 1px 1px rgba(0, 0, 0, 0.5))
      drop-shadow(0 10px 20px rgba(0, 0, 0, 0.42));
    --stamp-shadow-lift:
      drop-shadow(0 2px 4px rgba(0, 0, 0, 0.55))
      drop-shadow(0 20px 40px rgba(0, 0, 0, 0.5));
    --scan-glow: rgba(214, 232, 255, 0.9);
    --scan-spark: rgba(232, 243, 255, 0.95);
    --plate-edge: rgba(229, 233, 234, 0.16);
    --plate-shadow: 0 0 0 0 transparent;
  }
}

/* ══ The surface ═══════════════════════════════════════════════════════════
   ⚠️ `:not([hidden])` carries the `display`, exactly as `.spread` does.
   `[hidden]` is only a UA `display: none`, so a `display` on the element
   itself outranks it and the surface would sit in the home layout as an empty
   grid row. */
/* ⚠️ The SURFACE carries the negative margin, not the cover inside it.
   `overflow` clips to the padding box, so a bleeding child of a scroller whose
   padding box stops at the sheet's text column is simply cut off there. The
   surface is pulled out to the sheet's own edges instead and the gutter is
   handed to `.plates-scroll` as padding — which then gives `.bleed` something
   to negate, and makes the clip happen at the sheet's edge where it belongs. */
.plates:not([hidden]) {
  display: block;
  min-height: 0;
  margin-inline: calc(-1 * var(--sheet-pad));
  opacity: 0;
  transition: opacity 380ms ease;
}
.plates.is-in { opacity: 1; }

/* Its own scroller, like `.page-scroll`. `html, body { overflow: hidden }` is
   untouched and the sheet still cannot move. */
.plates-scroll {
  height: 100%;
  overflow-y: auto;
  overflow-x: hidden;
  padding-inline: var(--sheet-pad);
  box-sizing: border-box;
  scrollbar-width: none;
}
.plates-scroll::-webkit-scrollbar { display: none; }

/* ⚠️ The dog-ear clearance is on the VIEW, not on the scroller. A padded
   scroller shortens its own content box, and the cover measures ninety percent
   of that — so the one dial that decides how tall the cover is would quietly
   be "ninety percent, less however much room the corner needs". */
.pcol,
.pindex {
  padding-bottom: clamp(48px, 9vh, 110px);
}

/* Headroom under the masthead before the trip's banner: the same dial as the
   day view's top (`.dayview-scroll`). On the view, not the scroller, for the
   reason above; the lift's probe (`.tripscan-probe > .pcol`) picks it up. */
.pcol { padding-top: clamp(56px, 11vh, 120px); }

/* Full bleed: back out the gutter the scroller just added. ⚠️ Derived from
   --sheet-pad rather than restating the clamp, so the two cannot drift. */
.bleed {
  margin-inline: calc(-1 * var(--sheet-pad));
  width: calc(100% + 2 * var(--sheet-pad));
  max-width: none;
}

/* ══ The index ═════════════════════════════════════════════════════════════ */

.pindex-head {
  margin: 0 0 clamp(20px, 4vh, 40px);
}

.pindex-note {
  margin: 10px 0 0;
  font-size: clamp(0.56rem, 0.9vw, 0.68rem);
  text-transform: uppercase;
  letter-spacing: 0.2em;
  color: var(--ink-soft);
}

/* A plain set's title (`paintPlainSet`). */
.pset-name {
  font-family: var(--font-display);
  margin: 0;
  font-weight: 400;
  font-size: clamp(1rem, 2vw, 1.6rem);
  line-height: 1.2;
  text-transform: uppercase;
  letter-spacing: 0.08em;
}

/* The badge sits ON the photograph, top-left, as a small card in the frame's
   own colour so it reads in both themes. */
.pset-badge {
  position: absolute;
  top: 14px;
  left: 14px;
  padding: 5px 10px;
  border-radius: 999px;
  background: var(--card);
  color: var(--ink);
  font-size: clamp(0.54rem, 0.86vw, 0.66rem);
  letter-spacing: 0.2em;
  text-transform: uppercase;
  line-height: 1.4;
  pointer-events: none;
}

/* An unwritten set is a folder of frames, not a piece — it opens the frames
   themselves, and says so. */
.pset-tag {
  display: inline-block;
  margin-left: 0.7em;
  padding: 1px 6px;
  border: 1px solid var(--rule);
  border-radius: 2px;
  font-size: 0.82em;
  letter-spacing: 0.14em;
}

/* ── One chronology ────────────────────────────────────────────────────────
   The index is the writing spread's layout: the years down a nav on the left
   (the writing index's own rows and chip, `.index-list` / `.index-link`), and
   one year at a time on a white card on the right, which scrolls inside
   itself. js/photos.js swaps the card's contents when a year is picked.

   ⚠️ Its own `--pnav-w`, not `--index-w`: `body.solo` (collapse the writing
   index) zeroes that one site-wide, and photos has no control to bring it
   back. */
.pindex--chron {
  --pnav-w: clamp(170px, 20vw, 260px);
  height: 100%;
  box-sizing: border-box;
  padding-bottom: 0;
  display: grid;
  grid-template-columns: var(--pnav-w) minmax(0, 1fr);
  column-gap: var(--index-gutter);
  min-height: 0;
}

.pnav {
  min-height: 0;
  overflow-y: auto;
  scrollbar-width: none;
}
.pnav::-webkit-scrollbar { display: none; }
/* The year is the title here, so it leads and the count sits under it. */
.pnav .index-title {
  font-family: var(--font-display);
  font-size: clamp(0.9rem, 1.5vw, 1.2rem);
  letter-spacing: 0.1em;
}
.pnav .index-date { margin: 2px 0 0; }

/* A year's counts: trips and frames on rows of their own, each behind its
   mark. The marks take the label's colour and size, and `.ico`'s hairline. */
.pcounts {
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.pnav .pcounts { margin-top: 6px; }
.pcount {
  display: flex;
  align-items: center;
  gap: 0.7em;
}
.pcount .ico { width: 1.5em; height: 1.5em; }

/* The picked year is the only one at full strength; the rest recede. */
.pnav .index-link { transition: color 200ms ease, opacity 200ms ease; }
.pnav .index-item:not(.is-current) .index-link { opacity: 0.45; }
.pnav .index-item:not(.is-current) .index-link:hover,
.pnav .index-item:not(.is-current) .index-link:focus-visible { opacity: 1; }
.pyear-short { display: none; }

/* ── The year rail ─────────────────────────────────────────────────────────
   On a wide sheet the nav rests as a rail of two-digit years ("26") and opens
   on hover, or when a year takes keyboard focus, to the full year and its
   counts.
   It opens BESIDE the card and pushes it over, on the bare paper: the column
   is `auto`, so it follows the nav's own width as that transitions. (It was
   an opaque panel laid over the card first; taken out on request.) The
   justified rows are pure flexbox, so the push re-lays them each frame and a
   frame can hop to the next row mid-way — that is the cost of pushing.
   ⚠️ The small delay before opening is what stops it springing open every
   time the pointer crosses the rail on its way to the card.

   ⚠️ Picking a year closes it again a moment later, with the pointer still on
   it: js/photos.js sets `.is-resting`, which every open rule here excludes,
   until the pointer leaves the rail or keyboard focus moves on. Mouse clicks
   don't focus the years (the mousedown is cancelled), or `:focus-within`
   would hold the panel open after the pointer had gone.

   Every part of the open moves on ONE ease-in-out curve and one duration —
   the nav's width (and so the card's), the chip's padding, the century and the counts — so it
   reads as a single movement rather than several things settling at
   different rates. Closing runs the same curve back with no delay.

   ⚠️ Resting, each year is a SQUARE the width of the rail. The chip's padding
   goes to 0 and the TITLE carries it instead, in its own em: 1.7em is its
   line box, 1.36em is the ink of two digits (measured; 1.46em with the
   trailing tracking, which is left out so the pair centres optically). The
   max-width / max-height the century and the counts open to are their real
   sizes (1.58em, 3.84em), not generous caps — an eased max-width that
   overshoots finishes its visible part early and the curve is lost. */
@media (min-width: 641px) {
  .pindex--chron {
    --pnav-rail: clamp(62px, 5.6vw, 76px);
    /* Sine in-out, not cubic: cubic's middle runs at 3x the average speed
       and the push lurched once it got going. Sine peaks near 1.6x. */
    --rail-ease: cubic-bezier(0.37, 0, 0.63, 1);
    --rail-dur: 175ms;
    --rail-wait: 120ms;
    grid-template-columns: auto minmax(0, 1fr);
  }
  .pnav { width: var(--pnav-rail); }
  .pnav:not(.is-resting):is(:hover, :focus-within) { width: var(--pnav-w); }

  .pnav .index-link {
    padding: 0;
    white-space: nowrap;
    overflow: hidden;
  }
  .pnav .index-title {
    padding-block: calc((var(--pnav-rail) - 1.7em - 1px) / 2);
    margin-left: calc((var(--pnav-rail) - 1.36em) / 2);
  }
  .pnav:not(.is-resting):is(:hover, :focus-within) .index-link { padding: 11px 12px; }
  .pnav:not(.is-resting):is(:hover, :focus-within) .index-title { padding-block: 0; margin-left: 0; }

  /* The century folds away. */
  .pyear-long {
    display: inline-block;
    vertical-align: bottom;
    max-width: 0;
    opacity: 0;
    overflow: hidden;
  }
  .pyear-short { display: inline; }
  .pnav:not(.is-resting):is(:hover, :focus-within) .pyear-long { max-width: 1.58em; opacity: 1; }
  .pnav:not(.is-resting):is(:hover, :focus-within) .pyear-short { display: none; }

  .pnav .index-date {
    max-height: 0;
    margin: 0;
    opacity: 0;
    overflow: hidden;
  }
  .pnav:not(.is-resting):is(:hover, :focus-within) .index-date {
    max-height: 3.84em;
    margin-top: 6px;
    opacity: 1;
  }

  /* One curve for all of it. `--wait` is the hover-intent delay, set on the
     open nav and inherited, so closing (where it is unset) starts at once.
     The dim on a single year stays its own quicker, undelayed fade. */
  .pnav:not(.is-resting):is(:hover, :focus-within) { --wait: var(--rail-wait); }
  /* ⚠️ The nav is also a scroll-reveal block (`.sr`), and
     `.plates-scroll .sr`'s transition list outranks a bare `.pnav` — which
     silently dropped the width from it, so the push SNAPPED in one frame
     whatever the curve said. Hence the specificity, and the reveal's own two
     entries carried along in the same list. */
  .plates-scroll .pnav.sr {
    transition:
      width var(--rail-dur) var(--rail-ease) var(--wait, 0ms),
      opacity 700ms cubic-bezier(0.33, 0, 0.2, 1),
      transform 900ms cubic-bezier(0.16, 1, 0.3, 1);
  }
  .pnav .index-link {
    transition:
      padding var(--rail-dur) var(--rail-ease) var(--wait, 0ms),
      opacity 200ms ease,
      color 200ms ease;
  }
  .pnav .index-title {
    transition:
      padding var(--rail-dur) var(--rail-ease) var(--wait, 0ms),
      margin var(--rail-dur) var(--rail-ease) var(--wait, 0ms);
  }
  .pyear-long {
    transition:
      max-width var(--rail-dur) var(--rail-ease) var(--wait, 0ms),
      opacity var(--rail-dur) var(--rail-ease) var(--wait, 0ms);
  }
  .pnav .index-date {
    transition:
      max-height var(--rail-dur) var(--rail-ease) var(--wait, 0ms),
      margin var(--rail-dur) var(--rail-ease) var(--wait, 0ms),
      opacity var(--rail-dur) var(--rail-ease) var(--wait, 0ms);
  }
}

.pcard {
  position: relative;
  min-height: 0;
  background: var(--card);
  border-radius: 20px;
  overflow: hidden;
}
.pcard-scroll {
  height: 100%;
  box-sizing: border-box;
  overflow-y: auto;
  padding: 32px;
  scrollbar-width: none;
  transition: opacity 180ms ease;
}
.pcard-scroll::-webkit-scrollbar { display: none; }
.pcard-scroll.is-out { opacity: 0; }
.pcard-head { margin-bottom: clamp(20px, 3vh, 28px); }

.pchron {
  display: flex;
  flex-direction: column;
  gap: clamp(8px, 1vw, 12px);
}

/* Justified rows: every frame in a row shares one height. Grow and basis are
   both the frame's aspect, so spare width scales the whole row evenly; the
   `::after` soaks up the last row so it isn't stretched to the edge. */
.prow {
  --rh: clamp(110px, 13vw, 200px);
  display: flex;
  flex-wrap: wrap;
  gap: clamp(8px, 1vw, 12px);
}
.prow::after { content: ''; flex: 999 1 0; }
.prow > .frame {
  flex: calc(var(--ar)) 1 calc(var(--ar) * var(--rh));
  width: auto;
  aspect-ratio: var(--ar);
  border-radius: 6px;
}

/* A trip lands in the run at its start date: a wide plate with a caption
   line under it, the name in the colour taken from its cover (`accent`). */
.ptrip {
  display: block;
  color: inherit;
  text-decoration: none;
}
/* More air where a plate meets a row of frames than between two plates. */
.prow + .ptrip,
.ptrip + .prow { margin-top: clamp(14px, 2.6vh, 28px); }
.ptrip + .ptrip { margin-top: clamp(10px, 1.6vh, 18px); }
.ptrip-cover {
  position: relative;
  display: block;
  aspect-ratio: 21 / 8;
  overflow: hidden;
  border-radius: 6px;
  background: var(--paper-lo);
}
.ptrip-cover img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: transform 900ms cubic-bezier(0.2, 0.6, 0.2, 1);
}
a.ptrip:hover .ptrip-cover img,
a.ptrip:focus-visible .ptrip-cover img { transform: scale(1.02); }
a.ptrip:focus-visible { outline: none; }
a.ptrip:focus-visible .ptrip-cover { box-shadow: 0 0 0 2px var(--ink); }

.ptrip-cap {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: baseline;
  gap: 20px;
  padding-top: 12px;
}
.ptrip-lbl {
  font-size: clamp(0.54rem, 0.86vw, 0.66rem);
  line-height: 1.4;
  text-transform: uppercase;
  letter-spacing: 0.2em;
  color: var(--ink-soft);
  white-space: nowrap;
}
.ptrip-name {
  font-family: var(--font-display);
  font-size: clamp(1rem, 2vw, 1.6rem);
  line-height: 1.2;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--accent, inherit);
}
@media (prefers-color-scheme: dark) {
  .ptrip-name { color: color-mix(in oklab, var(--accent, currentColor) 55%, #fff); }
}
.pchron-chev {
  width: 1.1em;
  height: 1.1em;
  vertical-align: -0.2em;
}

/* A trip marked `soon` in the manifest is WIP: the same plate, not a link. */
.ptrip--soon .ptrip-cover img { filter: grayscale(0.35); opacity: 0.85; }

/* A phone: the years become a row of chips over the card. */
@media (max-width: 640px) {
  .pindex--chron {
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: auto minmax(0, 1fr);
    row-gap: 10px;
  }
  .pnav { overflow: auto hidden; }
  .pnav .index-list { display: flex; gap: 4px; }
  .pnav .index-item + .index-item .index-link { border-top-color: transparent; }
  .pnav .index-link { padding-block: 8px; white-space: nowrap; }
  /* Just the year: the counts are in the card's own header. */
  .pnav .index-date { display: none; }
  .pcard-scroll { padding: 18px; }
  .prow { --rh: 84px; gap: 6px; }
  .ptrip-cover { aspect-ratio: 16 / 9; }
  .ptrip-cap { grid-template-columns: minmax(0, 1fr); gap: 4px; }
}

/* ── Loose frames ──────────────────────────────────────────────────────────
   Masonry by CSS columns rather than grid. A grid has to be told each item's
   row span, which means knowing every frame's aspect before layout; columns
   just flow, and these are mixed portrait and landscape from six years of two
   phones and three cameras. */
.stamps-title {
  font-family: var(--font-display);
  margin: 0 0 clamp(14px, 2.4vh, 24px);
  font-weight: 400;
  font-size: clamp(0.8rem, 1.3vw, 1.05rem);
  text-transform: uppercase;
  letter-spacing: 0.12em;
  color: var(--ink-soft);
}

.masonry {
  columns: clamp(140px, 17vw, 230px);
  column-gap: clamp(8px, 1.4vw, 16px);
}

/* The loose frames sit on a white card of their own, the same one the featured
   set is on (`--frame`, so near-black in dark mode), with 20px corners. The
   photographs on it are lightly rounded.

   ⚠️ The masonry gives every frame a bottom margin, including the last in each
   column, which would leave the card's bottom padding 16px deeper than its
   sides — the negative margin takes that back. */
.ploose {
  background: var(--card);
  border-radius: 20px;
  padding: 32px;
}
.ploose .masonry { margin-bottom: calc(-1 * clamp(8px, 1.4vw, 16px)); }
.ploose .frame {
  border-radius: 6px;
  overflow: hidden;
}

.masonry .frame {
  break-inside: avoid;
  margin-bottom: clamp(8px, 1.4vw, 16px);
  /* The frame's own ratio, so the column reserves its height before the image
     lands and the masonry does not re-flow under the reader. */
  aspect-ratio: var(--ar, 3 / 4);
}

/* A frame is a plate pasted onto the sheet: no hairline and no shadow, never a
   border-radius. Prints do not have rounded corners. */
.frame {
  display: block;
  position: relative;
  width: 100%;
  padding: 0;
  border: 0;
  background: var(--paper-lo);
  cursor: pointer;
  overflow: hidden;
}
/* Fills whatever box the context gave the frame. In the masonry that box is
   the image's own ratio and `cover` does nothing; in a day's grid it is a
   square and `cover` is what crops to it. One rule, both layouts. */
.frame img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: transform 700ms cubic-bezier(0.2, 0.6, 0.2, 1),
              filter 300ms ease;
}
.frame:hover img { transform: scale(1.03); }
.frame:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--ink); }

/* ══ A collection ══════════════════════════════════════════════════════════ */

/* ⚠️ `height: 100%` is what makes the cover's own percentage resolve, and it is
   load-bearing. A percentage height against an AUTO-height parent computes to
   `auto`, so without this the cover quietly falls back to its image's
   intrinsic ratio — which on a wide sheet is a 2:3 photograph a third taller
   than the window, and the byline under it is below the fold on arrival.
   The article overflowing its own 100% is fine and intended: the scroller is
   what the rest of the piece scrolls inside. */
.pcol { height: 100%; }

/* The banner sits in the site's content width (960px, `--folio-measure`),
   centred, like the lede and the frames under it — not full bleed. At that
   width an aspect ratio is safe (it can't outgrow the window the way a
   full-width 16:9 did on a wide monitor); `max-height` still stops it at
   ninety percent of the screen on a short one, which is what `.pcol`'s
   `height: 100%` resolves. The title sizes off the banner (`cqw`), not the
   viewport, so it can't run off the edge of a capped banner. */
.pcol-cover {
  position: relative;
  width: min(960px, 100%);
  margin-inline: auto;
  aspect-ratio: 16 / 9;
  max-height: 90%;
  min-height: 260px;
  overflow: hidden;
  border-radius: 20px;
  background: var(--paper-lo);
  container-type: inline-size;
}

.pcol-cover img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* The type sits in the photograph's own shadow. A scrim rather than a flat
   overlay: the cover is a dark vault and the words are in the bottom-left of
   it, so the wash is angled to where they are and stops short of the oculus. */
.pcol-cover::after {
  content: '';
  position: absolute;
  inset: 0;
  background:
    linear-gradient(200deg, transparent 38%, rgba(10, 12, 14, 0.52) 100%),
    linear-gradient(to top, rgba(10, 12, 14, 0.4) 0%, transparent 46%);
  pointer-events: none;
}

.pcol-cover-type {
  position: absolute;
  left: clamp(18px, 4cqw, 40px);
  right: clamp(18px, 4cqw, 40px);
  bottom: clamp(26px, 6vh, 64px);
  z-index: 1;
  color: #fff;
}

/* ⚠️ Wingman, not the comp's Bricolage Grotesque. The site has exactly two
   faces and the split between them is load-bearing (see styles.css); a third
   one imported for a single word is how that stops being true. Set large and
   tracked it carries the comp's weight without adding a family. */
.pcol-title {
  font-family: var(--font-display);
  margin: 0;
  font-weight: 400;
  font-size: clamp(2.2rem, 9cqw, 5.5rem);
  line-height: 0.92;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  /* The type is white on a photograph, which is the one place on this site
     something needs lifting off its ground. */
  text-shadow: 0 2px 30px rgba(0, 0, 0, 0.45);
}

.pcol-byline {
  display: flex;
  align-items: baseline;
  gap: clamp(10px, 1.4vw, 20px);
  margin: clamp(10px, 1.6vh, 20px) 0 0;
  font-size: clamp(0.62rem, 1.1vw, 0.95rem);
  letter-spacing: 0.06em;
  text-shadow: 0 1px 20px rgba(0, 0, 0, 0.5);
}
.pcol-byline .slash { opacity: 0.66; }

/* The lede. Set exactly like the writing page's prose (`.prose` in
   styles.css): the body face, same size, same leading, full ink, ragged
   right — it is the trip's opening paragraph, so it reads as one. */
.pcol-lede {
  max-width: min(960px, 100%);
  margin: clamp(30px, 6vh, 72px) auto clamp(28px, 5vh, 56px);
  font-family: var(--font-body);
  font-size: clamp(0.95rem, 1.2vw, 1.1rem);
  line-height: 1.9;
  text-wrap: pretty;
  color: var(--ink);
}

.pcol-actions {
  display: flex;
  justify-content: center;
  margin-bottom: clamp(30px, 6vh, 70px);
}

/* Drawn as a drafting mark, like `.expand` — a hairline box in the spec ink,
   not a filled button. */
.playbtn {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  padding: 10px 20px;
  background: none;
  border: 1px solid var(--rule);
  border-radius: 2px;
  color: var(--ink-soft);
  font-family: var(--font-display);
  font-size: clamp(0.6rem, 0.92vw, 0.74rem);
  text-transform: uppercase;
  letter-spacing: 0.2em;
  cursor: pointer;
  transition: color 200ms ease, border-color 200ms ease;
}
.playbtn:hover,
.playbtn:focus-visible {
  color: var(--ink);
  border-color: var(--ink-soft);
  outline: none;
}
.playbtn svg { width: 11px; height: 11px; flex: 0 0 auto; }

/* ══ The stamps ════════════════════════════════════════════════════════════
   One per day, laid out four to a row the way they are in the comp, each
   pinned on its own small angle.

   ⚠️ The tilt comes from the data, not from `Math.random()` at render. A
   random rake re-rolls on every paint, so a stamp moves when you come back to
   the page — and the layout never settles. */
.stamps { margin-top: clamp(10px, 2vh, 20px); }

.stamp-row {
  position: relative;
  display: grid;
  grid-template-columns: repeat(4, minmax(0, var(--stamp-w)));
  justify-content: center;
  gap: clamp(26px, 5vh, 56px) var(--stamp-gap);
  margin-bottom: clamp(26px, 5vh, 56px);
}

/* Every other row runs right to left, so the route is a serpentine (see
   drawRoute() in js/photos.js). DOM order stays chronological — only the
   picture is reversed, so tab order and screen readers still go day by day. */
.stamp-row--rev { direction: rtl; }
.stamp-row--rev > .stamp { direction: ltr; }

/* The route: ONE dotted line through the trip in order, drawn by
   js/photos.js off the stamps' measured positions — along a row, a U-turn in
   the margin past its last stamp, back along the next. It never crosses a
   caption. A dot at each end.

   ⚠️ It spans `.stamps`, not a row, which is the whole point: the turns are
   the bits that make it one journey. Pinned behind everything and
   `pointer-events: none`, so it never eats a click meant for a stamp. */
.stamp-route {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 0;
  /* The dots' weight is in the colour, not in `opacity`: the route fades on
     with the stamps (`.sr`), which animates opacity. */
  color: color-mix(in srgb, var(--ink) 45%, transparent);
  overflow: visible;
  pointer-events: none;
}
.stamp-route path {
  stroke-width: 2.4;
  stroke-dasharray: 0.1 8;
}

/* The stamp's place in the route. A row that reads right to left needs it. */
.stamp-num {
  position: absolute;
  top: -9px;
  left: -9px;
  z-index: 2;
  display: grid;
  place-items: center;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: var(--ink);
  color: var(--frame);
  font-family: var(--font-display);
  font-size: 10px;
  line-height: 1;
  letter-spacing: 0;
}

.stamps { position: relative; }

.stamp {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: clamp(9px, 1.5vh, 17px);
  padding: 0;
  background: none;
  border: 0;
  color: inherit;
  font: inherit;
  cursor: pointer;
  /* The shadow rides the wrapper: the card itself is masked into a perforated
     shape, and a `box-shadow` follows the border box rather than the mask —
     it would draw a rectangle behind a scalloped card. */
  filter: var(--stamp-shadow);
  transition: filter 320ms ease, transform 320ms cubic-bezier(0.2, 0.7, 0.2, 1);
}

@media (prefers-reduced-motion: no-preference) {
  .stamp:hover,
  .stamp:focus-visible {
    transform: translateY(-4px) scale(1.022);
    filter: var(--stamp-shadow-lift);
  }
}
.stamp:focus-visible { outline: none; }
.stamp:focus-visible .stamp-card { box-shadow: 0 0 0 2px var(--ink); }

/* ── The card, and its perforation ─────────────────────────────────────────
   Four masks, one per edge: each is a tile one pitch wide (or tall) and FULL
   height (or width), carrying a single notch at its own edge and solid
   everywhere else. Intersected, only the four notch rows are taken out.

   ⚠️ Every layer has to be full-size in its cross axis. A layer sized `pitch
   51%` covers half the card, and under `intersect` the half it does not cover
   is unpainted — which is to say the bottom of the stamp disappears.

   ⚠️ Where `mask-composite` is not supported the card falls back to a plain
   rectangle, which is a stamp with no teeth rather than a broken one. */
.stamp-card {
  position: relative;
  width: 100%;
  padding: clamp(5px, 0.7vw, 9px);
  box-sizing: border-box;
  background: var(--stamp-paper);

  --notch-t: radial-gradient(circle var(--perf-r) at 50% 0, #0000 98%, #000 100%);
  --notch-b: radial-gradient(circle var(--perf-r) at 50% 100%, #0000 98%, #000 100%);
  --notch-l: radial-gradient(circle var(--perf-r) at 0 50%, #0000 98%, #000 100%);
  --notch-r: radial-gradient(circle var(--perf-r) at 100% 50%, #0000 98%, #000 100%);

  -webkit-mask-image: var(--notch-t), var(--notch-b), var(--notch-l), var(--notch-r);
  mask-image: var(--notch-t), var(--notch-b), var(--notch-l), var(--notch-r);
  -webkit-mask-size: var(--perf-pitch) 100%, var(--perf-pitch) 100%, 100% var(--perf-pitch), 100% var(--perf-pitch);
  mask-size: var(--perf-pitch) 100%, var(--perf-pitch) 100%, 100% var(--perf-pitch), 100% var(--perf-pitch);
  -webkit-mask-repeat: repeat-x, repeat-x, repeat-y, repeat-y;
  mask-repeat: repeat-x, repeat-x, repeat-y, repeat-y;
  -webkit-mask-position: 50% 0, 50% 100%, 0 50%, 100% 50%;
  mask-position: 50% 0, 50% 100%, 0 50%, 100% 50%;
  -webkit-mask-composite: source-in;
  mask-composite: intersect;
  mask-mode: alpha;
}

/* The picture window. A fixed ratio for every stamp, so a sheet of them is a
   sheet — the twelve engravings are natively anywhere from 0.62 to 1.47, and
   `cover` is what reconciles that without a destructive re-crop on disk. */
.stamp-art {
  position: relative;
  display: block;
  aspect-ratio: 5 / 6;
  overflow: hidden;
  background: var(--stamp-paper);
}
.stamp-art > img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* ── The photographs, cycling under the hand ───────────────────────────────
   Hovering a stamp riffles the frames it stands for. Each one is wiped on
   from the left over the engraving rather than cross-faded, so it reads as
   somebody flicking through prints, not as a slideshow. */
.stamp-cycle {
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
}
.stamp-cycle img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0;
  clip-path: inset(0 100% 0 0);
}
.stamp-cycle img.is-on {
  opacity: 1;
  clip-path: inset(0 0 0 0);
  transition: clip-path 190ms cubic-bezier(0.3, 0.8, 0.3, 1);
}

/* The flash that sells the swipe: a hard bright edge travelling with the wipe.
   It is the only thing on the stamp that is not paper or ink, and it is gone
   in under a fifth of a second. */
.stamp-cycle::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(100deg, transparent 38%, var(--scan-glow) 50%, transparent 62%);
  opacity: 0;
  mix-blend-mode: screen;
}
.stamp.is-riffling .stamp-cycle::after {
  animation: stamp-flash 190ms linear;
}
@keyframes stamp-flash {
  from { opacity: 0.85; transform: translateX(-70%); }
  to   { opacity: 0;    transform: translateX(70%); }
}

/* ── The caption ───────────────────────────────────────────────────────────
   Three lines, exactly as the comp sets them: what it is, which day, where.
   The stamp images carry no type of their own — the captions were cropped off
   so they could be set in the page's own face and stay themeable. */
.stamp-cap {
  display: flex;
  flex-direction: column;
  gap: 2px;
  font-family: var(--font-display);
  font-size: clamp(0.52rem, 0.82vw, 0.8rem);
  line-height: 1.25;
  text-align: center;
  color: var(--ink);
}
.stamp-cap-day,
.stamp-cap-place { color: var(--ink-soft); }
/* The long ones (`Duomo di Santa Maria del Fiore`) are stepped down in the
   comp too, rather than wrapped. */
.stamp-cap-title.is-long { font-size: 0.78em; }

/* ── Gathering, when one is opened ─────────────────────────────────────────
   The others do not simply vanish: they are drawn in toward the middle as
   they go, so the sheet reads as being cleared for the one you picked. The
   distance is handed in per stamp by js/photos.js. */
.stamps.is-gathering .stamp {
  transition: opacity 340ms ease, transform 420ms cubic-bezier(0.4, 0, 0.2, 1);
}
.stamps.is-gathering .stamp:not(.is-open) {
  opacity: 0;
  transform: translate(var(--gx, 0px), var(--gy, 0px)) scale(0.82);
  pointer-events: none;
}
/* The opened one is lifted out of the page and flown by the Web Animations
   API; leaving its resting transform on would fight the flight. */
.stamp.is-open {
  opacity: 0;
  transition: none;
}

/* ── Played through ───────────────────────────────────────────────────────
   Everything below the banner comes off and the whole trip is laid out in one
   continuous field. Not a slideshow and not a stack of modals — the cover
   stays because it is what says which trip this is, and the rest is frames. */
body.gallery .pcol-lede,
body.gallery .stamps { display: none; }
/* With the lede gone the button would sit flush on the banner's foot. */
body.gallery .pcol-actions { margin-top: clamp(28px, 5vh, 56px); }

/* ── A collection with no stamps ──────────────────────────────────────────
   Its days, one white card each (`.ploose`, the loose frames' card) under the
   cover: a header line, then that day's frames in the same masonry. Same
   960px content width as the banner above them. */
.pdays {
  max-width: min(960px, 100%);
  margin-inline: auto;
  display: grid;
  gap: clamp(18px, 3vw, 34px);
  margin-top: clamp(30px, 6vh, 72px);
}
.pday { scroll-margin-top: clamp(12px, 3vh, 32px); }
.pday-head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 6px clamp(10px, 1.4vw, 20px);
  margin: 0 0 clamp(14px, 2.4vh, 24px);
}
.pday-title,
.pday-meta {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 400;
  text-transform: uppercase;
  letter-spacing: 0.12em;
}
.pday-title { font-size: clamp(0.9rem, 1.5vw, 1.2rem); }
.pday-meta {
  font-size: clamp(0.66rem, 1.05vw, 0.85rem);
  color: var(--ink-soft);
}

.pgallery[hidden] { display: none; }
.pgallery {
  max-width: min(960px, 100%);
  margin: clamp(8px, 1.6vh, 18px) auto 0;
  display: grid;
  gap: clamp(48px, 9vh, 96px);
}

/* ── The day markers ───────────────────────────────────────────────────────
   Each day of the played-through trip is a section: a big numbered marker
   down the left (the index's year rail restated, a square chip with the
   number set large in it) beside that day's title line and its grid. The
   marker is sticky, so it stays at the top of the view for as long as its
   day is scrolling past and hands over to the next one at the break. */
.pgday {
  --pgday-mark: clamp(64px, 7vw, 92px);
  display: grid;
  grid-template-columns: var(--pgday-mark) minmax(0, 1fr);
  column-gap: clamp(16px, 2.4vw, 32px);
  align-items: start;
}
.pgday-mark {
  position: sticky;
  top: clamp(12px, 2vh, 24px);
  display: grid;
  place-items: center;
  width: var(--pgday-mark);
  aspect-ratio: 1;
  border-radius: 8px;
  background: color-mix(in srgb, var(--ink) 9%, transparent);
  color: var(--ink);
}
.pgday-num {
  font-family: var(--font-display);
  font-size: clamp(1.4rem, 2.6vw, 2.2rem);
  line-height: 1;
  letter-spacing: 0.08em;
  /* The trailing tracking would push the pair off centre. */
  margin-right: -0.08em;
}
/* The title line sits level with the top of the marker. */
.pgday-head { margin-top: 0; }
.pgday-grid { margin-top: 0; }

/* ══ The day view ══════════════════════════════════════════════════════════
   The stamp lands in the middle of the sheet and is repainted into the
   photograph it was drawn from, with that frame's real EXIF printed under it
   and the day's title and copy to its right.
   Everything below the fold is the day itself. */
/* ⚠️ Appended to <body> and `fixed`, like the lightbox: the whole viewport,
   over the masthead. Under it, not over it — the lightbox opens from here. */
.dayview {
  position: fixed;
  inset: 0;
  z-index: 90;
  display: flex;
  flex-direction: column;
  opacity: 0;
  transition: opacity 260ms ease;
}
.dayview[hidden] { display: none; }
.dayview.is-in { opacity: 1; }

/* Opaque, on the same ground as the lightbox: the nav must not show through. */
.dayview::before {
  content: '';
  position: absolute;
  inset: 0;
  background: var(--frame);
}

.dayview-scroll {
  position: relative;
  height: 100%;
  overflow-y: auto;
  overflow-x: hidden;
  /* The top clears the close button with room to spare; the stamp shouldn't
     land tucked up under the edge of the view. */
  padding: clamp(56px, 11vh, 120px) clamp(16px, 4vw, 64px) clamp(48px, 9vh, 110px);
  scrollbar-width: none;
}
.dayview-scroll::-webkit-scrollbar { display: none; }

/* ── The stage ─────────────────────────────────────────────────────────────
   The engraving and the photograph are the same box, stacked. The photograph
   is masked in; the engraving simply stays where it is underneath.

   The photograph and the copy beside it are centred in the view as ONE
   group: two tracks, the photograph's sized by the stage and the copy's a
   fixed measure, with the pair centred between them. */
.scan {
  display: grid;
  grid-template-columns: auto minmax(0, clamp(220px, 26vw, 360px));
  justify-content: center;
  align-items: center;
  gap: clamp(18px, 3vw, 48px);
  max-width: min(1120px, 100%);
  margin: 0 auto clamp(24px, 5vh, 52px);
  min-height: clamp(280px, 56vh, 620px);
}

.scan-figure {
  grid-column: 1;
  margin: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: clamp(10px, 1.6vh, 16px);
}

.scan-stage {
  position: relative;
  /* Not a percentage: the column is `auto`, sized by this. */
  width: clamp(240px, 42vh, 460px);
  aspect-ratio: 5 / 6;
  background: var(--stamp-paper);
  padding: clamp(6px, 0.8vw, 11px);
  box-sizing: border-box;
}

.scan-plate {
  position: relative;
  width: 100%;
  height: 100%;
  overflow: hidden;
}

.scan-art,
.scan-photo {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* ⚠️ THE ENGRAVING IS MASKED AWAY, not the photograph masked in.

   Built the other way round first, and it did not paint: with the photograph
   masked on top of an opaque engraving it stayed invisible even once the sweep
   had ended and its own `mask-image` computed to `none` — loaded, on top,
   hit-testable, and not drawn until the layer underneath was removed. Whatever
   the compositor was doing there, taking the ENGRAVING off instead leaves an
   ordinary opaque photograph at the bottom of the stack, and the resting state
   is a plain image with no mask on it at all.

   It is also the truer gesture. The brush is not laying a photograph down on
   top of a drawing; it is taking the drawing off the photograph that was
   always underneath it.

   The mask itself is one SVG: an alpha ramp pushed through an feTurbulence /
   feDisplacementMap pair whose frequency is low across and high down, so the
   ramp's iso-lines break into bristles. A plain `linear-gradient` reads as a
   photocopier — one straight edge crossing at a constant rate is a machine.

   ⚠️ The mask is 300% wide and TRAVELS, rather than the gradient's stops being
   animated. Animating colour stops re-rasterises the mask image every frame;
   `mask-position` is only a composited offset.

   ⚠️ The plateaus are sized around the DISPLACEMENT. The element sees one
   third of the mask at a time and the filter pushes the edge up to 6.5% of the
   mask's width either way, so the opaque run has to reach past 0.333 + 0.065
   and the clear run has to start before 0.667 - 0.065. Hence 0.42 and 0.58:
   tighten them and the engraving is already breaking up before the brush has
   touched it, or a rag of it survives at the end. */
.scan-art {
  --brush: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='600' height='400' viewBox='0 0 600 400' preserveAspectRatio='none'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='0'%3E%3Cstop offset='0' stop-color='%23fff' stop-opacity='1'/%3E%3Cstop offset='0.42' stop-color='%23fff' stop-opacity='1'/%3E%3Cstop offset='0.58' stop-color='%23fff' stop-opacity='0'/%3E%3Cstop offset='1' stop-color='%23fff' stop-opacity='0'/%3E%3C/linearGradient%3E%3Cfilter id='b' x='-25%25' y='-25%25' width='150%25' height='150%25'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.004 0.055' numOctaves='4' seed='11' result='n'/%3E%3CfeDisplacementMap in='SourceGraphic' in2='n' scale='78' xChannelSelector='R' yChannelSelector='G'/%3E%3C/filter%3E%3C/defs%3E%3Crect width='600' height='400' fill='url(%23g)' filter='url(%23b)'/%3E%3C/svg%3E");
  -webkit-mask-image: var(--brush);
  mask-image: var(--brush);
  -webkit-mask-size: 300% 100%;
  mask-size: 300% 100%;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  /* Untouched: the plate is showing the mask's fully opaque third. */
  -webkit-mask-position: 0% 0;
  mask-position: 0% 0;
  mask-mode: alpha;
}

.scan.is-painting .scan-art {
  animation: scan-paint var(--scan-dur, 2100ms) cubic-bezier(0.45, 0.02, 0.25, 1) forwards;
}
@keyframes scan-paint {
  from { -webkit-mask-position: 0% 0;   mask-position: 0% 0; }
  to   { -webkit-mask-position: 100% 0; mask-position: 100% 0; }
}

/* And then it is simply gone. ⚠️ `opacity`, not a mask left parked at its last
   offset: the whole point of masking the engraving rather than the photograph
   is that the resting state owes the compositor nothing. */
.scan.is-painted .scan-art {
  animation: none;
  opacity: 0;
  -webkit-mask-image: none;
  mask-image: none;
}

/* The lamp travelling with the edge. Blurred and tilted a couple of degrees,
   so it is a light being passed over the print rather than a progress bar. */
.scan-line {
  position: absolute;
  top: -8%;
  bottom: -8%;
  left: 0;
  width: 26%;
  z-index: 2;
  background: linear-gradient(95deg, transparent 0%, var(--scan-glow) 52%, transparent 100%);
  filter: blur(7px);
  mix-blend-mode: screen;
  opacity: 0;
  pointer-events: none;
}
.scan.is-painting .scan-line {
  animation: scan-sweep var(--scan-dur, 2100ms) cubic-bezier(0.45, 0.02, 0.25, 1) forwards;
}
@keyframes scan-sweep {
  0%   { transform: translateX(-120%) rotate(1.5deg); opacity: 0; }
  10%  { opacity: 0.95; }
  86%  { opacity: 0.95; }
  100% { transform: translateX(420%) rotate(1.5deg); opacity: 0; }
}

/* The sparkle. One span per mote, each parked where the edge will pass and
   told to twinkle at the moment it does — js/photos.js sets --t (where) and
   the delay falls out of it. Cheap, and exactly in step with the brush,
   because both are driven off the same duration. */
.scan-sparks {
  position: absolute;
  inset: 0;
  z-index: 3;
  pointer-events: none;
}
.scan-spark {
  position: absolute;
  left: calc(var(--t) * 100%);
  top: calc(var(--y) * 100%);
  width: var(--sz, 3px);
  height: var(--sz, 3px);
  margin: calc(var(--sz, 3px) / -2);
  border-radius: 50%;
  background: var(--scan-spark);
  box-shadow: 0 0 6px 1px var(--scan-spark);
  opacity: 0;
}
.scan.is-painting .scan-spark {
  animation: spark 520ms ease-out both;
  animation-delay: calc(var(--t) * var(--scan-dur, 2100ms) * 0.94);
}
@keyframes spark {
  0%   { opacity: 0; transform: scale(0.4); }
  35%  { opacity: 1; transform: scale(1.25); }
  100% { opacity: 0; transform: scale(0.5); }
}

/* ── The EXIF, printed as it develops ──────────────────────────────────────
   Real values, read off the file itself at build time — which is the entire
   reason the panel is here. It is the receipt for the photograph: this was a
   frame on a real camera at a real f-stop, not the engraving you were just
   looking at. */
.scan-meta {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  /* No wider than the photograph it sits under. */
  max-width: clamp(240px, 42vh, 460px);
  gap: 6px clamp(12px, 2vw, 24px);
  font-size: clamp(0.48rem, 0.76vw, 0.6rem);
  text-transform: uppercase;
  letter-spacing: 0.16em;
  color: var(--ink-soft);
}
.scan-meta-item { opacity: 0; }

.scan.is-painting .scan-meta-item,
.scan.is-painted .scan-meta-item {
  animation: meta-in 420ms cubic-bezier(0.2, 0.6, 0.2, 1) both;
  animation-delay: calc(240ms + var(--i) * 110ms);
}
@keyframes meta-in {
  from { opacity: 0; transform: translateY(5px); }
  to   { opacity: 1; transform: none; }
}

/* ── The day itself ────────────────────────────────────────────────────── */
.dayview-body {
  grid-column: 2;
  text-align: center;
  max-width: min(var(--measure), 100%);
}

.dayview-head { margin-bottom: clamp(16px, 3vh, 30px); }

.dayview-title {
  font-family: var(--font-display);
  margin: 0;
  font-weight: 400;
  font-size: clamp(1.05rem, 2.2vw, 1.7rem);
  line-height: 1.32;
  text-transform: uppercase;
  letter-spacing: 0.07em;
}

.dayview-meta {
  margin: 10px 0 0;
  font-size: clamp(0.54rem, 0.88vw, 0.66rem);
  text-transform: uppercase;
  letter-spacing: 0.2em;
  color: var(--ink-soft);
}

.dayview-blurb {
  font-size: clamp(0.78rem, 1vw, 0.92rem);
  line-height: 1.95;
}
.dayview-blurb p { margin: 0 0 1.35em; }
/* Or the copy sits a paragraph-gap high beside the photograph. */
.dayview-blurb p:last-child { margin-bottom: 0; }

/* ── Keepsakes ─────────────────────────────────────────────────────────────
   The ticket stubs, receipts and boarding passes. They are cards IN the day's
   masonry, among the frames of the moment they belong to (`dayGridHTML()` in
   js/photos.js places them), not a separate row. Until one is scanned it is
   drawn as an empty slot rather than a stand-in JPEG: a slot says plainly
   that something goes here, where a grey rectangle just looks like an image
   that failed to load. The slot's shape follows the thing: a ticket is wide,
   a receipt is a tall slip, a map is square. */
.keepsake {
  position: relative;
  break-inside: avoid;
  margin-bottom: clamp(10px, 1.4vw, 18px);
  aspect-ratio: 8 / 5;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  gap: 3px;
  padding: 10px 11px;
  box-sizing: border-box;
  border: 1px dashed var(--rule);
  border-radius: 6px;
  background: linear-gradient(160deg, rgba(255, 255, 255, 0.05), transparent 60%);
  font-size: clamp(0.54rem, 0.8vw, 0.66rem);
  text-transform: uppercase;
  letter-spacing: 0.16em;
  color: var(--ink-soft);
}
.keepsake--receipt { aspect-ratio: 5 / 8; }
.keepsake--map { aspect-ratio: 1; }
.keepsake-kind { opacity: 0.6; }

/* Scanned: the scan itself, at its own ratio, with the label under it. */
.keepsake--scan {
  aspect-ratio: auto;
  padding: 0;
  border: 0;
  background: none;
}
.keepsake--scan::before { display: none; }
.keepsake--scan img {
  display: block;
  width: 100%;
  height: auto;
  margin-bottom: 6px;
  border-radius: 6px;
}
.keepsake-note { color: var(--ink); letter-spacing: 0.1em; }

/* A stub is torn off down one side. Notches in the border colour, pinned to
   the left edge — the same trick the stamp's perforation uses, at one edge. */
.keepsake--stub::before {
  content: '';
  position: absolute;
  top: -1px;
  bottom: -1px;
  left: clamp(24px, 4vw, 38px);
  border-left: 1px dashed var(--rule);
}

/* The day's frames and keepsakes: a three-column masonry at each item's own
   ratio, wider than the copy above it and capped at the site's content width
   (960px, `--folio-measure`). Two columns on a phone. The columns are real
   elements filled in row order by js/photos.js (`dayColumnsHTML()`), not CSS
   `columns`, so the day reads left to right in the order it happened. */
.daygrid {
  --day-gap: clamp(10px, 1.4vw, 18px);
  max-width: min(960px, 100%);
  margin: clamp(20px, 4vh, 40px) auto 0;
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--day-gap);
  align-items: start;
}
.daycol {
  display: grid;
  gap: var(--day-gap);
}
.daygrid .frame {
  aspect-ratio: var(--ar, 3 / 4);
  border-radius: 6px;
}
.daygrid .keepsake { margin-bottom: 0; }

/* A frame that is standing in for one not yet in the repo. Marked, because an
   unmarked stand-in is just a wrong caption. */
.frame--standin::after {
  content: '';
  position: absolute;
  inset: 0;
  box-shadow: inset 0 0 0 1px var(--rule);
  background: repeating-linear-gradient(
    45deg, transparent 0 7px, rgba(127, 140, 148, 0.07) 7px 14px);
  pointer-events: none;
}

/* ── The day view's own furniture ──────────────────────────────────────── */
.dayview-close,
.dayview-step {
  position: absolute;
  z-index: 3;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 34px;
  height: 34px;
  padding: 0;
  color: var(--ink-soft);
  cursor: pointer;
  transition: color 200ms ease, border-color 200ms ease;
}
/* The day arrows are round; the close is the bare mark, no box. */
.dayview-step {
  background: color-mix(in srgb, var(--paper-hi) 78%, transparent);
  border: 1px solid var(--rule);
  border-radius: 50%;
}
.dayview-close {
  background: none;
  border: 0;
}
.dayview-close:hover,
.dayview-step:hover,
.dayview-close:focus-visible,
.dayview-step:focus-visible {
  color: var(--ink);
  border-color: var(--ink-soft);
  outline: none;
}
.dayview-close { top: clamp(12px, 2vh, 24px); right: clamp(12px, 2vw, 24px); }
.dayview-close svg { width: 18px; height: 18px; }
.dayview-step svg { width: 13px; height: 13px; }
.dayview-step--prev { left: clamp(12px, 2vw, 24px); top: 50%; transform: translateY(-50%); }
.dayview-step--next { right: clamp(12px, 2vw, 24px); top: 50%; transform: translateY(-50%); }
.dayview-step[disabled] { opacity: 0.3; pointer-events: none; }

/* ══ A trip, lifted off the index ══════════════════════════════════════════
   js/photos.js `liftTrip()`. The cover photograph flies from its card to the
   trip page's banner rect (measured, see `bannerRect()`) while the index fades
   off the sheet; there it crumples, the illustration is brushed over it on a
   canvas, and it flattens back out as the illustration, exactly where the
   banner is. The page then fades up under it and the overlay fades off.

   No background: the sheet itself is what is left when the index goes. */
.plates-scroll { transition: opacity 420ms ease; }
.plates-scroll.is-lifting { opacity: 0; pointer-events: none; }
/* Changing page between the index and a trip: the old one fades off before
   the new one is painted (`swapIn()` in js/photos.js). */
.plates-scroll.is-leaving {
  opacity: 0;
  pointer-events: none;
  transition-duration: 240ms;
}

/* Fading on as you come to it (`watchReveal()`): transparent and a little
   low until the block first scrolls into view. The delay is set per block
   by js, so a screenful arrives top to bottom. `.sr--fade` is opacity only,
   for the stamp rows, which the route is measured off. */
.plates-scroll .sr {
  opacity: 0;
  transform: translateY(16px);
  transition:
    opacity 700ms cubic-bezier(0.33, 0, 0.2, 1),
    transform 900ms cubic-bezier(0.16, 1, 0.3, 1);
}
.plates-scroll .sr--fade { transform: none; }
.plates-scroll .sr.is-shown { opacity: 1; transform: none; }
.ptrip.is-lifted .ptrip-cover img { visibility: hidden; }

/* The trip page's banner, laid out by its own rules to be measured. It has
   to sit exactly where `.plates-scroll`'s content does. */
.tripscan-probe {
  position: absolute;
  inset: 0;
  padding-inline: var(--sheet-pad);
  box-sizing: border-box;
  visibility: hidden;
  pointer-events: none;
}

.tripscan {
  position: fixed;
  inset: 0;
  z-index: 8;
  cursor: pointer;
  transition: opacity 600ms ease;
}
.tripscan.is-out { opacity: 0; pointer-events: none; }

/* Placed on the banner's rect by js; the flight is a FLIP on it. */
.tripscan-stage {
  position: fixed;
  transform-origin: 50% 50%;
}

/* The shadow of the sheet lifted off the page while it is crumpled. Its own
   element riding the same keyframes as the paper, rather than a `filter` on
   the stage: an animated drop-shadow repaints the whole layer every frame,
   and this is a transform and an opacity. */
.tripscan-shade {
  position: absolute;
  inset: 0;
  border-radius: 20px;
  box-shadow: 0 18px 44px rgba(0, 0, 0, 0.34), 0 4px 12px rgba(0, 0, 0, 0.18);
  opacity: 0;
  transform-origin: 50% 50%;
  will-change: transform, opacity;
  transition: opacity 600ms ease;
}
.tripscan.is-crumpled .tripscan-shade { opacity: 1; }
.tripscan.is-flat .tripscan-shade { opacity: 0; transition-duration: var(--flatten-dur, 700ms); }

/* The sheet. Rests with the banner's own 20px corners. */
.tripscan-paper {
  position: absolute;
  inset: 0;
  overflow: hidden;
  clip-path: inset(0 round 20px);
  background: var(--paper-lo);
  transform-origin: 50% 50%;
  /* Its own layer from the start, so the crumple doesn't have to create and
     rasterise one on its first frame (that was an intermittent hitch). */
  will-change: transform;
}
.tripscan-photo,
.tripscan-brush,
.tripscan-art,
.tripscan-creases {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}
.tripscan-photo,
.tripscan-art { object-fit: cover; }
/* The whole illustration, faded up once the last stroke is down to close
   whatever the bristles left bare. */
.tripscan-art { opacity: 0; transition: opacity 360ms ease; }
.tripscan.is-painted .tripscan-art { opacity: 1; }
.tripscan.is-skipped * { transition: none !important; animation: none !important; }

/* Creases: faceted shading drawn once on a canvas (`drawCreases()`), so
   only its opacity moves while the sheet is crumpled. */
.tripscan-creases {
  mix-blend-mode: soft-light;
  opacity: 0;
  pointer-events: none;
  transition: opacity 300ms ease;
}
.tripscan.is-crumpled .tripscan-creases { opacity: 0.9; }
.tripscan.is-flat .tripscan-creases {
  opacity: 0;
  transition-duration: calc(var(--flatten-dur, 620ms) * 0.8);
}

/* The card: both faces of the sheet. In `crumple` mode it is just a box. */
.tripscan-card {
  position: absolute;
  inset: 0;
}

/* ── Turned over (`photos.liftStyle = 'flip'`, the default) ────────────────
   The photograph turns on its vertical axis, lifting toward you as it goes,
   and its back is a blank sheet of cold-press paper the colour of the
   illustration's own (#f1ede6, sampled from cover-art.jpg), which the brush
   then paints the illustration onto. Recompute the colour if the art changes.

   ⚠️ The turn and the lift are two animations on two INDIVIDUAL transform
   properties (`rotate`, `scale`), not one keyframed `transform`. An easing
   applies per keyframe segment, so a 0 → 90 → 180 transform with a midpoint
   for the lift decelerates into edge-on and speeds up out of it — a visible
   hitch at exactly the moment the sheet is thinnest. */
.tripscan--flip .tripscan-stage { perspective: 2000px; }
.tripscan--flip .tripscan-card {
  transform-style: preserve-3d;
  will-change: transform;
}
.tripscan--flip .tripscan-paper,
.tripscan-back {
  -webkit-backface-visibility: hidden;
  backface-visibility: hidden;
}
.tripscan-back {
  position: absolute;
  inset: 0;
  overflow: hidden;
  border-radius: 20px;
  transform: rotateY(180deg);
  background: #f1ede6;
}
/* The paper's tooth: lit relief noise, multiplied in faintly. Only where no
   paint has landed yet shows it; the illustration carries its own. */
.tripscan-back::before {
  content: '';
  position: absolute;
  inset: 0;
  background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='360'%3E%3Cfilter id='t' x='0' y='0' width='100%25' height='100%25'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.09' numOctaves='4' seed='7' stitchTiles='stitch'/%3E%3CfeDiffuseLighting surfaceScale='2.4' diffuseConstant='1.05' lighting-color='%23fff'%3E%3CfeDistantLight azimuth='235' elevation='52'/%3E%3C/feDiffuseLighting%3E%3C/filter%3E%3Crect width='360' height='360' filter='url(%23t)'/%3E%3C/svg%3E") 0 0 / 360px 360px;
  mix-blend-mode: multiply;
  opacity: 0.2;
}
.tripscan.is-turning .tripscan-card {
  animation:
    card-turn var(--flip-dur, 900ms) cubic-bezier(0.55, 0, 0.25, 1) forwards,
    card-lift var(--flip-dur, 900ms) ease-in-out forwards;
}
.tripscan.is-turned .tripscan-card { rotate: y 180deg; }
@keyframes card-turn {
  from { rotate: y 0deg; }
  to   { rotate: y 180deg; }
}
@keyframes card-lift {
  0%, 100% { scale: 1; }
  50%      { scale: 1.06; }
}
/* The shadow narrows as the sheet goes edge-on and darkens as it lifts. */
.tripscan.is-turning .tripscan-shade {
  animation: shade-turn var(--flip-dur, 900ms) ease-in-out forwards;
}
@keyframes shade-turn {
  0%   { opacity: 0; scale: 1 1; }
  50%  { opacity: 1; scale: 0.12 0.97; }
  100% { opacity: 0; scale: 1 1; }
}

/* The motion: one smooth ease into a slightly scrunched pose — a little off
   square and tipped in depth — held while the brush works, then one smooth
   ease back out, flat, with no overshoot. (Two quick grabs and a bounce were
   tried and read as jerky.) ⚠️ Every keyframe carries the same function
   list, so it interpolates per function rather than through a decomposed
   matrix. */
.tripscan.is-crumpled .tripscan-paper,
.tripscan.is-crumpled .tripscan-shade {
  animation: paper-crumple var(--crumple-dur, 640ms) cubic-bezier(0.45, 0, 0.2, 1) forwards;
}
.tripscan.is-flat .tripscan-paper,
.tripscan.is-flat .tripscan-shade {
  animation: paper-flatten var(--flatten-dur, 700ms) cubic-bezier(0.33, 0, 0.15, 1) forwards;
}
@keyframes paper-crumple {
  from { transform: perspective(1400px) rotateX(0deg) rotateY(0deg) rotate(0deg) scale(1, 1); }
  to   { transform: perspective(1400px) rotateX(3deg) rotateY(-4deg) rotate(-0.8deg) scale(0.95, 0.93); }
}
@keyframes paper-flatten {
  from { transform: perspective(1400px) rotateX(3deg) rotateY(-4deg) rotate(-0.8deg) scale(0.95, 0.93); }
  to   { transform: perspective(1400px) rotateX(0deg) rotateY(0deg) rotate(0deg) scale(1, 1); }
}

/* ══ A single frame, opened ════════════════════════════════════════════════
   The plainest thing on the page on purpose: the photograph, its EXIF, and a
   way out. ⚠️ Appended to <body>, not the plates, and `fixed`: it is the whole
   viewport, over the masthead, not a layer inside the sheet. */
.lightbox {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: grid;
  grid-template-rows: minmax(0, 1fr) auto;
  gap: 10px;
  padding: clamp(8px, 1.6vh, 18px);
  cursor: zoom-out;
  box-sizing: border-box;
  background: var(--frame);
  opacity: 0;
  transition: opacity 220ms ease;
}
.lightbox[hidden] { display: none; }
.lightbox.is-in { opacity: 1; }
.lightbox img {
  justify-self: center;
  align-self: center;
  /* No wider than the site's content width (960px, `--folio-measure`). */
  max-width: min(960px, 100%);
  max-height: 100%;
  object-fit: contain;
}
.lightbox-meta {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 6px clamp(12px, 2vw, 24px);
  font-size: clamp(0.48rem, 0.76vw, 0.6rem);
  text-transform: uppercase;
  letter-spacing: 0.16em;
  color: var(--ink-soft);
}

/* ══ Narrow screens ════════════════════════════════════════════════════════
   Four stamps to a row needs four stamps' worth of width. Below that the
   stamps go two to a row. */
@media (max-width: 900px) {
  .stamp-row { grid-template-columns: repeat(2, minmax(0, var(--stamp-w))); }
  /* The rows are rebuilt two to a line (STAMP_COLS in js/photos.js), so the
     serpentine still alternates — it just folds five times instead of twice. */
  /* No room beside the photograph: the copy goes under it, still centred. */
  .scan {
    grid-template-columns: minmax(0, 1fr);
    gap: clamp(14px, 3vh, 26px);
  }
  .scan-figure,
  .dayview-body { grid-column: 1; justify-self: center; }
  .scan-stage { width: min(100%, clamp(240px, 42vh, 460px)); }
}

@media (max-width: 640px) {
  .pcol-cover { aspect-ratio: auto; height: 78%; }
  /* No gutter to hang the marker in: it leads the day's title line instead. */
  .pgday { grid-template-columns: minmax(0, 1fr); row-gap: 12px; }
  .pgday { --pgday-mark: 52px; }
  .pgday-mark { position: static; }
  .daygrid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .dayview-step { display: none; }
}

/* ══ Reduced motion ════════════════════════════════════════════════════════
   The scan is the one thing here that cannot simply be turned off — the whole
   point of the moment is that the engraving becomes the photograph, and
   without the transition there is no way to tell that the two are the same
   picture. So it keeps a plain crossfade and loses the brush, the lamp and the
   sparkle. Everything else settles instantly. */
@media (prefers-reduced-motion: reduce) {
  .plates-scroll .sr { opacity: 1; transform: none; transition: none; }
  .stamp,
  .stamp-cycle img,
  .frame img,
  .ptrip-cover img { transition: none; }

  .scan.is-painting .scan-art,
  .scan.is-painting .scan-line,
  .scan.is-painting .scan-spark,
  .scan.is-painting .scan-meta-item,
  .scan.is-painted .scan-meta-item { animation: none; }

  .scan-line, .scan-sparks { display: none; }
  .scan-art {
    -webkit-mask-image: none;
    mask-image: none;
    transition: opacity 400ms ease;
  }
  .scan.is-painting .scan-art,
  .scan.is-painted .scan-art { opacity: 0; }
  .scan-meta-item { opacity: 1; }
}
