/* ============================================================
   Ofir Attar — Portfolio
   Gray-black + per-project accent, justified editorial grid,
   inspired by rotemcohensoaye.com structure, own brand identity.
   ============================================================ */

@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700;800&family=Geist+Mono:wght@400;500;600&display=swap');

:root {
  --bg: #1b1b1a;
  --bg-elevated: #242422;
  --fg: #f2f0ea;
  --fg-dim: #a8a6a0;
  --fg-faint: #6f6d68;
  --accent: #ff3b1f;
  --border: #34342f;
  --border-strong: #48463f;

  --font-display: 'Geist', 'Helvetica Neue', Arial, sans-serif;
  --font-body: 'Geist Mono', 'SF Mono', Menlo, monospace;

  --space-1: 8px;
  --space-2: 16px;
  --space-3: 24px;
  --space-4: 32px;
  --space-5: 48px;
  --space-6: 64px;
  --space-7: 96px;
  --space-8: 128px;

  --ease: cubic-bezier(0.2, 0.7, 0.2, 1);
  --dur-fast: 150ms;
  --dur-med: 300ms;

  --container-max: 1720px;
}

*, *::before, *::after { box-sizing: border-box; }
html {
  -webkit-text-size-adjust: 100%;
  overflow-x: hidden;
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--fg);
  font-family: var(--font-body);
  font-size: 15px;
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
}

img, video { max-width: 100%; display: block; }

a { color: inherit; text-decoration: none; }

h1, h2, h3, h4 {
  font-family: var(--font-display);
  font-weight: 500;
  line-height: 1.05;
  margin: 0;
  letter-spacing: -0.01em;
}

p { margin: 0; }
ul { list-style: none; margin: 0; padding: 0; }

button {
  font-family: inherit;
  background: none;
  border: none;
  color: inherit;
  cursor: pointer;
}

:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

.container {
  max-width: var(--container-max);
  margin: 0 auto;
  padding: 0 var(--space-3);
}

@media (min-width: 768px) {
  .container { padding: 0 var(--space-5); }
}

.visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}

.skip-link {
  position: absolute;
  top: -100px;
  left: var(--space-2);
  background: var(--accent);
  color: #0a0a0a;
  padding: var(--space-1) var(--space-2);
  z-index: 300;
  font-weight: 600;
  transition: top var(--dur-fast) var(--ease);
}
.skip-link:focus { top: var(--space-2); }

/* ---------------- Header: logo + hamburger only ---------------- */
.site-header {
  position: sticky;
  top: 0;
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: var(--space-3);
  background: rgba(27, 27, 26, 0.86);
  backdrop-filter: blur(10px);
  border-bottom: 1px solid var(--border);
}

@media (min-width: 768px) {
  .site-header { padding: var(--space-3) var(--space-5); }
}

/* ---------------- Micro-glitch ----------------
   A short digital ERROR, not a shake — each of these five variants
   is a distinct one-off disturbance (a clipped slice jumping, a
   chromatic ghost, a tracking pulse, a vertical band offset, a scan
   mask) rather than the same motion repeated. main.js picks one at
   random per hover/focus entry, randomizes its duration (80-220ms,
   via --glitch-dur) and its displacement (2-5px, via --gd), then
   removes the class on animationend so the next trigger starts
   clean. steps() rather than an eased curve keeps every variant
   feeling quantized/digital instead of a smooth wobble. None of them
   oscillate more than once — they land off-center for a beat and
   snap directly back, which is what keeps this reading as "glitched"
   rather than "shaking". The site-wide prefers-reduced-motion rule
   near the bottom of this file collapses all animation-durations to
   ~0, disabling this outright for anyone who's asked for less
   motion. */
@keyframes glitch-a {
  0%, 100% { clip-path: inset(0 0 0 0); transform: translateX(0); }
  20% { clip-path: inset(0 28% 0 0); transform: translateX(calc(var(--gd, 3px) * -1)); }
  45% { clip-path: inset(0 0 0 38%); transform: translateX(var(--gd, 3px)); }
  70% { clip-path: inset(0 0 0 0); transform: translateX(calc(var(--gd, 3px) * -0.3)); }
}

@keyframes glitch-b {
  0%, 100% { text-shadow: none; transform: translateX(0); }
  30% { text-shadow: calc(var(--gd, 3px) * -1) 0 0 var(--glitch-accent, currentColor), 1px 0 0 rgba(255, 255, 255, 0.35); transform: translateX(1px); }
  65% { text-shadow: var(--gd, 3px) 0 0 var(--glitch-accent, currentColor); transform: translateX(-1px); }
}

@keyframes glitch-c {
  0%, 100% { letter-spacing: normal; transform: translateY(0); }
  35% { letter-spacing: 0.14em; transform: translateY(-1px); }
  70% { letter-spacing: -0.02em; transform: translateY(1px); }
}

@keyframes glitch-d {
  0%, 100% { clip-path: inset(0 0 0 0); transform: translateY(0); }
  25% { clip-path: inset(0 0 52% 0); transform: translateY(calc(var(--gd, 3px) * -1)); }
  55% { clip-path: inset(48% 0 0 0); transform: translateY(var(--gd, 3px)); }
  80% { clip-path: inset(0 0 0 0); transform: translateY(0); }
}

@keyframes glitch-e {
  0%, 100% { clip-path: inset(0 0 0 0); }
  25% { clip-path: inset(22% 0 22% 0); }
  55% { clip-path: inset(0 0 58% 0); }
  80% { clip-path: inset(65% 0 0 0); }
}

.glitch-a { animation: glitch-a var(--glitch-dur, 150ms) steps(3, jump-end); }
.glitch-b { animation: glitch-b var(--glitch-dur, 150ms) steps(3, jump-end); }
.glitch-c { animation: glitch-c var(--glitch-dur, 150ms) steps(3, jump-end); }
.glitch-d { animation: glitch-d var(--glitch-dur, 150ms) steps(3, jump-end); }
.glitch-e { animation: glitch-e var(--glitch-dur, 150ms) steps(3, jump-end); }

/* Idle attention cues (see main.js) — a different character from the
   glitch system above on purpose: one quiet, irregular nudge rather
   than a digital disturbance, fired rarely on a randomized timer
   while the element sits idle and un-interacted-with, never looping.
   The site-wide prefers-reduced-motion rule above already collapses
   these durations to ~0; main.js additionally never starts the idle
   timers at all under that setting, so no background work runs for
   an effect that couldn't be seen anyway. */
@keyframes idle-menu-nudge {
  0%, 100% { transform: translateX(0); }
  35% { transform: translateX(3px); }
  65% { transform: translateX(-1px); }
}

.nav-toggle.idle-cue {
  animation: idle-menu-nudge 180ms var(--ease);
}

@keyframes idle-arrow-nudge {
  0%, 100% { transform: translateX(0); }
  45% { transform: translateX(4px); }
}

.nav-arrow.idle-cue {
  animation: idle-arrow-nudge 200ms var(--ease);
}

/* Mobile equivalent of the arrow nudge above, for when .nav-arrow is
   display:none (see the mobile .project-nav rules) and so has nothing
   to animate — a very subtle opacity/position dip on the NEXT PROJECT
   text itself instead, same rare/short/one-shot character as every
   other idle cue on the site, no new graphic introduced. */
@keyframes idle-next-nudge {
  0%, 100% { opacity: 1; transform: translateX(0); }
  45% { opacity: 0.72; transform: translateX(2px); }
}

.nav-next.idle-cue-text {
  animation: idle-next-nudge 220ms var(--ease);
}

.brand {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 18px;
  letter-spacing: -0.01em;
  text-transform: uppercase;
  min-height: 44px;
  display: flex;
  align-items: center;
}

.brand-text {
  color: var(--fg);
  transition: color var(--dur-med) var(--ease);
  font-variant-numeric: tabular-nums;
}

.brand:hover .brand-text,
.brand:focus-visible .brand-text {
  color: #cfcdc6;
}

/* On the homepage --logo-accent is only set on tile hover, falling
   back to --fg by default. On a project page there's no hover to
   drive it, so it falls back one step further to that page's own
   --accent instead — the dot always carries the color of whatever
   project you're actually looking at. */
.brand-dot {
  color: var(--logo-accent, var(--accent, var(--fg)));
  transition: color var(--dur-med) var(--ease);
}

/* Typographic menu control — [MENU] / [CLOSE], no icon. Auto-width
   rather than the old fixed 44px square so the bracket text has
   room, with padding keeping the tap target close to 44px tall. */
.nav-toggle {
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: 44px;
  min-height: 44px;
  padding: 0 2px;
  position: relative;
  z-index: 210;
  background: none;
  border: none;
  cursor: pointer;
  font-family: var(--font-body);
  font-size: 12px;
  font-weight: 500;
  letter-spacing: 0.05em;
  color: var(--fg-dim);
  transition: color var(--dur-fast) var(--ease);
}

.nav-toggle:hover,
.nav-toggle:focus-visible {
  color: var(--fg);
}

.nav-toggle .label-close { display: none; }
.nav-toggle[aria-expanded="true"] .label-menu { display: none; }
.nav-toggle[aria-expanded="true"] .label-close { display: block; }

/* ---------------- Right-side nav drawer ---------------- */
.nav-scrim {
  position: fixed;
  inset: 0;
  z-index: 190;
  background: rgba(10, 10, 9, 0.4);
  opacity: 0;
  visibility: hidden;
  transition: opacity var(--dur-med) var(--ease), visibility 0s var(--dur-med);
}

.nav-scrim.is-open {
  opacity: 1;
  visibility: visible;
  transition: opacity var(--dur-med) var(--ease), visibility 0s;
}

/* Flat and dark, matching the rest of the system — no blur, no
   translucency standing in for a surface. A 1px border does the job
   a frosted panel used to do (separating the drawer from the page
   behind it) without the glass effect. */
.site-nav {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  z-index: 200;
  width: min(360px, 100vw);
  background: var(--bg-elevated);
  border-left: 1px solid var(--border);
  color: var(--fg);
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding: var(--space-6) var(--space-4);
  transform: translateX(100%);
  visibility: hidden;
  transition: transform var(--dur-med) var(--ease), visibility 0s var(--dur-med);
  overflow-y: auto;
  box-shadow: -24px 0 64px rgba(0,0,0,0.35);
}

@media (min-width: 768px) {
  /* min(400px, 25vw) had a ceiling (400px) *above* the old fixed
     360px, so between ~1440px and 1600px viewports 25vw exceeds
     360px and the panel actually got WIDER, not narrower — and at
     exactly 1440px (a common laptop width) 25vw == 360px, so the
     drawer looked completely unchanged there. A ceiling has to sit
     *below* the old 360px for the panel to read as narrower on
     every desktop width, with no coincidental or wider zone — 340px
     does that (it's reached at 1360px and below, holds flat above
     it), while 25vw still does the shrinking on narrower/tablet
     widths. Internal padding is untouched. Mobile keeps the
     original min(360px, 100vw) above. */
  .site-nav {
    width: min(340px, 25vw);
  }
}

.site-nav.is-open {
  transform: translateX(0);
  visibility: visible;
  transition: transform var(--dur-med) var(--ease), visibility 0s;
}

.site-nav-list {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
}

/* Same index grammar as the rest of the portfolio — [XX] is a small,
   always-on accent detail (like .idx-main .num on a project page),
   the destination name stays neutral until hovered, and only the
   current page earns the full accent treatment on its whole line.
   Compact and technical rather than a big fullscreen-menu list. */
.site-nav-list a {
  font-family: var(--font-body);
  font-size: 15px;
  font-weight: 500;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  padding: 9px 0;
  min-height: 44px;
  display: flex;
  align-items: center;
  gap: 0.6em;
  color: var(--fg-dim);
  transition: color 300ms var(--ease);
}

.site-nav-list a .num {
  color: var(--item-accent, var(--accent));
}

.site-nav-list a:hover,
.site-nav-list a:focus-visible {
  color: var(--fg);
}

.site-nav-list a[aria-current="page"] {
  color: var(--item-accent, var(--accent));
}

.site-nav-list .home-link {
  border-bottom: 1px solid var(--border);
  padding-bottom: var(--space-2);
  margin-bottom: var(--space-1);
}

/* Explicit close control, mobile only — on a narrow drawer the panel
   fills the full viewport width, so the existing [MENU]→[CLOSE] text
   swap sitting up in the header (see .nav-toggle above) reads as easy
   to miss once a full-bleed drawer is the only thing on screen. A
   plain typographic × in the same corner the trigger itself lives in
   — no circle, no button surface — gives an unambiguous close action
   without introducing new chrome language. Desktop's narrower
   side-panel drawer keeps the rest of the page visible alongside it,
   so the existing header toggle already reads clearly there — this
   stays hidden above the drawer's own 768px width breakpoint. */
.nav-close { display: none; }

@media (max-width: 767px) {
  .nav-close {
    display: flex;
    align-items: center;
    justify-content: center;
    position: absolute;
    top: var(--space-3);
    right: var(--space-3);
    min-width: 44px;
    min-height: 44px;
    font-family: var(--font-body);
    font-size: 22px;
    font-weight: 500;
    line-height: 1;
    color: var(--fg-dim);
    background: none;
    border: none;
    cursor: pointer;
    transition: color var(--dur-fast) var(--ease);
  }

  .nav-close:hover,
  .nav-close:focus-visible {
    color: var(--fg);
  }
}

/* The homepage has no single project, so --accent must not fall
   back to :root's default (which is one specific project's red) —
   every other page overrides --accent inline on <body> intentionally;
   this is the one page that shouldn't inherit any project's color at
   all. Affects the brand dot and any other var(--accent) reference
   here (e.g. the tile-label number falls back to var(--accent) when
   a tile has no --tile-accent), keeping the global chrome neutral. */
body.home { --accent: var(--fg-dim); }

/* Desktop only (1025px+) — the fit-to-one-screen, no-scroll treatment
   is a desktop-specific composition choice. Tablet (768–1024px) keeps
   its own three-row grid (see layoutThreeRows() in main.js) which is
   deliberately allowed to run taller than the viewport and scroll
   normally, so it must not inherit this height/overflow lock. */
@media (min-width: 1025px) {
  body.home { height: 100vh; overflow: hidden; }
}

/* ---------------- Justified project grid ---------------- */
.grid {
  display: flex;
  flex-wrap: wrap;
  gap: 14px;
  padding: var(--space-4);
  max-width: 1760px;
  margin: 0 auto;
}

@media (min-width: 768px) {
  .grid { gap: 18px; padding: var(--space-5); }
}

/* Default state: a pure wall of artwork, no persistent chrome — the
   media fills the entire tile again (identity is silent until you
   hover). Hovering the grid pushes every tile to a low resting
   opacity except the one actually under the pointer, which stays
   full-strength, scales up slightly and reveals its [XX] NAME ↗
   label — a focus-mode read, not a caption. Sizing is driven
   entirely by JS (see layoutTwoRows/layoutFlow in main.js), which
   sets width/height directly on .tile since .tile-media fills it
   completely again (no label row to reserve space for). */
.tile {
  position: relative;
  overflow: hidden;
  background: var(--bg-elevated);
  flex-shrink: 0;
  opacity: 1;
  transition: opacity 350ms var(--ease);
}

/* :has() rather than .grid:hover — the grid's own box includes the
   gaps between tiles (flex gap isn't covered by any .tile), so
   .grid:hover alone would dim everything just from crossing that
   dead space. :has(.tile:hover) only ever matches while the pointer
   is truly over a tile's own hit area. */
.grid:has(.tile:hover, .tile:focus-within) .tile {
  opacity: 0.35;
}

.grid:has(.tile:hover, .tile:focus-within) .tile:hover,
.grid:has(.tile:hover, .tile:focus-within) .tile:focus-within {
  opacity: 1;
}

.tile-media {
  position: absolute;
  inset: 0;
  overflow: hidden;
}

.tile-media img,
.tile-media video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: transform 600ms var(--ease);
}

.tile-media video { z-index: 1; }

.tile:hover .tile-media img,
.tile:focus-within .tile-media img,
.tile:hover .tile-media video,
.tile:focus-within .tile-media video {
  transform: scale(1.03);
}

.tile-link {
  position: absolute;
  inset: 0;
  z-index: 3;
}

/* Irnian Tama: crop out the source video's baked-in pillarboxing.
   The bars measure ~7% of the frame per side, so 1.2x comfortably
   clears them with a small margin; the hover scale above multiplies
   on top of this (transforms compose), giving ~1.24x on hover. */
.tile[data-crop] .tile-media video {
  transform: scale(1.2);
}

.tile[data-crop]:hover .tile-media video,
.tile[data-crop]:focus-within .tile-media video {
  transform: scale(1.236);
}

/* Revealed only on hover, directly on the media — [XX] carries the
   project's own accent, the arrow is a static "go to project" cue.
   No background — a text-shadow does the contrast work a scrim used
   to, without reading as a badge/card sitting on top of the art. Two
   stacked shadows: a tight near-black one for edge definition plus a
   softer wider one for a bit of glow, so it holds up over both flat
   and busy imagery without ever looking like a container. Plain
   opacity/transform reveal, no glitch — hover stays clean here (see
   the micro-glitch block near .brand for where the glitch actually
   lives now: a one-time scroll-reveal on project index typography). */
.tile-label {
  position: absolute;
  left: var(--space-2);
  bottom: var(--space-2);
  z-index: 4;
  display: flex;
  align-items: baseline;
  gap: 0.5em;
  font-family: var(--font-body);
  font-size: 12px;
  font-weight: 500;
  letter-spacing: 0.03em;
  text-transform: uppercase;
  color: var(--fg);
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9), 0 2px 10px rgba(0, 0, 0, 0.6);
  opacity: 0;
  transform: translateY(6px);
  transition: opacity 250ms var(--ease), transform 250ms var(--ease);
  pointer-events: none;
}

.tile:hover .tile-label,
.tile:focus-within .tile-label {
  opacity: 1;
  transform: translateY(0);
}

.tile-label .name {
  display: inline-block;
}

.tile-label .num {
  color: var(--tile-accent, var(--accent));
}

.tile-label .arrow-glyph {
  color: var(--fg-dim);
}

/* ---------------- Footer ---------------- */
/* Deliberately slim — this is a typography line, not a section; the
   grid's fit-to-viewport math reads its real height, so keeping this
   compact directly hands more room to the images. */
.site-footer {
  border-top: 1px solid var(--border);
  padding: 12px var(--space-3);
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
  opacity: 1;
  transition: opacity 350ms var(--ease);
}

/* A slight recede for the rest of the chrome while a project has
   focus — matches the same :has(.tile:hover) condition the tiles
   themselves use, not just .grid:hover (see above). */
.grid:has(.tile:hover, .tile:focus-within) ~ .site-footer {
  opacity: 0.6;
}

@media (min-width: 768px) {
  .site-footer { padding: 12px var(--space-4); }
}

/* Restrained hover: no color/underline — a neutral brighten plus a
   small arrow that slides in, not a project-accent reaction (the
   footer is global chrome, not tied to any one project). */
.site-footer a {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  color: var(--fg-dim);
  font-family: var(--font-body);
  font-size: 12px;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  transition: color var(--dur-fast) var(--ease);
}

.site-footer a:hover,
.site-footer a:focus-visible {
  color: var(--fg);
}

.site-footer a .arrow {
  display: inline-block;
  opacity: 0;
  transform: translate(-3px, 3px);
  transition: opacity var(--dur-fast) var(--ease), transform var(--dur-fast) var(--ease);
}

.site-footer a:hover .arrow,
.site-footer a:focus-visible .arrow {
  opacity: 1;
  transform: translate(0, 0);
}

.site-footer .foot-meta {
  color: var(--fg-faint);
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.05em;
}

/* ============================================================
   Project index system (prototype) — shared by body.project-lab
   pages. An editorial/archival numbering language — [01] LABEL / XX
   — replacing the boxed/bracket-tag caption styles used elsewhere on
   the site. Quiet metadata, not a UI component: no border, no fill,
   no pill. The bracket+number stays in the project's own accent
   color via --project-accent (the one place accent is used at all in
   this system's captions); the label itself reads in dim neutral so
   the two halves sit as one small unit rather than competing parts.
   Every label sits ABOVE the group it describes, with no exception —
   one placement rule for every page that uses this system. The
   constant here is the typography/grid/spacing grammar; only
   --project-accent varies per project (each project page already
   sets --accent inline on <body>, so this just aliases it). */
.project-lab {
  --project-accent: var(--accent);
  /* Three spacing tokens, each with exactly one job. Same-group
     images always use --project-image-gap, horizontally AND
     vertically — one gutter, not a different value per axis or per
     section. --project-label-gap is deliberately smaller, so a label
     reads as attached to the artwork below it rather than floating
     independently. --project-section-gap is deliberately much larger
     — that contrast is what makes whitespace alone legible as the
     grouping system. */
  --project-image-gap: var(--space-3);
  --project-label-gap: var(--space-2);
  --project-section-gap: var(--space-7);
}

.project-lab .idx {
  font-family: var(--font-body);
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.03em;
  line-height: 1.5;
  text-transform: uppercase;
  color: var(--fg-dim);
}

.project-lab .idx-main .num {
  color: var(--project-accent);
  margin-right: 0.5em;
}

/* The quiet second layer — a plate/asset reference, not a caption.
   Real information (this section's position in the project's own
   asset sequence), not invented spec. Deliberately smaller and
   dimmer than the primary line so it reads as something the viewer
   notices on a second pass, not on the first. */
.project-lab .idx-sub {
  display: block;
  margin-top: 3px;
  font-size: 9px;
  letter-spacing: 0.05em;
  opacity: 0.5;
}

.project-lab .idx.idx-row {
  display: block;
}

.project-lab .proj-idx {
  margin-top: 0;
}

/* The hero's own [XX] TYPE / YEAR line reuses .num but isn't inside
   .idx-main (that spacing rule is scoped to gallery section labels
   only), so the number and the type/year text were running together
   with no gap at all. Same modest 0.5em used everywhere else .num
   precedes text — no color change, this is spacing-only. */
.project-lab .proj-idx .num {
  margin-right: 0.5em;
}

/* Poster + GIF: two individually-indexed assets sharing one row.
   Their labels sit in their own row directly above. Unlike every
   other row on the page, this pair isn't sized by flex-grow filling
   a container width — it's sized by JS (see "viewport-row" in
   main.js) to be as large as possible while still fitting within
   roughly one viewport's height, so both labels and both images get
   an explicit pixel width per column instead of a --r flex-grow
   share. That keeps the label's column start pinned to its image's,
   whatever width the height cap ends up producing. */
.nwl-lab .idx-pair {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

@media (min-width: 700px) {
  .nwl-lab .idx-pair {
    /* Must exactly equal the image row's own gap below — the JS that
       sizes Poster/GIF mirrors each image's computed width onto its
       label, so the two rows only stay column-aligned if they share
       the identical gutter value. */
    flex-direction: row;
    gap: var(--project-image-gap);
  }

  .nwl-lab .poster-gif-block .idx-pair > .idx,
  .nwl-lab .poster-gif-block .thumb-row figure {
    flex: 0 0 auto;
  }
}

/* Every image gutter within one section — horizontal (figure-to-
   figure in a row) and vertical (row-to-row within the same group,
   including the Book mockups→cover-spread transition) — is this one
   value. Equal on both axes is the point: it's what makes the
   negative space between images read as one calm, intentional grid
   instead of an arbitrary layout. */
.project-lab .gallery-v2 .thumb-row {
  gap: var(--project-image-gap);
}

.nwl-lab .thumb-row.book-spread {
  margin-top: var(--project-image-gap);
}

/* Brochure photography: two-column mobile grid (marked .mobile-2col
   in the markup, shared utility above). Where a row's image count is
   odd, its trailing image spans both columns instead of leaving an
   awkward empty cell — :last-child combined with :nth-child(odd) only
   ever matches when the total count in that row is odd. */
@media (max-width: 699px) {
  .nwl-lab .thumb-row.mobile-2col > figure:last-child:nth-child(odd) {
    grid-column: 1 / -1;
  }
}

/* Editorial rhythm: whitespace does the grouping instead of a label
   repeated per row. An index label always opens a new body of work,
   so it always gets the large section break above it. The row
   directly under a label gets the (smaller) label→art gap — close
   enough that the label reads as attached to what's under it, not
   floating independently — while any row that follows *another* row
   in the same group gets the same image gutter used everywhere else
   in that group. */
.project-lab .gallery-v2 {
  gap: 0;
}

.nwl-lab .gallery-v2 > .poster-gif-block {
  margin-top: var(--project-label-gap);
}

.project-lab .gallery-v2 > .thumb-row {
  margin-top: var(--project-label-gap);
}

.project-lab .gallery-v2 > .thumb-row + .thumb-row {
  margin-top: var(--project-image-gap);
}

.project-lab .gallery-v2 > .idx.idx-row {
  margin-top: var(--project-section-gap);
}

.nwl-lab .poster-gif-block > .thumb-row {
  margin-top: var(--project-label-gap);
}

/* At mobile, GIF is a distinct section from Poster, not a continuation
   of it (each carries its own [0N] heading) — the gap between them
   should read as a real section break, not the plain image-gap the
   row's own flex `gap` gives every adjacent pair. Targeted at the
   labeled figure specifically, excluding :first-child (Poster, whose
   own gap above comes from the block-level rule above) so this
   doesn't double up there. GIF's own label-to-media spacing is
   untouched. Same pattern as the equivalent Telem/Halva fix. */
@media (max-width: 699px) {
  .nwl-lab .poster-gif-block .thumb-row > figure:has(.mobile-media-label):not(:first-child) {
    margin-top: calc(var(--project-section-gap) - var(--project-image-gap));
  }
}

.project-lab .gallery-v2 > *:first-child {
  margin-top: 0;
}

/* Irnian Tama — original content layout, new index typography on
   top. Scoped to body.iat-lab only. .iat-section groups the assets
   back into the original page's three physical compositions (video
   triplet / image row / video pair) — the 96px section-gap runs
   between these groups, not between every individual asset, because
   several of the "website sections" the typography now numbers
   individually still share one physical row, exactly as the
   original page composed them. */
.iat-lab .gallery-v2 > .iat-section {
  margin-top: var(--project-section-gap);
}

.iat-lab .gallery-v2 > .iat-section:first-child {
  margin-top: 0;
}

.iat-lab .iat-section > .idx.idx-row,
.iat-lab .iat-section > .idx-pair {
  margin-top: 0;
}

.iat-lab .iat-section > .thumb-row {
  margin-top: var(--project-label-gap);
}

/* At mobile, .idx-pair headings are hidden (each figure carries its
   own injected .mobile-media-label instead — see the :has() rule
   below), so the label-gap margin-top above is spacing a thumb-row
   away from a heading that no longer occupies any layout space.
   That left the section-gap (already applied to the .iat-section
   itself) plus this label-gap stacking into a 64px gap instead of
   the intended 48px, at both the top of the gallery (before the
   video triplet) and before the Previous Tenants/Tickets pair.
   Sections whose heading is .idx.idx-row ("What's in the Plan")
   stay visible at mobile, so their label-gap is still needed and is
   untouched. */
@media (max-width: 699px) {
  .iat-lab .iat-section > .idx-pair + .thumb-row {
    margin-top: 0;
  }
}

/* A label row mirroring the image row's own columns, for the two
   groups where several individually-indexed website sections share
   one physical row (the opening video triplet, the closing video
   pair). The row below splits its width by each figure's own --r
   (proportional flex-grow, same as every other thumb-row on the
   site) rather than a fixed percentage — so each .idx here carries
   the *same* --r as its matching figure and uses the identical
   flex formula, which keeps the two rows column-aligned by CSS
   alone, however the widths end up dividing. */
.iat-lab .idx-pair {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

@media (min-width: 700px) {
  .iat-lab .idx-pair {
    flex-direction: row;
    gap: var(--project-image-gap);
  }

  .iat-lab .idx-pair > .idx {
    flex: var(--r, 1) var(--r, 1) 0;
  }
}

/* The video triplet and the Previous Tenants/Tickets pair each carry
   a genuine per-item section label (own [XX] number, own name) rather
   than a lightweight shared descriptor — closer in kind to Murder
   Pattern's "[01] Back Cover" than to its "Open"/"Folded" states. At
   the standard --project-image-gap (24px) between stacked figures,
   a label injected above e.g. video 2 sits closer to video 2 below it
   than to video 1 above (16px label-gap vs 24px image-gap), which is
   *structurally* correct but visually too subtle a difference to read
   as unambiguous — it can still look like it belongs with whichever
   video is nearest. Bumping the gap between these specific individually-
   labeled figures up to the section-gap scale removes the ambiguity
   outright. Scoped via :has() to rows that actually contain an
   injected label, so the six-image "What's in the Plan" grid (one
   shared label, not per-figure) is unaffected. */
@media (max-width: 699px) {
  .iat-lab .thumb-row:has(.mobile-media-label) {
    gap: var(--project-section-gap);
  }
}

/* "What's in the Plan" (7 images, one shared primary label — not an
   idx-pair, so it's unaffected by the label-attachment fix above): on
   mobile only, drop the most visually redundant frame from the visible
   set (marked .iat-hide-mobile in the markup; the asset itself, and
   desktop, are untouched). The remaining six now use the shared
   .mobile-photo-columns/.mobile-photo-column fixed-column technique
   (see that rule further down this file) instead of a CSS-grid
   dense-packing approach — the grid version's JS-computed
   grid-row-end:span values necessarily round up (Math.ceil) to
   guarantee each item has *enough* room, which left roughly 18px of
   slack in the very last row, and since that was the grid's own
   trailing edge, it read as an oversized gap before the next section.
   Plain fixed flex columns can't produce that kind of slack — a
   column's own height is always exactly its content's real height,
   nothing rounded or estimated. All six remaining images share the
   same aspect ratio, so a straightforward first-half/second-half
   split (3 + hidden-plus-3) already gives two naturally equal-height
   columns. */
@media (max-width: 699px) {
  .iat-lab .iat-hide-mobile {
    display: none;
  }
}

/* Murder Pattern — the shared spacing tokens, deliberately quiet.
   This is one publication, not several deliverables, so it carries
   only two index moments (Back Cover, Spread) rather than a label
   per group. The opening cover pair and the motion clip right after
   it share one unlabeled group, so — same as every other
   .project-lab page — that transition takes the plain internal
   image gutter (.project-lab .gallery-v2 > .thumb-row + .thumb-row,
   defined above) rather than a section break; only an actual .idx
   label (Back Cover, Spread) opens a new section. */

/* Cover: one shared primary index for the pair, but each image is
   its own state (open / folded), so the quiet secondary line splits
   into two — one per image, aligned to that image's own column. Same
   per-image-flex-basis technique as Iranian Tama's idx-pair, just
   holding bare .idx-sub lines instead of full .idx blocks since the
   primary index above already covers the shared "Cover / 2" info. */
.mp-lab .gallery-v2 > .idx-pair {
  margin-top: 4px;
}

.mp-lab .idx-pair {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

@media (min-width: 700px) {
  .mp-lab .idx-pair {
    flex-direction: row;
    gap: var(--project-image-gap);
  }

  .mp-lab .idx-pair > .idx-sub {
    flex: var(--r, 1) var(--r, 1) 0;
    margin-top: 0;
    color: var(--fg-dim);
  }
}

/* HaGevatron — mockup, then its own flat design directly under it,
   for each of the three artifact groups (Cover, Songbook, Record).
   Only the mockups carry index labels; a flat design reads as "the
   design behind the mockup above" without needing one of its own, so
   consecutive thumb-rows within a group stay on the default image
   gutter (see .project-lab .gallery-v2 > .thumb-row + .thumb-row
   above) and only .idx-pair openers get the large section break. The
   closing row's label column mirrors GIF+Record the same way Cover
   and Songbook do, except the GIF side of that pair is an empty .idx
   — the record mockup already lives in the Hero, so only the record
   flat design needs a label here, and an empty flex sibling keeps
   that label's column pinned to the record image's own column
   without drawing anything above the GIF. Songbook is one group (one
   publication with two sides, like Murder Pattern's cover), so it
   follows Murder Pattern's own pattern exactly: a single shared
   .idx.idx-row ("Songbook / 2") carries the section break and the
   left-edge alignment, and the .idx-pair right under it holds only
   the quiet per-side .idx-sub lines, sitting close (4px) rather than
   opening another section. */
.hg-lab .gallery-v2 > .idx-pair {
  margin-top: var(--project-section-gap);
}

.hg-lab .gallery-v2 > .idx-pair:first-child {
  margin-top: 0;
}

.hg-lab .gallery-v2 > .idx.idx-row + .idx-pair {
  margin-top: 4px;
}

.hg-lab .idx-pair {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

@media (min-width: 700px) {
  .hg-lab .idx-pair {
    flex-direction: row;
    gap: var(--project-image-gap);
  }

  .hg-lab .idx-pair > .idx,
  .hg-lab .idx-pair > .idx-sub {
    flex: var(--r, 1) var(--r, 1) 0;
  }

  .hg-lab .idx-pair > .idx-sub {
    margin-top: 0;
    color: var(--fg-dim);
  }
}

/* Mobile-only mockup+flat grouping fix. Desktop's Cover and Songbook
   groups are built as two side-by-side rows (mockups, then flats
   below) — correct there, but at mobile width each row just stacks
   independently, so a flat lands after the *next* mockup rather than
   its own, reading as if it belongs to the wrong item. Rather than
   restructure the DOM those desktop rows depend on (which would
   detach them from .gallery-v2's direct-child margin-top rules above
   — the > combinator matches actual DOM nesting regardless of any
   display:contents trick), each flat gets a second, mobile-only
   <img> reference — same asset, not a new file — placed directly
   after its own mockup in the existing mockup row; the original
   desktop row is hidden at mobile instead of removed, so nothing is
   ever visibly duplicated. */
.hg-lab .hg-mobile-flat {
  display: none;
}

@media (max-width: 699px) {
  .hg-lab .hg-mobile-flat {
    display: block;
  }

  /* .hg-lab .hg-desktop-only-row (2 classes) ties in specificity with
     the base `.gallery-v2 .thumb-row, ... {display:flex}` rule
     further down this file (also 2 classes) and, being earlier in
     source, was silently losing that tie — confirmed via computed
     style (display:flex, not none). .gallery-v2 as a third qualifier
     guarantees this always wins regardless of source order. */
  .hg-lab .gallery-v2 .hg-desktop-only-row {
    display: none;
  }

  /* A flat sitting right before the *next* item's label at only the
     standard image gutter reads exactly like the bug this fixes —
     too subtle a gap to read as "this flat is done, a new item is
     starting." Adds on top of the row's own flex gap (not a
     replacement for it) to reach the same visual weight as a real
     section break, without touching --project-section-gap itself or
     any other row's spacing. */
  .hg-lab .hg-mobile-section-gap {
    margin-top: calc(var(--project-section-gap) - var(--project-image-gap));
  }
}

/* The GIF/Record .idx-pair carries a real section-open margin (see
   the base rule above) but is display:none at mobile (all .idx-pair
   are — see the shared label-attachment rules elsewhere in this
   file), so that margin never produces visible space there; the
   thumb-row after it falls back to the small label-gap baseline
   instead, collapsing what should read as a clear break after the
   Songbook group. Restoring it on the visible following row (mobile
   only) fixes the result without touching the hidden element.
   Side A/Side B's own idx-pair sits directly under the Songbook
   heading and is *meant* to stay tight to it (4px on desktop) — the
   more specific override below keeps that thumb-row on the small
   label-gap instead of picking up this rule. */
@media (max-width: 699px) {
  .hg-lab .gallery-v2 > .idx-pair:not(:first-child) + .thumb-row {
    margin-top: var(--project-section-gap);
  }

  .hg-lab .gallery-v2 > .idx.idx-row + .idx-pair + .thumb-row {
    margin-top: var(--project-label-gap);
  }
}

/* Telem — a plain justified-row catalogue. Deliberately the least
   custom CSS of any .project-lab page: every row is a bare
   .thumb-row and every figure's own --r is its true photographed
   aspect ratio, so the existing flex:var(--r,1) var(--r,1) 0
   mechanism *is* the "common height, natural widths, shared gutter"
   rule the brief asks for — there's no separate row system to
   build. Two additions only: the Shake+Halva shared-row label
   (.idx-pair, same technique as HaGevatron's GIF+Record row, except
   its second column's --r is the SUM of the two Halva figures' own
   --r so the label column spans both images at once) and the
   closing flat-graphic row's section-gap (it isn't preceded by its
   own .idx label, so the generic adjacent-thumb-row rule would
   otherwise give it the small image-gap instead of a real section
   break). */
.tlm-lab .gallery-v2 > .idx.idx-row:first-child {
  margin-top: 0;
}

.tlm-lab .gallery-v2 > .idx-pair {
  margin-top: var(--project-section-gap);
}

/* This idx-pair carries the section-open gap above (it has no
   preceding .idx.idx-row of its own — Shake+Halva's shared label
   *is* the section opener) — but .idx-pair is display:none on mobile
   (see the label-attachment rules further down this file), so that
   margin never contributes any actual space there; a hidden element
   with a margin still renders zero visible gap. The row that follows
   it inherits only the generic small label-gap baseline instead,
   collapsing what should read as a clear break between the Raw
   Tahini group and Shake/Halva into the same tight spacing used
   between a label and its own media. Restoring the real section gap
   directly on that following row (mobile only) fixes the visible
   result without touching the hidden element itself. */
@media (max-width: 699px) {
  .tlm-lab .gallery-v2 > .idx-pair + .thumb-row {
    margin-top: var(--project-section-gap);
  }
}

/* Within that same row, Halva (its own labeled figure) is a distinct
   product from Shake, not a continuation of it — the gap between
   them should read the same as any other product-to-product break
   (the standard section-gap), not the plain image-gap the row's own
   flex `gap` already gives every adjacent pair. Targeted at the
   labeled figure specifically, and excluding :first-child (Shake
   itself, which already gets the section-gap above from the rule
   above) so this doesn't double up there. Halva's own second image
   (halva-2, no label of its own) is unaffected, so it keeps the
   plain image-gap from Halva's figure — exactly the established
   label-to-its-own-media spacing, untouched. */
@media (max-width: 699px) {
  .tlm-lab .thumb-row > figure:has(.mobile-media-label):not(:first-child) {
    margin-top: calc(var(--project-section-gap) - var(--project-image-gap));
  }
}

.tlm-lab .idx-pair {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

@media (min-width: 700px) {
  .tlm-lab .idx-pair {
    flex-direction: row;
    gap: var(--project-image-gap);
  }

  .tlm-lab .idx-pair > .idx {
    flex: var(--r, 1) var(--r, 1) 0;
  }
}

.tlm-lab .gallery-v2 > .thumb-row + .thumb-row.tlm-flat {
  margin-top: var(--project-section-gap);
}

/* A lone flex:var(--r,1) var(--r,1) 0 figure with --r below 1
   (portrait) doesn't grow to fill the row the way every landscape
   single-figure row on the site does (verified against HaGevatron's
   --r:2 songbook-flat rows, which fill correctly) — confirmed browser
   quirk in how a flex-basis:0 replaced item's automatic minimum size
   resolves for portrait intrinsic ratios. flat.jpg is portrait, so
   it hits this directly. Bypassing flex-grow for width here —
   width:100% directly — sidesteps the quirk outright. */
@media (min-width: 700px) {
  .tlm-lab .thumb-row.tlm-flat figure {
    flex: 1 1 auto;
    width: 100%;
  }
}

/* Raw Tahini: the row's first image is the strongest/main shot and
   stays full-width on mobile, a plain sibling figure outside the
   masonry wrapper entirely — see .mobile-photo-columns further down
   this file for how the remaining supporting photographs flow as two
   true independent columns underneath it. Desktop is untouched (the
   wrapper is display:contents there — see the same comment). */

/* Packaging flat/layout: a wide horizontal composition that reads far
   too small at mobile's narrow width if left at its natural landscape
   aspect. Rotate it 90° into a tall wrapper sized to the *inverse* of
   the image's own --r, so the rotated result exactly fills the full
   mobile width using what was previously its height — same asset,
   same crop, presentation-only. The image is sized in its own
   pre-rotation coordinate space (width along the wrapper's width axis,
   height along the wrapper's height axis) so that after rotate(90deg)
   it lands exactly on the wrapper's own box with no gap or overflow.
   Desktop keeps the plain unrotated figure (this wrapper carries no
   styling above 699px, so it's visually a no-op div there). */
@media (max-width: 699px) {
  .tlm-lab .tlm-flat-rotate {
    position: relative;
    width: 100%;
    aspect-ratio: 1 / var(--r, 1.649);
    overflow: hidden;
  }

  .tlm-lab .tlm-flat-rotate img {
    position: absolute;
    top: 50%;
    left: 50%;
    width: calc(var(--r, 1.649) * 100%);
    height: calc(100% / var(--r, 1.649));
    max-width: none;
    object-fit: contain;
    /* -90deg (i.e. the previous 90deg plus an additional 180°) — the
       bounding-box math above is direction-agnostic (rotating either
       way produces the same swapped width/height), so only the sign
       needed to change to correct the reading orientation. */
    transform: translate(-50%, -50%) rotate(-90deg);
  }
}

/* Point of Light — the page opens on a bare, unlabeled full-width
   .thumb-row (image 12), not an .idx label, so the generic
   *:first-child reset already wins here on its own (equal
   specificity to the plain .thumb-row baseline rule, later in
   source order) — no override needed, unlike every other page whose
   gallery opens on a labeled row. Page-specific rules: the tight 4px
   gap under [01] COCOA / 03 for its three secondary percentage
   labels (same technique as Murder Pattern's Open/Folded pair, just
   extended to three columns — flex:var(--r,1) works identically for
   any number of children), and the section-gap that opens the
   group-photography block (.pol-group-start, the 13+16 row) — a
   distinct "visual moment" from the cocoa row above it, despite
   having no label of its own. Image 4 underneath, by contrast, is
   the *same* group continuing onto a second row, so it deliberately
   falls through to the generic .thumb-row + .thumb-row image-gap
   instead of repeating the section-gap. */
.pol-lab .gallery-v2 > .idx-pair {
  margin-top: 4px;
}

.pol-lab .idx-pair {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

@media (min-width: 700px) {
  .pol-lab .idx-pair {
    flex-direction: row;
    gap: var(--project-image-gap);
  }

  .pol-lab .idx-pair > .idx-sub {
    flex: var(--r, 1) var(--r, 1) 0;
    margin-top: 0;
    color: var(--fg-dim);
  }
}

.pol-lab .gallery-v2 > .thumb-row + .thumb-row.pol-group-start {
  margin-top: var(--project-section-gap);
}

/* Image 7 (90% cocoa) was photographed at a slightly different angle
   from 8 and 9, which breaks the three-way comparison the COCOA row
   exists for. A perceptual fix, not a geometric one: the wrapper
   takes over the figure's own sizing (aspect-ratio:var(--r), same
   value the figure itself already carries, so the column's displayed
   footprint is byte-for-byte identical to 8 and 9) and clips
   overflow, while the image inside is rotated back toward 8/9's
   apparent orientation and scaled up just enough to cover the
   corners that rotation opens up. 8 and 9 are untouched — same plain
   width:100%/height:auto as every other image on the site. */
.pol-lab .cocoa-align-fix {
  overflow: hidden;
  width: 100%;
  aspect-ratio: var(--r, 1.5);
}

.pol-lab .cocoa-align-fix img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transform: rotate(-2deg) scale(1.05);
  transform-origin: center;
}

/* ============================================================
   Mobile-only refinements — every rule below this point is scoped to
   a max-width query (or is a utility that's a no-op until one applies
   a mobile class), so none of it touches desktop rendering.
   ============================================================ */

/* --project-section-gap (96px) was tuned for desktop, where images
   are large enough that the gap reads as proportionate. At mobile's
   single-column width, the same 96px reads as an oversized, seemingly
   accidental break between a media group and the next section label —
   this is the actual root cause behind the "giant empty gap before/
   after [section]" reports across Murder Pattern, HaGevatron, No
   Words Left and Iranian Tama, all of which share this one token via
   the existing .project-lab spacing system. Redefining the token here
   (rather than patching each transition individually) fixes all of
   them at once and keeps the three-tier spacing hierarchy — label→
   media / media→media / section→section — intact, just rescaled for
   the narrower viewport. */
@media (max-width: 699px) {
  .project-lab {
    --project-section-gap: var(--space-5);
  }
}

/* Secondary media labels (Open/Folded, Side A/Side B, cocoa %, per-
   product tags, etc.) live in a .idx-pair row that sits, on every
   .project-lab page, directly above the .thumb-row of images it
   describes — correct on desktop, where the pair's own flex-basis
   math keeps each label's column pinned to its matching figure. On
   mobile the two rows just stack independently (all labels, then all
   images), so a label reads as detached from its own media. Rather
   than restructure the DOM those desktop rules depend on, mobile
   hides the whole .idx-pair and reveals a small mirrored label placed
   as the first child inside each figure it belongs to (added in the
   markup, marked .mobile-media-label) — same underlying .idx-main/
   .idx-sub/.num classes, so it inherits identical typography/color to
   its desktop counterpart. Exactly one of the two is ever visible at
   a time, so nothing is ever double-announced to assistive tech
   either. */
/* !important on both states rather than chasing specificity: this
   class is reused on elements that carry their own unconditional
   display rules (.idx-sub is display:block; unconditionally, .idx
   isn't but could gain one later) — a plain 1-class selector loses
   that fight on some of them (confirmed: .idx-sub's 2-class rule beat
   a bare .mobile-media-label at desktop width). The whole point of
   this class is to guarantee exactly one of the two label copies is
   ever visible at a given width, so its visibility should never be
   something a reused component class can silently override. */
.mobile-media-label { display: none !important; }

@media (max-width: 699px) {
  .project-lab .idx-pair {
    display: none;
  }

  .mobile-media-label {
    display: block !important;
    margin-bottom: var(--project-label-gap);
  }
}

/* Two-column mobile grid — opt-in via .mobile-2col on a .thumb-row,
   used only where a project's own instructions call for it (a run of
   several related, comfortably-narrow photographs). Same gutter value
   on both axes as the rest of the site's image spacing, so the grid
   reads as part of the same system rather than a one-off. Prefixed
   with .gallery-v2 (not just the bare compound selector) so its
   specificity reliably beats the later, equal-selector-count
   `.gallery-v2 .thumb-row, .project-hero-v2 .thumb-row { display:flex }`
   base rule further down the file — a plain `.thumb-row.mobile-2col`
   ties with it in specificity and, being earlier in source order,
   would otherwise lose that tie.

   Plain auto-sized grid rows, no JS: every group currently using this
   class pairs images that share the same --r within that group, so a
   default (auto-height) row track already sizes to exactly their real
   height — there's no differing-height pair that needs a masonry-style
   independent-column algorithm. An earlier version used a tiny fixed
   grid-auto-rows unit plus grid-auto-flow:row dense, with main.js
   computing an explicit grid-row-end:span N per item to approximate
   each item's real height — but that span is necessarily rounded up
   (Math.ceil), which left inconsistent trailing slack under whichever
   item's rounding happened to be furthest off (this is the same bug
   or Iranian Tama's "What's in the Plan" grid). Plain auto rows can't
   produce that kind of slack: a row's height is always exactly its
   content's real height. The trailing odd-image-spans-both-columns
   rule below still applies on top of this unchanged. */
@media (max-width: 699px) {
  .gallery-v2 .thumb-row.mobile-2col {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: var(--project-image-gap);
    align-items: start;
  }
}

/* Two fixed, independent columns — no height-balancing. Height-aware
   dynamic assignment (dense-packed grid, then greedy shortest-column
   absolute positioning) was tried and rejected: both still produced
   visible holes, because "smart" placement based on running height
   totals can leave one column's *next* item starting well below
   where its own previous item ended, whenever the algorithm's
   bookkeeping doesn't match what the eye expects. The fix is to stop
   being clever: .mobile-photo-column is a plain flex column, each
   image is a normal flex item directly under the previous one with
   nothing but the standard gap between them — there is no mechanism
   by which a gap *inside* a column can occur. Which image goes in
   which column is decided once, explicitly, in the markup (typically
   first-half/second-half of the group) — never recomputed from
   heights. The two columns are allowed to end at different heights;
   .mobile-photo-columns is a plain (not absolutely-positioned) flex
   row, so its own height is just the natural max of its two children,
   and whatever follows in the document starts right below the taller
   one for free.

   At desktop both this wrapper and .mobile-photo-column collapse via
   display:contents, promoting every figure back to being a direct,
   unmodified-source-order child of .thumb-row's proportional --r flex
   row — exactly reproducing the original layout, provided the fixed
   split assigns column 1 the first N images and column 2 the rest (so
   depth-first DOM order after both layers of display:contents matches
   the original authored sequence exactly). */
.mobile-photo-columns {
  display: block;
}

.mobile-photo-column {
  display: block;
}

@media (min-width: 700px) {
  .mobile-photo-columns,
  .mobile-photo-column {
    display: contents;
  }
}

@media (max-width: 699px) {
  .mobile-photo-columns {
    display: flex;
    align-items: flex-start;
    gap: var(--project-image-gap);
  }

  .mobile-photo-column {
    display: flex;
    flex-direction: column;
    gap: var(--project-image-gap);
    width: calc(50% - var(--project-image-gap) / 2);
    min-width: 0;
  }
}

/* Telem raw tahini, structure row: a manual (not height-based) mobile
   composition swap — the open-package/flap photo (structure-2) reads
   better as the final image in its column, and the group photo
   (structure-main) reads better pulled out to its own full-width row
   below the two columns entirely. DOM order stays exactly as
   originally authored (tahini-5, structure-2, structure-3,
   structure-main) so desktop's display:contents-based flattening is
   untouched; .tlm-mobile-last only changes structure-2's *visual*
   position within its column via order, mobile-only. structure-main
   needs no special rule at all — it's already a plain, unwrapped
   .thumb-row child (same pattern as tahini-main above), so it's full
   width by default and picks up the ordinary standard-gutter gap from
   .thumb-row's own flex gap automatically, both before and after it. */
@media (max-width: 699px) {
  .tlm-lab .tlm-mobile-last {
    order: 2;
  }
}

/* Final manual composition for this whole section (both raw-tahini
   rows) — no algorithmic balancing anywhere here, every placement
   below is a deliberate, fixed choice. tahini-3 (a small, mostly-
   white-background shot of a closed box lying on its side) is the
   weakest image in the group and is dropped from mobile entirely;
   hidden via display:none rather than removed from the DOM, so
   desktop (which still shows all four Raw Tahini supporting photos in
   their original row) is completely unaffected. */
@media (max-width: 699px) {
  .tlm-lab .tlm-hide-mobile {
    display: none;
  }
}

/* tahini-5, as the sole left-column image, renders shorter than the
   two stacked images opposite it (structure-3 + structure-2 + the
   gutter between them) at its native --r — a real "not perfect
   mathematical alignment" per the brief, but visually further off
   than "approximately balanced" calls for. A presentational crop
   (source asset untouched) tightens the frame around the box —
   there's substantial dead white background on both sides at the
   native ratio — bringing its displayed height close to the combined
   right-hand stack without touching the cardboard object itself.
   Same wrapper + aspect-ratio + object-fit:cover technique as
   .cocoa-align-fix on the Point of Light page. */
@media (max-width: 699px) {
  .tlm-lab .tlm-left-crop {
    aspect-ratio: 0.5;
    overflow: hidden;
  }

  .tlm-lab .tlm-left-crop img {
    width: 100%;
    height: 100%;
    object-fit: cover;
  }
}

/* The row above this one has an uneven left/right split (tahini-2
   alone on the left, tahini-4 alone on the right, ~21px shorter) —
   correct and harmless on its own, but since this next row starts as
   one block 24px below whichever of those two columns is taller, the
   *shorter* column (left) ends up with an oversized ~45px gap before
   its own next image while the right stays at the normal 24px. A
   manual, one-off correction — not a general mechanism — pulls this
   row's left column up by exactly that ~21px difference so the left
   column's own gap matches the standard gutter; the right column
   (structure-3) is untouched and stays exactly where it already
   renders correctly. Deliberately a fixed pixel value tied to these
   specific images' current heights, not a formula — this is a
   one-time art-directed nudge per the brief, not a rule meant to
   generalize. */
@media (max-width: 699px) {
  .tlm-lab .tlm-pull-up {
    margin-top: -21px;
  }
}

/* ============================================================
   Art — interactive artwork viewer. Scoped to body.art-lab only.
   This collection isn't a linear case study, so it doesn't scroll
   like one: one carousel steps between the seven technique groups,
   each slide an atomic unit of its own counter/title/dimensions +
   artwork so the two can never fall out of sync. Only the active
   slide is ever in normal flow (position:relative) — every other
   slide sits absolutely stacked underneath at the same box.

   Desktop is its own full viewport section (~100svh): metadata /
   artwork / nav stack via flexbox with the artwork row taking
   flex:1, so the browser — not JS — gives it "every pixel left
   after the fixed chrome," and that's identical across all seven
   slides since every slide's head/frame/nav structure is the same.
   Multi-image frames never wrap; JS only steps in as a safety net
   when a row would be wider than the viewer at its flex-derived
   height, scaling that one frame down via --frame-h so the complete
   work still fits on one line undistorted. */
.art-viewer {
  padding: var(--space-3);
}

@media (min-width: 700px) {
  .art-viewer {
    /* Full viewport height MINUS the sticky header, which overlays
       the page rather than pushing it down in normal flow — without
       this the viewer's own top padding (where the metadata sits)
       ends up underneath the header instead of below it. --header-h
       is measured in JS since the header's real height can drift a
       few px with font loading; the fallback matches its usual
       height. */
    height: calc(100svh - var(--header-h, 93px));
    padding: var(--space-5);
    display: flex;
    flex-direction: column;
  }
}

.art-stage {
  position: relative;
}

@media (min-width: 700px) {
  .art-stage {
    flex: 1 1 auto;
    min-height: 0;
  }
}

.art-slide {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  margin: 0;
  opacity: 0;
  transition: opacity 380ms var(--ease), transform 380ms var(--ease);
  pointer-events: none;
}

.art-slide.is-active {
  position: relative;
  opacity: 1;
  transform: translateX(0);
  pointer-events: auto;
}

@media (min-width: 700px) {
  .art-slide {
    inset: 0;
    height: 100%;
    display: flex;
    flex-direction: column;
  }

  .art-slide.is-active {
    height: 100%;
  }
}

.art-head {
  display: flex;
  flex-direction: column;
  gap: 4px;
  margin-bottom: var(--project-label-gap);
}

@media (min-width: 700px) {
  .art-head { flex: 0 0 auto; }
}

.art-counter {
  font-family: var(--font-body);
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.03em;
  color: var(--project-accent);
}

.art-title {
  font-family: var(--font-body);
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.03em;
  text-transform: uppercase;
  color: var(--fg-dim);
}

.art-dim {
  font-family: var(--font-body);
  font-size: 9px;
  letter-spacing: 0.05em;
  color: var(--fg-dim);
  opacity: 0.5;
}

/* Mobile default: images stack full-width, natural height — the
   fixed-height single-row rule is a desktop art-direction choice
   (see the 700px breakpoint below), not appropriate once there's no
   width left to divide four drawings across. */
.art-frame {
  display: flex;
  flex-direction: column;
  flex-wrap: wrap;
  gap: var(--project-image-gap);
  align-items: center;
  justify-content: center;
}

.art-frame figure {
  margin: 0;
  min-width: 0;
  width: 100%;
  aspect-ratio: var(--r, 1);
}

.art-frame img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
}

@media (min-width: 700px) {
  .art-frame {
    flex: 1 1 auto;
    flex-direction: row;
    flex-wrap: nowrap;
    min-height: 0;
    gap: clamp(24px, 2vw, 40px);
    overflow: hidden;
  }

  .art-frame figure {
    height: var(--frame-h, 100%);
    width: auto;
    flex: 0 0 auto;
  }
}

.art-nav {
  display: flex;
  justify-content: space-between;
  margin-top: var(--project-image-gap);
}

@media (min-width: 700px) {
  .art-nav { flex: 0 0 auto; }
}

/* Sized up from an earlier 11px/no-hit-area version so the controls
   read as unmistakably navigable rather than incidental footer text.
   Horizontal padding only grows on the side facing the center of the
   row (prev's right, next's left) — the outer edge stays at 0 so the
   visible glyph/text still sits exactly on the master-grid edge;
   only the invisible hit area gets bigger. */
.art-prev,
.art-next {
  font-family: var(--font-body);
  font-size: 15px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--fg-dim);
  background: none;
  border: none;
  padding-top: 12px;
  padding-bottom: 12px;
  margin: 0;
  min-height: 44px;
  display: inline-flex;
  align-items: center;
  cursor: pointer;
  transition: color 150ms var(--ease), transform 150ms var(--ease), opacity 150ms var(--ease);
}

.art-prev { padding-left: 0; padding-right: 20px; }
.art-next { padding-left: 20px; padding-right: 0; }

.art-prev:hover:not(:disabled) { transform: translateX(-4px); color: var(--project-accent); }
.art-next:hover:not(:disabled) { transform: translateX(4px); color: var(--project-accent); }

.art-prev:focus-visible,
.art-next:focus-visible {
  outline: 1.5px solid var(--project-accent);
  outline-offset: 4px;
}

.art-prev:disabled,
.art-next:disabled {
  opacity: 0.25;
  cursor: default;
}

.project-meta {
  position: relative;
  margin-top: var(--space-3);
  display: inline-block;
  padding: 11px 14px 9px 16px;
  font-family: var(--font-body);
  font-size: 11px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--fg-dim);
}

.project-meta i {
  position: absolute;
  inset: 0;
  border: 0 solid var(--accent);
  border-width: 1.5px 0 0 1.5px;
}

.project-meta .idx { color: var(--accent); }

/* Hero: title/meta/description/credits together on the left, the
   cover image beside them on the right — nothing sits alone in its
   own full-width row. */
.project-hero-v2 {
  position: relative;
  display: grid;
  gap: var(--space-4);
  padding: var(--space-6) var(--space-3) var(--space-5);
}

/* Mobile: title+meta, then the hero image, then the description —
   the image should read immediately after the headline, not after a
   full paragraph of body copy. .hero-text/.hero-image/.desc are three
   separate grid children in that exact source order so this is the
   natural mobile flow with zero JS/order trickery; gap is zeroed here
   so .hero-image's own margin-top (below) plus .desc's pre-existing
   unconditional margin-top are the only spacing source, each applied
   once. Desktop (900px+) restores the original two-column result via
   named grid areas — see below. */
@media (max-width: 899px) {
  .project-hero-v2 { gap: 0; }
  /* .project-hero-v2 .hero-image (2 classes) ties in specificity with
     the unconditional position/margin reset further down this file
     (also 2 classes) and, being earlier in source, was silently
     losing that tie — margin-top was computing to 0, not var(--space-4)
     as intended, leaving the metadata and image visually touching.
     .project-lab (present on <body> on every page using this hero)
     adds a third class purely to guarantee this always wins regardless
     of source order. */
  .project-lab .project-hero-v2 .hero-image { margin-top: var(--space-4); }
}

@media (min-width: 900px) {
  .project-hero-v2 {
    grid-template-columns: 1fr 1fr;
    grid-template-areas: "text image" "desc image";
    align-items: start;
    /* row-gap:0 rather than the old single `gap` shorthand — desc's
       own margin-top (unchanged, unconditional, further down) already
       reproduced the exact original text→desc spacing when both sat
       in one plain block wrapper; splitting them into separate grid
       rows would otherwise add this column-gap value a second time
       between them. */
    column-gap: var(--space-6);
    row-gap: 0;
    padding: var(--space-7) var(--space-5) var(--space-6);
  }

  .project-hero-v2 .hero-text { grid-area: text; }
  .project-hero-v2 .hero-image { grid-area: image; }
  .project-hero-v2 .desc { grid-area: desc; }

  .project-hero-v2.no-image {
    grid-template-columns: 1fr;
    /* The Art page's hero wasn't restructured into hero-text/desc
       (it has no image to reorder around, so section 03's fix doesn't
       apply there) — its one plain child would otherwise auto-place
       against the named areas above with no area of its own to claim,
       so this variant explicitly falls back to no named areas at all,
       matching its original plain single-column behavior exactly. */
    grid-template-areas: none;
    /* No image means no tall grid row forcing the padding open — an
       image-less hero carries less visual mass, so it should read as
       a compact typographic beat, not repeat the full padding an
       image-bearing hero uses to balance its photo. Matters most on
       the Art page, where the viewer below needs this space back to
       fit a complete artwork viewport without scrolling. */
    padding-top: var(--space-5);
    padding-bottom: var(--space-4);
  }
}

.project-hero-v2 h1 {
  color: var(--accent);
  font-size: clamp(52px, 7.5vw, 128px);
  margin-top: -0.18em;
  text-transform: uppercase;
}

/* Title, then the criteria in one row right under it, then the
   long-form description below that. */
.project-hero-v2 .typo-criteria {
  margin-top: var(--space-3);
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  align-items: center;
  gap: 14px;
}

.project-hero-v2 .desc {
  margin-top: var(--space-4);
  font-size: clamp(15px, 1.78vw, 19.5px);
  color: var(--fg-dim);
  line-height: 1.75;
  max-width: 62ch;
}

.project-hero-v2 .desc strong {
  color: var(--accent);
  font-weight: 600;
}

.project-hero-v2 .hero-image {
  position: relative;
  margin: 0;
}

.project-hero-v2 .hero-image img,
.project-hero-v2 .hero-image video {
  width: 100%;
  height: auto;
  display: block;
}

.project-hero-v2 .hero-video {
  margin: var(--space-4) 0 0;
  width: min(200px, 55%);
}

.project-hero-v2 .hero-video video {
  width: 100%;
  height: auto;
  display: block;
}

/* A second stacked figure beside the text column — e.g. poster then
   spread, one directly under the other. */
.project-hero-v2 .hero-stack {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
}

/* When a thumb-row sits inside the hero (rather than the gallery,
   which spaces its own rows via its flex gap) it needs its own
   top margin. */
.project-hero-v2 .thumb-row {
  margin-top: var(--space-4);
}

/* Gallery: a real justified grid, the same principle as the homepage
   tiles — every row fills the exact same total width as the rest of
   the page. Within a row, each image keeps its own aspect ratio (set
   via --r on the figure); giving every figure's flex-grow that same
   ratio value makes the row divide its fixed width in proportion to
   each image's shape while landing on one shared row height for
   free — a wider image simply claims more of the row, nothing crops,
   nothing is forced into a mismatched box. */
.gallery-v2 {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
  padding: 0 var(--space-3) var(--space-6);
}

@media (min-width: 768px) {
  .gallery-v2 { padding: 0 var(--space-5) var(--space-7); gap: var(--space-4); }
}

.gallery-v2 figure,
.project-hero-v2 .thumb-row figure {
  position: relative;
  margin: 0;
  min-width: 0;
}

.gallery-v2 img,
.gallery-v2 video,
.project-hero-v2 .thumb-row img {
  width: 100%;
  height: auto;
  display: block;
}

.gallery-v2 .thumb-row,
.project-hero-v2 .thumb-row {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
}

/* The video only pairs with the video-height's-worth of stills — the
   collage-row. Any further rows run full width below it, still part
   of the same justified system. */
.gallery-v2 .collage-row {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
}

@media (min-width: 700px) {
  .gallery-v2 .collage-row {
    flex-direction: row;
    align-items: stretch;
  }

  .gallery-v2 .cell-video {
    flex: 0 0 29%;
  }

  .gallery-v2 .cell-video video {
    width: 100%;
    height: auto;
    align-self: center;
  }

  .gallery-v2 .thumb-stack {
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    gap: var(--space-4);
    flex: 1 1 0;
    min-width: 0;
  }

  .gallery-v2 .thumb-row,
  .project-hero-v2 .thumb-row {
    flex-direction: row;
    gap: var(--space-4);
  }

  .gallery-v2 .thumb-row figure,
  .project-hero-v2 .thumb-row figure {
    flex: var(--r, 1) var(--r, 1) 0;
  }

  .gallery-v2 .thumb-row img,
  .project-hero-v2 .thumb-row img {
    width: 100%;
    height: auto;
  }

  /* A source clip with baked-in letterbox bars: force its own box to
     the true content ratio and crop the video to fill it, removing
     the black margins instead of showing them. */
  .gallery-v2 .thumb-row figure.crop-x {
    aspect-ratio: var(--r);
  }

  .gallery-v2 .thumb-row figure.crop-x video {
    width: 100%;
    height: 100%;
    object-fit: cover;
  }

  /* A row with just one figure would otherwise stretch it across the
     entire row width — for a portrait-ratio image that reads as
     oversized next to every other row. Instead it's held to a shared
     reference height so every row in the gallery reads the same
     scale regardless of how many images share it. Height AND width
     are both set inline by JS (not width:auto against a fixed
     height) because a lazy-loaded image's intrinsic ratio isn't
     known yet at layout time, which would otherwise collapse it to
     0 width until it scrolls into view and loads. */
  .gallery-v2 .thumb-row figure.solo {
    flex: 0 0 auto;
  }

  .gallery-v2 .thumb-row figure.solo img {
    max-width: 100%;
  }

  /* Book row: one feature image runs taller than its neighbours,
     everything bottom-aligned rather than sharing one row height. */
  .gallery-v2 .book-row,
  .project-hero-v2 .book-row {
    align-items: flex-end;
  }

  .gallery-v2 .book-row .feature,
  .project-hero-v2 .book-row .feature {
    flex: 0 0 34%;
  }

  .gallery-v2 .book-row figure:not(.feature),
  .project-hero-v2 .book-row figure:not(.feature) {
    flex: var(--r, 1) var(--r, 1) 0;
    min-width: 0;
  }
}

/* Same letterbox-crop treatment as the desktop figure.crop-x rule
   above, re-scoped to mobile — at mobile widths a plain video just
   shows its baked-in black bars uncropped otherwise (Iranian Tama's
   first clip). Desktop rule is untouched.
   Deliberately NOT the same technique as the desktop rule, though:
   desktop puts aspect-ratio on the *figure* because the figure only
   ever contains the video there (labels are display:none at desktop).
   At mobile the figure also contains the injected .mobile-media-label
   above the video — aspect-ratio on the figure fixes the figure's
   *total* box height regardless of that label's own height, so the
   video (sized to height:100% of that fixed-height figure, but
   starting further down because it follows the label in normal flow)
   overflowed the figure's own bottom edge by exactly the label's
   height — which is what was reading as the next label colliding
   with this video. Putting aspect-ratio directly on the video instead
   lets the figure's own height stay auto (label height + the video's
   own aspect-ratio-derived height, stacked normally, no overflow),
   while the video itself still gets the identical crop treatment. */
@media (max-width: 699px) {
  .gallery-v2 .thumb-row figure.crop-x video {
    aspect-ratio: var(--r);
    width: 100%;
    height: auto;
    object-fit: cover;
  }
}

/* Split collage: two independent stacked columns side by side — a
   narrower one (video on top of a small justified pair) and a wider
   one (a tall lead image on top of a wide one). Each column sizes
   itself naturally; nothing crops. */
.gallery-v2 .split-collage {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
}

.gallery-v2 .split-collage .split-col {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
}

@media (min-width: 700px) {
  .gallery-v2 .split-collage {
    flex-direction: row;
    align-items: flex-start;
  }

  .gallery-v2 .split-collage .split-col:first-child {
    flex: 0 0 43%;
  }

  .gallery-v2 .split-collage .split-col:last-child {
    flex: 1 1 0;
    min-width: 0;
  }
}

/* The closing navigation strip of a case study — restrained on
   purpose, this time deliberately: an earlier pass pushed the type
   to hero scale and read as a second hero instead of navigation, so
   this version sits in between the original 20-36px utility-scale
   footer and that oversized one. The thin top border is structural
   (separates content from navigation), not decorative — same line
   token used everywhere else on the site. Both links share one
   plain hover response (color); NEXT PROJECT's arrow is the only
   part that ever moves (hover, or the rare idle cue in main.js),
   kept the same color/weight as its surrounding text rather than a
   distinct graphic object. */
.project-nav {
  border-top: 1px solid var(--border);
  padding: var(--space-5) var(--space-3);
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  align-items: baseline;
  gap: var(--space-3);
}

@media (min-width: 768px) {
  .project-nav { padding: var(--space-5) var(--space-5); }
}

.project-nav a {
  font-family: var(--font-display);
  font-size: clamp(19px, 2.4vw, 27px);
  text-transform: uppercase;
  letter-spacing: 0.01em;
  color: var(--fg);
  min-height: 44px;
  display: inline-flex;
  align-items: center;
  transition: color 200ms var(--ease);
}

.project-nav a:hover,
.project-nav a:focus-visible {
  color: var(--accent);
}

.nav-arrow {
  display: inline-block;
  margin-left: 0.35em;
  transition: transform 200ms var(--ease);
}

.nav-next:hover .nav-arrow,
.nav-next:focus-visible .nav-arrow {
  transform: translateX(4px);
}

/* Mobile: same divider, same two actions, but rebuilt to guarantee a
   single row regardless of project-name length — this is chrome you
   step past, not a second hero, and it has to survive "Point of
   Light" and "Murder Pattern" without wrapping, clipping, or
   overflowing. ALL PROJECTS is fixed to its own content width
   (flex:0 0 auto); NEXT PROJECT takes the remaining space and never
   wraps (white-space:nowrap) — the font-size below is picked small
   enough that even the longest real title plus its "NEXT /" prefix
   fits inside what's left on a 375px viewport with room to spare.
   min-width:0 lets the flex item itself shrink to fit inside that
   remaining space (its default min-content floor would otherwise
   force the row wider than the viewport); nothing here relies on
   truncation or ellipsis to paper over an actual overflow. Desktop
   rule above is untouched. */
@media (max-width: 767px) {
  .project-nav {
    padding: var(--space-4) var(--space-3);
    flex-wrap: nowrap;
    align-items: center;
    gap: var(--space-2);
  }

  .project-nav a {
    font-size: 11px;
    letter-spacing: 0.02em;
    white-space: nowrap;
  }

  .nav-index {
    flex: 0 0 auto;
  }

  .nav-next {
    flex: 1 1 auto;
    min-width: 0;
    justify-content: flex-end;
  }

  /* The graphic-feeling arrow is dropped on mobile entirely (see
     item 15 in the brief this responds to — omitting it outright is
     explicitly sanctioned when even a small glyph feels unnecessary);
     the project title alone already communicates that this is a
     link, and one fewer element competing for the row's width is one
     less thing that could ever force an overflow. Desktop keeps its
     existing small inline arrow unchanged. */
  .nav-arrow {
    display: none;
  }
}

/* ---------------- Entrance ----------------
   The entry point to the same system, not a separate identity: dark
   background, master-grid padding, the same mono/display type pair
   and the same [ ] / ↳ grammar used everywhere else — just applied
   to "OFIR ATTAR." instead of a project. No floating elements, no
   scattered corner tags, no glass — a flat fade is the only motion
   besides the counter itself and its one completion glitch (wired in
   main.js). */
.intro {
  position: fixed;
  inset: 0;
  z-index: 500;
  display: flex;
  align-items: flex-end;
  justify-content: flex-start;
  padding: var(--space-4);
  background-color: var(--bg);
  transition: opacity 650ms var(--ease);
}

@media (min-width: 700px) {
  .intro { padding: var(--space-6); }
}

html.no-intro .intro,
.intro.is-gone {
  display: none;
}

.intro.is-leaving {
  opacity: 0;
  pointer-events: none;
}

.intro-inner {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 10px;
}

.intro-mark {
  font-family: var(--font-display);
  font-size: clamp(30px, 5vw, 52px);
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: -0.01em;
  color: var(--fg);
  opacity: 0;
  transform: translateY(10px);
  animation: introMarkIn 640ms var(--ease) 100ms forwards;
}

.intro-mark i {
  font-style: normal;
  color: var(--fg-dim);
}

.intro-role {
  font-family: var(--font-body);
  font-size: 12px;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--fg-dim);
  opacity: 0;
  animation: introRoleIn 500ms var(--ease) 420ms forwards;
}

/* The one moving piece: a small technical readout, same size/weight
   as an .idx line elsewhere. Quiet by default (fg-dim) — this is
   chrome, not a project accent, so it doesn't reach for --accent. */
.intro-counter {
  font-family: var(--font-body);
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--fg-dim);
  opacity: 0;
  animation: introRoleIn 500ms var(--ease) 650ms forwards;
}

.intro-counter-num {
  color: var(--fg);
  font-variant-numeric: tabular-nums;
}

@keyframes introMarkIn {
  to { opacity: 1; transform: translateY(0); }
}
@keyframes introRoleIn {
  to { opacity: 1; }
}

/* ---------------- Reveal-on-scroll ---------------- */
.reveal {
  opacity: 0;
  transform: translateY(18px);
  transition: opacity 600ms var(--ease), transform 600ms var(--ease);
  transition-delay: var(--stagger, 0ms);
}
.reveal.is-visible {
  opacity: 1;
  transform: translateY(0);
}

@media (prefers-reduced-motion: reduce) {
  * {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
  .reveal { opacity: 1; transform: none; }
}
