.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.empty-state {
  padding: var(--space-8) var(--space-6);
  color: color-mix(in srgb, var(--color-text) 55%, transparent);
}

/* ============ Projects wall ============ */

/* Source locks html/body to the viewport for the wall page only. */
html:has(.wall-page) {
  height: 100%;
  overflow: hidden;
}

html:has(.wall-page) body {
  height: 100%;
  overflow: hidden;
}

#main:has(.wall-page) {
  height: 100%;
  padding-top: 0;
}

.wall-page {
  /* Mirrors base.css's #main padding-top (72px, 96px under 480px where the
     nav wraps to two rows) so the fixed nav never overlaps or gaps the wall. */
  --nav-h: 72px;
  position: relative;
  height: calc(100% - var(--nav-h));
  margin-top: var(--nav-h);
}

.wall-page .empty-state {
  padding-top: var(--space-8);
}

.wall-scroll {
  position: absolute;
  inset: 0;
  overflow-x: scroll;
  overflow-y: hidden;
  scrollbar-width: none;
}

.wall-scroll::-webkit-scrollbar {
  display: none;
}

.wall-scroll:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: -2px;
}

.wall-grid {
  display: grid;
  grid-template-rows: repeat(5, 1fr);
  grid-auto-flow: column;
  /* Source: 250vw over 20 columns = 12.5vw/col, 8 columns visible per
     viewport width. Rows fill the wall height independently (not square). */
  grid-auto-columns: 12.5vw;
  gap: 2px;
  background: var(--color-divider);
  height: 100%;
}

.tile {
  position: relative;
  display: block;
  overflow: hidden;
  background: var(--color-surface);
  opacity: 0;
  animation: tileIn 0.7s cubic-bezier(0.16, 1, 0.3, 1) both;
  animation-play-state: paused;
}

.wall-grid.is-ready .tile {
  animation-play-state: running;
}

.tile:hover,
.tile:focus-visible {
  box-shadow: 0 0 0 2px var(--color-accent), var(--shadow-lg);
  z-index: 2;
  --sc: 1.18;
}

/* Edge auto-scroll streams tiles past a stationary cursor, so every tile
   boundary crosses :hover — each crossing repaints a 32px box-shadow blur
   on the main thread, at a cadence steady enough to read as a stutter.
   Suppressed only for :hover while the loop runs; :focus-visible (keyboard
   affordance) is untouched at all times. */
.wall-grid.is-autoscrolling .tile-image {
  transition: none;
}

.wall-grid.is-autoscrolling .tile:hover {
  box-shadow: none;
  --sc: 1;
}

.tile--small {
  grid-column: span 1;
  grid-row: span 1;
}

.tile--wide {
  grid-column: span 2;
  grid-row: span 1;
}

.tile--tall {
  grid-column: span 1;
  grid-row: span 2;
}

.tile--large {
  grid-column: span 2;
  grid-row: span 2;
}

.tile-image {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transform: scale(var(--sc, 1));
  transition: transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}

@keyframes tileIn {
  from {
    opacity: 0;
    transform: scale(1.08);
  }
  to {
    opacity: 1;
    transform: scale(1);
  }
}

/* Tile-morph overlay used by project.js on click, before navigation. */
.tile-morph {
  position: fixed;
  overflow: hidden;
  z-index: var(--z-modal);
  background: var(--color-surface);
  transition: top 0.6s cubic-bezier(0.16, 1, 0.3, 1),
    left 0.6s cubic-bezier(0.16, 1, 0.3, 1),
    width 0.6s cubic-bezier(0.16, 1, 0.3, 1),
    height 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}

.tile-morph.is-opening {
  transition: none;
}

.tile-morph img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.morph-veil {
  position: fixed;
  inset: 0;
  z-index: var(--z-overlay);
  background: var(--color-bg);
  opacity: 0;
  transition: opacity 0.5s ease;
  pointer-events: none;
}

.morph-veil.is-visible {
  opacity: 1;
}

/* Intro sheet */
.intro-sheet {
  position: fixed;
  inset: 0;
  z-index: var(--z-toast);
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--color-bg);
  background-image: repeating-linear-gradient(0deg, var(--color-divider) 0 1px, transparent 1px 32px),
    repeating-linear-gradient(90deg, var(--color-divider) 0 1px, transparent 1px 32px);
  opacity: 1;
  transition: opacity 0.45s ease;
  cursor: pointer;
}

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

/* CSS-only backstop: if project.js never loads, 404s, or throws before
   initIntro() registers its dismiss handlers, this sheet must still get out
   of the way on its own. JS's worst case is the 7000ms setTimeout fallback
   plus the 500ms removal fallback (~7.5s) — 9s delay leaves a margin so this
   never fires while JS is still in control. When JS does dismiss in time it
   removes the element outright, so the animation's delay never elapses and
   there's nothing for it to conflict with. */
.intro-sheet {
  animation: intro-sheet-backstop 0.01s linear 9s forwards;
}

@keyframes intro-sheet-backstop {
  to {
    opacity: 0;
    pointer-events: none;
  }
}

/* Reduced motion: JS's own reduceMotion branch dismisses instantly without
   ever running the wordmark wipe, so an instant CSS hide here matches that
   behavior rather than fighting it. */
@media (prefers-reduced-motion: reduce) {
  .intro-sheet {
    animation-delay: 0s;
  }
}

.intro-corner {
  position: absolute;
  width: 14px;
  height: 14px;
  border: 0 solid var(--color-accent-900);
  pointer-events: none;
}

.intro-corner.tl {
  top: 24px;
  left: 24px;
  border-top-width: 1px;
  border-left-width: 1px;
}

.intro-corner.tr {
  top: 24px;
  right: 24px;
  border-top-width: 1px;
  border-right-width: 1px;
}

.intro-corner.bl {
  bottom: 24px;
  left: 24px;
  border-bottom-width: 1px;
  border-left-width: 1px;
}

.intro-corner.br {
  bottom: 24px;
  right: 24px;
  border-bottom-width: 1px;
  border-right-width: 1px;
}

/* Counter-translate wipe: the wrapper's clip window slides -100% -> 0 while
   the image inside it slides 100% -> 0, so the two cancel and the letters
   stay put on screen — only the reveal edge moves, left to right, matching
   the original clip-path wipe instead of sliding the wordmark into place. */
.intro-wordmark-wrap {
  position: relative;
  width: min(88vw, 620px);
  height: auto;
  overflow: hidden;
  transform: translateX(-100%);
  animation: logoWipeWrap 3.6s cubic-bezier(0.16, 1, 0.3, 1) both;
  /* Paused until project.js confirms the image is decoded and paintable
     (img.decode()), so the wipe never starts against a blank frame. */
  animation-play-state: paused;
}

.intro-wordmark-wrap.is-running {
  animation-play-state: running;
  will-change: transform;
}

.intro-wordmark {
  display: block;
  width: 100%;
  height: auto;
  transform: translateX(100%);
  opacity: 0;
  animation: logoWipeImage 3.6s cubic-bezier(0.16, 1, 0.3, 1) both;
  animation-play-state: paused;
}

.intro-wordmark.is-running {
  animation-play-state: running;
  will-change: transform;
}

@keyframes logoWipeWrap {
  0% {
    transform: translateX(-100%);
  }
  100% {
    transform: translateX(0);
  }
}

@keyframes logoWipeImage {
  0% {
    transform: translateX(100%);
    opacity: 0;
  }
  12% {
    opacity: 1;
  }
  100% {
    transform: translateX(0);
    opacity: 1;
  }
}

@media (prefers-reduced-motion: reduce) {
  .tile {
    opacity: 1;
    animation: none;
  }

  .intro-wordmark-wrap {
    transform: translateX(0);
    animation: none;
  }

  .intro-wordmark {
    transform: translateX(0);
    opacity: 1;
    animation: none;
  }
}

/* Mobile: abandon the wall, plain vertical stack — source has no equivalent
   for narrow viewports, this is the port's own accessible fallback. */
@media (max-width: 640px) {
  html:has(.wall-page),
  html:has(.wall-page) body {
    height: auto;
    overflow: visible;
  }

  #main:has(.wall-page) {
    height: auto;
    padding-top: 0;
  }

  .wall-page {
    height: auto;
    margin-top: var(--nav-h);
  }

  .wall-scroll {
    position: static;
    overflow: visible;
    height: auto;
  }

  .wall-grid {
    display: flex;
    flex-direction: column;
    height: auto;
  }

  .tile--small,
  .tile--wide,
  .tile--tall,
  .tile--large {
    grid-column: auto;
    grid-row: auto;
    aspect-ratio: 4 / 3;
  }
}

/* base.css wraps the nav to two rows under 480px (96px tall vs 72px);
   must come after the 640px block above to win the cascade at this width. */
@media (max-width: 480px) {
  .wall-page {
    --nav-h: 96px;
  }
}

/* ============ Dialogs ============ */
/* .dialog-backdrop / .dialog / .corner come from base.css; only sizing here. */

.dialog-close {
  position: absolute;
  top: var(--space-2);
  right: var(--space-2);
  font-size: 20px;
  line-height: 1;
  z-index: 2;
}

/* ============ Project detail ============ */

/* No nav renders on this page (base.html's nav block is empty here), so the
   72px/96px reserved by base.css's #main is dead space — zero it, scoped to
   this page only via :has so the wall page's own nav clearance is untouched. */
#main:has(.detail-grid) {
  padding-top: 0;
}

.back-btn {
  position: fixed;
  top: var(--space-6);
  left: var(--space-6);
  z-index: var(--z-nav);
  display: flex;
  align-items: center;
  justify-content: center;
  width: 38px;
  height: 38px;
  background: var(--color-bg);
  border: 1px solid var(--color-divider);
  color: var(--color-text);
}

.back-btn:hover {
  color: var(--color-accent);
  border-color: var(--color-accent);
}

.detail-grid {
  display: grid;
  grid-template-columns: minmax(0, 1.6fr) minmax(300px, 1fr);
  gap: var(--space-8);
  max-width: 1360px;
  margin: 0 auto;
  padding: var(--space-10) var(--space-6) var(--space-12);
  align-items: start;
}

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

.detail-left {
  display: flex;
  flex-direction: column;
  gap: var(--space-12);
  min-width: 0;
}

.section-empty {
  margin: 0;
}

/* Overview */
.overview-viewer {
  aspect-ratio: 16 / 10;
  background: var(--color-surface);
}

/* .blueprint draws its registration corners outside the box (negative
   offsets); clipping has to happen on this inner layer instead of the
   .blueprint element itself, or the corners get cut off with the image. */
.overview-viewer-clip {
  position: absolute;
  inset: 0;
  overflow: hidden;
}

.viewer-image {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transform: scale(var(--z, 1));
  transition: transform 0.7s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.18s ease;
}

.overview-viewer:hover .viewer-image {
  --z: 1.05;
}

.viewer-image.is-fading {
  opacity: 0;
}

.viewer-label {
  margin: var(--space-2) 0 0;
  font-size: 11px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  opacity: 0.5;
  min-height: 1.4em;
}

.thumb-strip {
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  gap: var(--space-2);
  margin-top: var(--space-4);
  padding: 0;
  border: 0;
  background: none;
}

.thumb {
  aspect-ratio: 1 / 1;
  padding: 0;
  background: var(--color-surface);
  border: 1px solid var(--color-divider);
  cursor: pointer;
}

.thumb-clip {
  position: absolute;
  inset: 0;
  overflow: hidden;
}

.thumb:hover {
  --z: 1.1;
}

.thumb.is-active {
  border-color: var(--color-accent);
}

.thumb-image {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transform: scale(var(--z, 1));
  transition: transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}

/* Gallery */
.gallery-grid {
  columns: 3 320px;
  column-gap: var(--space-4);
}

.gallery-item {
  display: block;
  width: 100%;
  margin: 0 0 var(--space-4);
  break-inside: avoid;
  padding: 0;
  background: var(--color-surface);
  border: 1px solid var(--color-divider);
  cursor: pointer;
}

.gallery-item:hover,
.gallery-item:focus-visible {
  border-color: var(--color-accent);
}

.gallery-image {
  display: block;
  width: 100%;
  height: auto;
}

/* Gallery modal */
.gallery-modal {
  width: min(1100px, calc(100vw - var(--space-8)));
  max-height: calc(100vh - var(--space-8));
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-3);
  /* Blueprint's .dialog rule makes the surface transparent; the modal needs
     a real one so page content behind it doesn't show through. */
  background: var(--color-bg);
}

.gallery-modal[hidden] {
  display: none;
}

.gallery-modal-clip {
  position: relative;
  width: 100%;
  max-height: calc(100vh - 220px);
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  background: var(--color-surface);
}

.gallery-modal-image {
  max-width: 100%;
  max-height: calc(100vh - 220px);
  width: auto;
  height: auto;
  object-fit: contain;
}

.gallery-modal-nav {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  z-index: 1;
  background: var(--color-bg);
}

.gallery-modal-prev {
  left: var(--space-3);
}

.gallery-modal-next {
  right: var(--space-3);
}

.gallery-modal-nav:disabled {
  opacity: 0.3;
  cursor: default;
  pointer-events: none;
}

.gallery-modal-caption {
  margin: 0;
  font-size: 14px;
  text-align: center;
  max-width: 60ch;
}

.gallery-modal-caption[hidden] {
  display: none;
}

.gallery-modal-counter {
  margin: 0;
  font-size: 11px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--color-accent-700);
}

@keyframes gallerySlideFromRight {
  from {
    opacity: 0;
    transform: translateX(24px);
  }
  to {
    opacity: 1;
    transform: translateX(0);
  }
}

@keyframes gallerySlideFromLeft {
  from {
    opacity: 0;
    transform: translateX(-24px);
  }
  to {
    opacity: 1;
    transform: translateX(0);
  }
}

.gallery-modal-image.is-sliding-next {
  animation: gallerySlideFromRight 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
}

.gallery-modal-image.is-sliding-prev {
  animation: gallerySlideFromLeft 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
}

body.is-modal-open {
  overflow: hidden;
}

/* Meta rail */
.detail-rail {
  position: sticky;
  top: var(--space-8);
  align-self: start;
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
  opacity: 0;
  transform: translateY(14px);
  animation: pageIn 0.7s cubic-bezier(0.16, 1, 0.3, 1) both;
}

.detail-rail h1 {
  margin: 0;
  font-size: clamp(28px, 2.6vw, 46px);
}

.rail-description {
  margin: 0;
  font-size: 15px;
  line-height: 1.7;
  opacity: 0.8;
  max-width: 40ch;
  min-height: 2.4em;
}

.rail-meta {
  display: grid;
  grid-template-columns: repeat(3, auto);
  gap: var(--space-5);
  padding-top: var(--space-4);
  border-top: 1px solid var(--color-divider);
}

.rail-meta-item {
  display: flex;
  flex-direction: column;
  gap: 3px;
}

.rail-label {
  display: block;
  font-size: 10px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--color-accent-700);
}

.rail-value {
  display: block;
  font-size: 14px;
}

@media (prefers-reduced-motion: reduce) {
  .detail-rail {
    opacity: 1;
    transform: none;
    animation: none;
  }
}

@media (max-width: 900px) {
  .detail-grid {
    grid-template-columns: 1fr;
  }

  .detail-rail {
    position: static;
    order: -1;
  }
}

@media (max-width: 640px) {
  .detail-grid {
    padding: var(--space-8) var(--space-4) var(--space-10);
    gap: var(--space-6);
  }

  .thumb-strip {
    grid-template-columns: repeat(3, 1fr);
  }

  .gallery-grid {
    columns: 1;
  }

  .gallery-modal {
    padding: var(--space-4);
    gap: var(--space-2);
  }

  .gallery-modal-clip,
  .gallery-modal-image {
    max-height: calc(100vh - 180px);
  }
}
