/* ============================================================
   518 Official — gallery + video bank
   css/pages/gallery.css?v=2

   Pairs with js/gallery.js. The grid below is the zero-JS floor and
   is fully usable on its own: every cell is a real link to the media
   file. The viewer styles only matter once gallery.js has upgraded
   those links.

   Contrast: every text colour here is a token measured against its
   own ground in css/tokens.css. Do not substitute --ivory for
   --smoke on --panel without re-measuring.

   ⚠️ TWO THINGS ARE CALLED "gal-open" AND THEY ARE NOT THE SAME THING.
   `.gal-cell .gal-open` is a grid tile. `body.gal-open` is the state class
   js/gallery.js sets while the viewer is up. Unqualified, every tile
   rule below ALSO matched <body> the moment the viewer opened — the
   page took the tile's panel background, a 1px border, r-sm corners,
   cursor:pointer, and (worst) `.gal-open img`, which forced the
   viewer's own picture into a 3/2 box at width:100%. A 1063x1600
   portrait opened at 152px wide on a 390px phone. Worse, `:hover` on
   <body> is true whenever the pointer is anywhere on the page, so the
   tile's hover `translateY(-2px)` landed on the body and made it a
   containing block for fixed descendants: measured with a real pointer
   resting on the page, the fixed viewer resolved to 1438x12045 at
   y=-5522 and the page scroll-jumped 5521px on open. Every tile rule is
   therefore qualified `.gal-cell .gal-open` and must stay that way.
   ============================================================ */

/* ============================================================
   FILTER BAR — album switching

   This bar is the only thing on the page that says which album you
   are in, and the grid below it runs ~12,000px on a desktop and
   ~19,000px on a phone. Scrolled away, the library has no index and
   no way to switch albums without travelling back to the top — so
   the bar STICKS under the header and stays the page's spine.

   On a phone the eight pills wrapped to four rows (200px measured)
   and pushed the first photograph below the fold; sticky at that
   height would eat a quarter of the screen. Below 620px it becomes
   one horizontally-scrolled row that bleeds into the gutter, so a
   half-cut pill advertises the rest.
   ============================================================ */
/* ⚠️ TYPED `ul.` ON PURPOSE — DO NOT DROP THE ELEMENT.
   site.css resets lists with `ul[class], ol[class] { list-style: none;
   padding: 0 }`. That selector is (0,1,1); a bare `.gal-filters` is
   (0,1,0), so the reset WON and every padding declaration below was
   silently discarded. Measured at 390px before this: the bar computed
   padding 0 on all four sides — 44px tall instead of 68 (pills touching
   both edges, thumbnails scrolling flush under them) and, worse, the
   phone scroller lost its leading gutter, so the "All" pill sat at x=0
   with its rounded left cap clipped flat by the viewport edge.
   `ul.gal-filters` is (0,1,1) and this sheet loads after site.css. */
ul.gal-filters {
  position: sticky;
  top: var(--header-h);
  z-index: var(--z-sticky);
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2);
  margin-bottom: var(--sp-6);
  padding: var(--sp-3) 0;
  list-style: none;
  /* opaque: the thumbnails travel under this bar */
  background: var(--bg);
  box-shadow: 0 1px 0 0 var(--line);
}

@media (max-width: 620px) {
  ul.gal-filters {
    flex-wrap: nowrap;
    overflow-x: auto;
    overscroll-behavior-x: contain;
    scrollbar-width: none;
    /* full-bleed to the gutter so a cut pill reads as "there is more" */
    margin-inline: calc(var(--gutter) * -1);
    padding-inline: var(--gutter);
    scroll-padding-inline: var(--gutter);
  }
  .gal-filters::-webkit-scrollbar { display: none; }
  .gal-filters > li { flex: 0 0 auto; scroll-snap-align: start; }
}

.gal-filter {
  appearance: none;
  min-height: 44px;              /* touch target floor */
  padding: 0 var(--sp-4);
  background: var(--panel);
  color: var(--ivory-80);
  border: 1px solid var(--line);
  border-radius: var(--r-pill);
  font-family: var(--font-label);
  font-size: var(--fs-small);
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  white-space: nowrap;
  cursor: pointer;
  transition: background var(--dur-1) var(--ease), color var(--dur-1) var(--ease),
              border-color var(--dur-1) var(--ease);
}

.gal-filter:hover { background: var(--panel-2); color: var(--ivory); }

.gal-filter:focus-visible { outline: 2px solid var(--rizz-hot); outline-offset: 2px; }

/* --ivory on --rizz measures 5.73:1 (AA at any size). The literal #fff
   this replaced was 6.59:1 but off the token layer; the count numeral's
   rgba(255,255,255,.72) measured 4.20:1 and FAILED AA at 15px. */
.gal-filter[aria-pressed="true"] {
  background: var(--rizz);
  border-color: var(--rizz);
  color: var(--ivory);
}

/* ============================================================
   THE GRID — the zero-JS floor
   ============================================================ */
.gal-grid {
  display: grid;
  gap: var(--sp-3);
  grid-template-columns: repeat(2, 1fr);
  padding: 0;
  margin: 0;
  list-style: none;
}

@media (min-width: 620px)  { .gal-grid { grid-template-columns: repeat(3, 1fr); gap: var(--sp-4); } }
@media (min-width: 980px)  { .gal-grid { grid-template-columns: repeat(4, 1fr); } }
@media (min-width: 1400px) { .gal-grid { grid-template-columns: repeat(5, 1fr); } }

/* Every cell is a FRAME, and the frame is the full height of its row.
   Grid rows are sized by their tallest tile; a 2:3 portrait is 2.26x
   the height of a 3:2 landscape, so before this the short tiles hung
   from the top of the row and left a void under them — measured 13.2%
   of the whole grid area at 1440px, in 14 of 44 rows. The void read as
   a broken camera roll. Now the mismatch is a MAT inside a framed
   plate: rows align, hairlines line up, and the picture sits centred
   on the archival ground. Curation does the rest — portrait frames are
   ordered first inside each album in tools/gallery-copy.json, which
   drops the residual mat area to 5.5%. */
.gal-cell {
  min-width: 0;
  display: grid;                 /* the cell stretches; the tile fills it */
}

.gal-cell[hidden] { display: none; }

/* SCOPED TO .gal-cell ON PURPOSE — DO NOT UNSCOPE.
   js/gallery.js puts the SAME class name on the document body while the
   viewer is open (body.gal-open, below). Unscoped, every rule here also
   matched <body>: measured 2026-08-18, opening the viewer repainted the
   page ground --panel, drew a 1px hairline and a 4px radius around the
   whole document, set cursor:pointer, and — because :hover on <body> is
   true whenever the pointer is over the page — applied translateY(-2px)
   to the body, which made the body a containing block for fixed
   descendants and blew the fixed lightbox out to 12,045px tall at
   y=-5522 with the header behind it. `.gal-open img` also matched the
   viewer's own <img>, forcing every photograph into the 3/2 fallback. */
.gal-cell .gal-open {
  display: block;
  position: relative;
  width: 100%;
  height: 100%;
  padding: 0;
  background: var(--flyer-ground);  /* archival frame ground — darker than --panel */
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  overflow: hidden;
  cursor: pointer;
  /* it is an <a> in the no-JS floor; kill the underline either way */
  text-decoration: none;
  transition: border-color var(--dur-1) var(--ease), transform var(--dur-2) var(--ease);
}

.gal-cell .gal-open:hover { border-color: var(--rizz-line); }

.gal-cell .gal-open:focus-visible {
  outline: 2px solid var(--rizz-hot);
  outline-offset: 2px;
}

/* NOTHING HERE IS COVER-CROPPED. Each thumbnail carries its own
   --gal-ar (the asset's real width/height, written by the generator).
   It sizes the row; object-fit:contain then shows the frame WHOLE
   inside it, so no face is ever cut and no focal point is needed. If
   this ever becomes object-fit:cover, every face-bearing frame must
   first be measured into the crop law in tools/validate-data.py. */
.gal-cell .gal-open picture,
.gal-cell .gal-open img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
}

/* ASPECT RATIO BY CLASS, NOT BY INLINE STYLE.
   Production CSP is `style-src 'self'` with no 'unsafe-inline', so a
   style="" attribute on a tile is discarded by the browser and the grid
   silently loses its row sizing — 204 CSP violations per page load with
   the layout defeated. The generator emits one of these classes instead
   and hard-errors on a ratio it has no class for, so a new frame shape
   can never quietly fall back to a wrong box. */
.gal-cell .gal-open.gal-ar-ls   { aspect-ratio: 1600 / 1063; }
.gal-cell .gal-open.gal-ar-pt   { aspect-ratio: 1063 / 1600; }
.gal-cell .gal-open.gal-ar-wide { aspect-ratio: 16 / 9; }
/* Vertical clips. Seven of these shipped on their side until 2026-08-18:
   shot vertically, baked into a landscape frame, no rotation flag to warn
   anyone. They are 9:16 now and need their own box. */
.gal-cell .gal-open.gal-ar-tall { aspect-ratio: 9 / 16; }
.gal-cell .gal-open.gal-ar-ls2  { aspect-ratio: 1600 / 1067; }

.gal-cell .gal-open.gal-ar-ls img,
.gal-cell .gal-open.gal-ar-pt img,
.gal-cell .gal-open.gal-ar-wide img,
.gal-cell .gal-open.gal-ar-tall img,
.gal-cell .gal-open.gal-ar-ls2 img { aspect-ratio: inherit; }

@media (hover: hover) {
  .gal-cell .gal-open:hover { transform: translateY(-2px); }
}

@media (prefers-reduced-motion: reduce) {
  .gal-cell .gal-open,
  .gal-cell .gal-open:hover { transform: none; transition: none; }
}

/* Video cells wear a play badge so a still and a clip are never
   confused before you tap. */
.gal-badge {
  position: absolute;
  left: var(--sp-2);
  bottom: var(--sp-2);
  display: inline-flex;
  align-items: center;
  gap: 0.35em;
  padding: 0.25em 0.6em;
  background: color-mix(in srgb, var(--bg) 82%, transparent);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-pill);
  color: var(--ivory);
  font-family: var(--font-label);
  font-size: var(--fs-micro);
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  pointer-events: none;
}

/* CINDY 2026-08-18: the badge is a footnote on a photograph, and at the
   360px floor it was measuring 97x30 on a 154x88 clip tile — 63% of the
   frame's width and 34% of its height. Same failure the contact-sheet
   chip on media.html was cut down for: a label eating the picture it
   annotates. Below the 3-column switch it keeps every word and loses the
   weight — 11px, tight padding, tucked into the corner. Measured after:
   ~44% of tile width, ~19% of tile height. */
@media (max-width: 620px) {
  .gal-badge {
    left: var(--sp-1);
    bottom: var(--sp-1);
    gap: 0.3em;
    padding: 0.2em 0.45em;
    font-size: 0.6875rem;
    letter-spacing: 0.08em;
  }
}

/* ============================================================
   THE VIEWER
   ============================================================ */
.gal-lb {
  position: fixed;
  inset: 0;
  z-index: calc(var(--z-header) + 100); /* 900 — over the header, under --z-skip */
  display: grid;
  place-items: center;
  opacity: 0;
  transition: opacity var(--dur-2) var(--ease);
}

/* ⚠️ LOAD-BEARING. The viewer ships with the `hidden` attribute, but an
   author `display` beats the UA sheet's `[hidden] { display: none }` — so
   `display: grid` above kept a fixed, inset:0, z-index-900 sheet over the
   whole page at all times. It was invisible (opacity 0) and it still ate
   every pointer event: elementFromPoint at any coordinate on this page
   returned .gal-lb-backdrop, including over the header nav, the Book RIZZ
   button and all 216 tiles. Keyboard still worked (Enter fires on the <a>
   directly), which is exactly why a keyboard walk and a source read both
   passed it. Never declare `display` on .gal-lb without this line.
   .gal-cell[hidden] above already had the equivalent guard. */
.gal-lb[hidden] { display: none; }

.gal-lb.is-open { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  .gal-lb { transition: none; }
}

.gal-lb-backdrop {
  position: absolute;
  inset: 0;
  background: color-mix(in srgb, var(--bg) 94%, transparent);
  backdrop-filter: blur(6px);
}

body.gal-open { overflow: hidden; }

/* 100vh is the LARGE viewport on mobile Safari — with the URL bar showing
   it is taller than the screen, so the viewer's bottom chrome (the arrow
   row, the counter) sits below the fold inside an overflow:hidden body,
   i.e. unreachable. 100dvh tracks the visible viewport. The 100vh line
   stays first as the fallback for engines without dvh. */
.gal-lb-inner {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
  width: min(100vw - 1.5rem, 1400px);
  max-height: calc(100vh - 1.5rem);
  max-height: calc(100dvh - 1.5rem);
  padding: var(--sp-3);
}

.gal-lb-stage {
  display: grid;
  place-items: center;
  min-height: 0;                 /* lets the media shrink inside the flex column */
  flex: 1 1 auto;
  /* the swipe surface: the browser must not claim horizontal panning */
  touch-action: pan-y;
}

.gal-lb-stage img,
.gal-lb-stage video {
  display: block;
  max-width: 100%;
  max-height: calc(100vh - 11rem);
  max-height: calc(100dvh - 11rem);
  width: auto;
  height: auto;
  border-radius: var(--r-sm);
  background: var(--panel);
}

.gal-lb-stage [hidden] { display: none; }

/* ---------------- chrome ---------------- */

.gal-lb-bar {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--sp-4);
  flex-wrap: wrap;
}

.gal-lb-caption {
  margin: 0;
  color: var(--ivory-80);
  font-size: var(--fs-small);
  line-height: var(--lh-body);
  max-width: 68ch;
}

/* HOUSE FLOOR: tertiary type sitting over photography is --ivory-60,
   never --smoke. The viewer's ground is a 94% scrim, so what is really
   behind these numerals is the picture. Measured across the scrim over
   a white / mid-grey / black photo pixel: --smoke 4.54 / 4.86 / 5.09:1,
   --ivory-60 6.21 / 6.39 / 6.47:1. Smoke is also a cool grey against a
   warm ivory library and reads as disabled next to the caption. */
.gal-lb-counter {
  margin: 0;
  color: var(--ivory-60);
  font-family: var(--font-logo);
  font-size: var(--fs-small);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.gal-nav,
.gal-close {
  position: absolute;
  display: grid;
  place-items: center;
  width: 48px;
  height: 48px;                  /* >=44px touch floor */
  background: color-mix(in srgb, var(--panel) 88%, transparent);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-pill);
  color: var(--ivory);
  font-size: 1.25rem;
  line-height: 1;
  cursor: pointer;
  transition: background var(--dur-1) var(--ease), border-color var(--dur-1) var(--ease);
}

.gal-nav:hover,
.gal-close:hover { background: var(--panel-2); border-color: var(--rizz-line); }

.gal-nav:focus-visible,
.gal-close:focus-visible { outline: 2px solid var(--rizz-hot); outline-offset: 2px; }

.gal-prev { left: var(--sp-3); top: 50%; transform: translateY(-50%); }
.gal-next { right: var(--sp-3); top: 50%; transform: translateY(-50%); }
.gal-close { right: var(--sp-3); top: var(--sp-3); }

/* On a phone the side arrows would sit on top of the picture, so they
   drop to a thumb-reachable row under it. Swipe is the primary gesture
   there anyway; these stay for anyone who does not swipe.

   The arrows are absolutely positioned against .gal-lb-inner, so the
   flow content has to be told to keep out of their strip. It was not:
   padding-bottom was 2rem while the arrows occupy the bottom 12–60px,
   and the counter landed underneath the back arrow — measured at 390px,
   "1 / 216" sat at x24–75/y565–590 and .gal-prev at x28–76/y562–610, a
   48x28 overlap directly over the numerals. Reserve the strip:
   gap + button + gap. */
@media (max-width: 620px) {
  .gal-lb-inner { padding-bottom: calc(var(--sp-3) + 48px + var(--sp-4)); }
  .gal-prev { left: var(--sp-4); top: auto; bottom: var(--sp-3); transform: none; }
  .gal-next { right: var(--sp-4); top: auto; bottom: var(--sp-3); transform: none; }
  .gal-lb-stage img,
  .gal-lb-stage video {
    max-height: calc(100vh - 17rem);
    max-height: calc(100dvh - 17rem);
  }

  /* GUARD, not a live fix. When this sweep started some captions in
     tools/gallery-copy.json ran 350 characters and the caption block
     measured 198px against the 192px clip it described — the label
     outweighing the picture, on the page where content is king. The copy
     file has since been cut back (longest caption now 41 chars, so this
     rule is inert today) but the viewer must not be one long caption away
     from that layout again. ~5 lines, then it scrolls: nothing is lost,
     and the scroller sits in .gal-lb-bar, never on the swipe surface. */
  .gal-lb-caption {
    max-height: 8.4em;
    overflow-y: auto;
    overscroll-behavior: contain;
  }
}

/* CINDY 2026-08-18: this is a count AND a full instruction sentence, and
   it was set in letterspaced uppercase label face — a 3-line caps wall at
   390px sitting directly under the lead, with nothing separating the two.
   Same call as .md-lg-label on media.html: a sentence is prose, so it
   gets the prose face. The counts stay quiet in --smoke (5.09:1 on --bg),
   the sentence becomes readable, and the block drops to two lines. */
.gal-hint {
  margin: var(--sp-3) 0 0;
  color: var(--smoke);
  font-family: var(--font-body);
  font-size: var(--fs-small);
  line-height: var(--lh-body);
  max-width: 62ch;
  text-wrap: pretty;
}

.gal-filter-n {
  margin-left: 0.4em;
  color: var(--smoke);
  font-variant-numeric: tabular-nums;
}

/* The count has to recede from the label without falling out of AA, and on
   cobalt the budget is thin. Measured on --rizz #2B4BE0 (relative-luminance
   formula, sRGB linearised, run 2026-08-18):
     rgba(255,255,255,.72)  4.20:1  ← what was here. FAILS AA at 15px.
     var(--ivory-80) (.82)  4.37:1  ← still fails. Do not "simplify" to this.
     ivory 90% / rizz 10%   4.94:1  ✓ quieter than the label, passes AA.
     var(--ivory)           5.73:1  ← no longer recedes.
   Mixing toward the pill's own ground keeps this on the token layer. */
.gal-filter[aria-pressed="true"] .gal-filter-n {
  color: color-mix(in srgb, var(--ivory) 90%, var(--rizz));
}

.gal-sect { padding-top: calc(var(--header-h) + var(--sp-7)); }

/* site.css sets scroll-padding-top to clear the fixed header alone
   (--header-h + 1rem = 92px measured). This page adds a SECOND fixed
   band under it — the sticky album bar, 68px: a 44px pill plus
   --sp-3 above and below. Left at 92px, anything the browser scrolls
   to on this page — a keyboard-focused tile, a fragment link, a
   scrollIntoView — lands in the 28px strip the bar covers, with its
   focus ring cut off. Scoped to this sheet, which only loads here. */
:root {
  scroll-padding-top: calc(var(--header-h) + 44px + (var(--sp-3) * 2) + 1rem);
}


/* ---------- Hero film (added 2026-08-18) ----------
   The gallery opened on flat black. It now leads with the work itself —
   the Times Square walk — so the page shows what it holds before it
   describes it. */
.gal-sect--film { position: relative; overflow: hidden; }

.gal-film { position: absolute; inset: 0; height: clamp(320px, 46vh, 520px); z-index: var(--z-bg); }

/* hero-1-street measured 0 faces — the subject walks away, so centre is safe. */
.gal-film video,
.gal-film .vslot-poster { object-fit: cover; object-position: 50% 50%; }

.gal-grade {
  position: absolute; inset: 0; height: clamp(320px, 46vh, 520px);
  z-index: var(--z-base); pointer-events: none;
  background:
    linear-gradient(to top,
      var(--bg) 0%,
      rgba(10, 10, 10, 0.86) 30%,
      rgba(10, 10, 10, 0.52) 62%,
      rgba(10, 10, 10, 0.34) 100%);
}

.gal-sect--film > .container { position: relative; z-index: var(--z-content); }
.gal-sect--film > .container > .sect-head { padding-top: clamp(2rem, 8vh, 5rem); }
