@font-face {
  font-family: "PPMori";
  src: url("fonts/PPMori-Regular.otf") format("opentype");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "PPMori";
  src: url("fonts/PPMori-RegularItalic.otf") format("opentype");
  font-weight: 400;
  font-style: italic;
  font-display: swap;
}

@font-face {
  font-family: "PPMori";
  src: url("fonts/PPMori-Semibold.otf") format("opentype");
  font-weight: 600;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "PPMori";
  src: url("fonts/PPMori-SemiboldItalic.otf") format("opentype");
  font-weight: 600;
  font-style: italic;
  font-display: swap;
}

@font-face {
  font-family: "PPNeueBit";
  src: url("fonts/PPNeueBit-Regular.otf") format("opentype");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
  /* PPNeueBit's own metrics table gives browsers a line box padded out
     to the full em square (usWinAscent 3365 + usWinDescent 731 = 4096,
     exactly unitsPerEm) rather than the glyphs' real bounds — that's
     the "extraneous space," baked into the font file itself, showing up
     as bottom (and top) padding anywhere this font is used, no matter
     what line-height is set in CSS. These override it with the font's
     own tighter "typo" metrics (sTypoAscender 2048 / sTypoDescender -585
     / sTypoLineGap 73, as fractions of the 4096 unitsPerEm) instead —
     fixed once, here, rather than as a per-element padding tweak
     wherever --font-mono shows up. */
  ascent-override: 65%;
  descent-override: 14.29%;
  line-gap-override: 1.78%;
}

@font-face {
  font-family: "PPNeueBit";
  src: url("fonts/PPNeueBit-Bold.otf") format("opentype");
  font-weight: 700;
  font-style: normal;
  font-display: swap;
  /* Same metrics table as PPNeueBit-Regular (identical hhea/OS2 values
     in both files), so the same overrides apply here. */
  ascent-override: 65%;
  descent-override: 14.29%;
  line-gap-override: 1.78%;
}

@font-face {
  font-family: "PPNikkeiLine";
  src: url("fonts/PPNikkeiLine-Thin.otf") format("opentype");
  font-weight: 100;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "PPNikkeiLine";
  src: url("fonts/PPNikkeiLine-ThinItalic.otf") format("opentype");
  font-weight: 100;
  font-style: italic;
  font-display: swap;
}

@font-face {
  font-family: "PPNikkeiLine";
  src: url("fonts/PPNikkeiLine-Light.otf") format("opentype");
  font-weight: 300;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "PPNikkeiLine";
  src: url("fonts/PPNikkeiLine-LightItalic.otf") format("opentype");
  font-weight: 300;
  font-style: italic;
  font-display: swap;
}

@font-face {
  font-family: "PPNikkeiLine";
  src: url("fonts/PPNikkeiLine-Regular.otf") format("opentype");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "PPNikkeiLine";
  src: url("fonts/PPNikkeiLine-RegularItalic.otf") format("opentype");
  font-weight: 400;
  font-style: italic;
  font-display: swap;
}

@font-face {
  font-family: "PPNikkeiLine";
  src: url("fonts/PPNikkeiLine-Heavy.otf") format("opentype");
  font-weight: 800;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "PPNikkeiLine";
  src: url("fonts/PPNikkeiLine-HeavyItalic.otf") format("opentype");
  font-weight: 800;
  font-style: italic;
  font-display: swap;
}

@font-face {
  font-family: "PPNikkeiLine";
  src: url("fonts/PPNikkeiLine-Ultrabold.otf") format("opentype");
  font-weight: 900;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "PPNikkeiLine";
  src: url("fonts/PPNikkeiLine-UltraboldItalic.otf") format("opentype");
  font-weight: 900;
  font-style: italic;
  font-display: swap;
}

:root {
  --bg: #000000;
  --fg: rgba(255, 255, 255, 1);
  --fg-dim: rgba(255, 255, 255, 0.6);
  --border: rgba(255, 255, 255, 0.85);
  --gap-sm: 10px;
  --gap-lg: 60px;
  --content-width: clamp(1360px, 100vw, 1920px);
  --grid-columns-work: 4;
  --grid-columns-footer: 4;
  --radius-media: 0px;
  --font-display: "PPMori", "Helvetica Neue", Arial, sans-serif;
  --font-mono: "PPNeueBit", ui-monospace, "SF Mono", Menlo, monospace;
  --font-nikkei-line: "PPNikkeiLine", "Helvetica Neue", Arial, sans-serif;
}

/* Light mode — a straight foreground/background inversion of the
   :root defaults above, toggled by adding/removing this class on
   <html> (see initThemeToggle in script.js, and the anti-flash inline
   script in each page's <head> that applies it before first paint from
   the visitor's last choice in localStorage). Everything sitewide that
   reads --bg/--fg/--border picks this up automatically; the handful of
   places that don't (the password gate's own always-dark glass look,
   and the logo/row-nav SVGs, which are external image files rather
   than inline currentColor-based ones) are called out and handled
   separately, further down this file. */
html.is-light {
  --bg: #ffffff;
  --fg: rgba(0, 0, 0, 1);
  --fg-dim: rgba(0, 0, 0, 0.6);
  --border: rgba(0, 0, 0, 0.85);
}

/* Light/dark circle wipe (see initThemeToggle in script.js). The browser's
   default cross-fade between the old and new snapshots is turned off; the
   script grows a soft-edged circular mask on the new snapshot instead. */
:root {
  --theme-wipe-duration: .9s;
  --theme-wipe-easing: cubic-bezier(0.55, 0, 0.45, 1);
  /* 0 = the site's color fades are paused during the wipe (crisp circle);
     1 = they keep running, so the inside of the circle fades into the new
     colors over the first ~0.3s. */
  --theme-wipe-fades: 0;
  /* Width of the soft edge on the growing circle (0px = hard edge). */
  --theme-wipe-feather: 2000px;
}

::view-transition-old(root),
::view-transition-new(root) {
  animation: none;
  mix-blend-mode: normal;
}

/* The circle is a radial-gradient mask: solid inside, fading to
   transparent over --theme-wipe-feather at the rim. The script grows it by
   animating mask-size/mask-position around the toggle; it starts at 1px so
   the new view is hidden until the animation begins. */
::view-transition-new(root) {
  -webkit-mask-image: radial-gradient(closest-side, #000 calc(100% - var(--theme-wipe-feather)), transparent 100%);
  mask-image: radial-gradient(closest-side, #000 calc(100% - var(--theme-wipe-feather)), transparent 100%);
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  -webkit-mask-size: 1px 1px;
  mask-size: 1px 1px;
}

html.is-theme-wiping,
html.is-theme-wiping * {
  transition: none !important;
}

* { box-sizing: border-box; }

/* Utility — forces every transition off within this subtree. Used by
   script.js whenever it needs to measure a real, transitioned layout
   value (e.g. .site-header's compact height) synchronously: reading
   getBoundingClientRect() right after toggling a class that a
   transition is watching still reports the *pre*-transition layout
   (transitions only animate across actual paint frames, and a value
   read in the same script tick that triggered the change hasn't had
   one yet) — .no-transition sidesteps that by making the change land
   instantly instead, for however briefly it's applied. */
.no-transition,
.no-transition * {
  transition: none !important;
}

html, body {
  margin: 0;
  padding: 0;
  background: var(--bg);
  color: var(--fg);
  font-family: var(--font-display);
  transition: background-color 0.3s ease-in-out, color 0.3s ease-in-out;
}

@keyframes page-fade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

body {
  animation: page-fade-in 0.9s ease-in-out;
}

body.is-leaving {
  opacity: 0;
  transition: opacity 0.9s ease-in-out;
}

@media (prefers-reduced-motion: reduce) {
  html, body {
    transition: none;
  }
  body {
    animation: none;
  }
  body.is-leaving {
    transition: none;
  }
}

/* Scroll reveal — fades content up into place, row by row, as it enters
   the viewport. Shared by the homepage grid/footer and the project page.
   One-shot: once revealed, an element stays revealed (see
   initScrollReveal in script.js). Gallery rows have their own,
   continuous version of this just below. */
/* .content-row, — old gallery only, see the commented .work-grid block in index.html */
.work-gallery .card,
.footer-row,
.project-meta,
.project-credits,
.project-nav {
  opacity: 0;
  transform: translateY(60px);
  transition: opacity 0.3s ease-in-out, transform 0.45s ease-in-out;
}

/* .content-row.is-visible, — old gallery only */
.work-gallery .card.is-visible,
.footer-row.is-visible,
.project-meta.is-visible,
.project-credits.is-visible,
.project-nav.is-visible {
  opacity: 1;
  transform: translateY(0);
}

@media (prefers-reduced-motion: reduce) {
  /* .content-row, — old gallery only */
  .work-gallery .card,
  .footer-row,
  .project-meta,
  .project-credits,
  .project-nav {
    opacity: 1;
    transform: none;
    transition: none;
  }
}

/* Gallery-row fade — unlike the one-shot reveal above, this one keeps
   toggling for as long as the page is scrolled: a row fades out again
   once it leaves the viewport, and back in when it re-enters (see
   initGalleryRowFade in script.js). Opacity only, no slide, since the
   motion needs to read the same leaving in either direction. */
.project-gallery > .gallery-media,
.project-gallery > .gallery-row {
  opacity: 0;
  transition: opacity 0.65s ease-in-out;
}

.project-gallery > .gallery-media.is-visible,
.project-gallery > .gallery-row.is-visible {
  opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
  .project-gallery > .gallery-media,
  .project-gallery > .gallery-row {
    opacity: 1;
    transition: none;
  }
}

a { color: inherit; text-decoration: none; }
ul { margin: 0; padding: 0; list-style: none; }
p { margin: 0; }

.page {
  max-width: var(--content-width);
  margin: 0 auto;
  padding: 0px 20px 20px;
}

/* Header */

.site-header {
  position: sticky;
  top: 0;
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: space-between;
  column-gap: 20px;
  row-gap: 0;
  flex-wrap: wrap;
  padding: 20px 0px 16px;
  /*background: var(--bg);
  border-bottom: 1px solid transparent;*/
  transition: border-color 0.3s ease-in-out;
}

.site-header.is-scrolled {
  border-bottom-color: var(--fg);
}

#inquiries {
  scroll-margin-top: 140px;
}

/* #work and #about land flush against .site-header's bottom edge (in its
   scrolled/compact state): padding-top 20px + logo height (its scrolled
   font-size, 3.5vw, at line-height: 1) + padding-bottom 20px. The
   header's own bottom padding is what creates the visual 20px gap from
   its content — the target itself has no extra gap added. This is a
   fallback for no-JS/native fragment navigation — script.js measures
   the header's real rendered height live and corrects the landing
   position after the fact. */
#work,
#about {
  scroll-margin-top: calc(20px + 3.5vw + 20px);
}

/* Live text (PPNikkeiLine Ultrabold) rather than assets/logo.svg — see
   .logo-mark.logo-mark--footer below for the footer's own big closing
   wordmark, the same swap. font-size here (and in
   .site-header.is-scrolled .logo-mark, and the tablet/mobile
   equivalents of both) is an approximation of the old SVG's rendered
   width at each breakpoint/scroll state; being text now rather than a
   fixed-aspect-ratio image, it won't match pixel-for-pixel and may
   want a visual pass to tune. */
.logo-mark {
  display: block;
  font-family: var(--font-nikkei-line);
  font-weight: 800;
  font-size: 4.5vw;
  line-height: 1;
  letter-spacing: 0.01em;
  white-space: nowrap;
  color: var(--fg);
  transition:
    font-size 0.1s ease-in-out,
    opacity 0.1s ease-in-out;
}

/* Hover dim removed (replaced by the hover chroma effect on clickable
   text — see initHoverChroma in script.js).
.logo-mark:hover {
  opacity: 0.8;
}
*/

.tagline {
  flex-basis: 100%;
  order: 2;
  font-family: var(--font-mono);
  font-weight: 400;
  font-size: 2vw;
  letter-spacing: 0.04em;
  margin-top: 0px;
  max-height: 60px;
  overflow: hidden;
  transition:
    opacity 0.2s ease-in-out,
    transform 0.2s ease-in-out,
    max-height 0.2s ease-in-out,
    margin-top 0.2s ease-in-out;
}

/* Matches statement/selected work/services/clients/industries' own
   shared 3.5vw/5vw/7vw responsive scale (see .footer-row--about
   .statement and the tablet/mobile breakpoints further down this
   file), rather than a size specific to the logo. */
.site-header.is-scrolled .logo-mark {
  font-size: 3.5vw;
}

.site-header.is-scrolled .tagline {
  opacity: 0;
  transform: translateY(-12px);
  max-height: 0;
  margin-top: 0;
}

.nav-pills {
  display: flex;
  gap: 12px;
  align-items: center;
}

.pill {
  /* background/color/appearance/cursor: only ever needed once a <button>
     (rather than .pill's original <a>) started using this class (see
     .credits-see-all) — a UA stylesheet gives <button> its own opaque
     background and system font/cursor that .pill never had to override
     before, showing up as an unwanted white fill. Harmless no-ops on
     the existing <a class="pill"> usages, which already rendered this
     way by default. */
  display: inline-block;
  background: none;
  color: var(--fg);
  appearance: none;
  cursor: pointer;
  font-family: var(--font-mono);
  font-weight: 700;
  font-size: 20px;
  letter-spacing: 0.05em;
  line-height: .8;
  text-transform: uppercase;
  border: 1px solid var(--border);
  border-radius: 40px;
  padding: 8px 18px 8px;
  transition: background 0.15s ease, color 0.15s ease;
}

/* Browsers that ignore the PPNeueBit metric overrides (see the @font-face
   block at the top of this file) lay pill text out with the font's own,
   shorter metrics, which leaves the caps about 1px high in a 20px pill
   (measured in Safari: 11px above, 13px below). initFontMetricFallback
   in script.js adds this class when it detects that case; the padding
   shift moves the text down by that amount while keeping the pill's
   total height unchanged. */
html.no-font-metric-override .pill {
  padding-top: calc(8px + 0.05em);
  padding-bottom: calc(8px - 0.05em);
}

.pill:hover {
  background: var(--fg);
  color: var(--bg);
}

/* Light/dark toggle — last item in .nav-pills (see initThemeToggle in
   script.js). A plain filled circle rather than an outlined pill like
   its siblings, sized to match their own rendered height exactly
   (.pill's font-size 20px × line-height .8 = 16px, + 8px top/bottom
   padding + 1px top/bottom border = 34px). background: var(--fg) does
   the white-in-dark-mode/black-in-light-mode swap for free — it's
   already exactly what --fg is in each mode, no separate light-mode
   rule needed here. */
.theme-toggle {
  width: 34px;
  height: 34px;
  flex-shrink: 0;
  padding: 0;
  border: none;
  border-radius: 50%;
  background: var(--fg);
  cursor: pointer;
  transition: opacity 0.15s ease;
}

/* Hover dim removed (replaced by the hover chroma effect on clickable
   text — see initHoverChroma in script.js).
.theme-toggle:hover {
  opacity: 0.8;
}
*/

/* Hero — homepage-only, madlibs-style headline (see initHeroShuffle in
   script.js). Each of the 4 lines is an independent word/phrase pulled
   at random from its own column of options, re-rolled on load and again
   whenever .hero__shuffle is clicked. font-size below is the no-JS
   fallback (and the pre-fit flash of content); initHeroShuffle
   overrides it per line with an inline style, scaled so the longest
   line's natural (nowrap) width always lands at 3 of the site's
   4-column grid. white-space: nowrap is load-bearing for that fit — it
   also guards a line against wrapping if the fitted size is ever
   slightly off. */
/* Fades continuously with scroll position (initHeroFade in script.js) —
   opacity 1 whenever at least 20% of .hero is vertically visible,
   fading toward 0 as it scrolls out either edge below that. */
.hero {
  padding-top: 2vw;
  padding-bottom: 40px;
  opacity: 0;
  transition: opacity 0.3s ease-in-out;
}

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

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

.hero__headline {
  position: relative;
  display: flex;
  flex-direction: column;
  /* Without this, flex's default align-items: stretch makes each
     .hero__line's box fill the full container width regardless of its
     font-size or text — which breaks initHeroShuffle's fit-to-width
     measurement (getBoundingClientRect on a stretched box just reports
     the container's width, not the text's actual rendered width). This
     keeps each line's box shrink-wrapped to its own (nowrap) content. */
  align-items: flex-start;
  /* height is set inline by initHeroShuffle (reserveHeadlineHeight),
     reserved for the tallest the 4 lines could ever render at the
     current viewport — so this box's own height never changes as the
     shuffle picks longer or shorter words. justify-content: flex-end
     bottom-anchors the (usually shorter) actual text within it, so it
     grows upward into the reserved space above rather than pushing
     .hero__shuffle or anything after .hero around. */
  justify-content: flex-end;
}

/* Each line re-rolls just that word on hover (initHeroShuffle) — click
   under prefers-reduced-motion, where there's no spin animation to
   trigger on hover. cursor + hover dim signal the interactivity either
   way, matching the hover treatment used elsewhere on the site (e.g.
   .logo-mark). */
.hero__line {
  position: relative;
  margin: 0;
  font-family: var(--font-nikkei-line);
  font-weight: 900;
  font-size: 9.75vw;
  line-height: 0.75;
  letter-spacing: -0.01em;
  text-transform: uppercase;
  white-space: nowrap;
  cursor: pointer;
  transition: opacity 0.15s ease-in-out;
}

/* Hover dim removed (replaced by the hover chroma effect on clickable
   text — see initHoverChroma in script.js).
.hero__line:hover {
  opacity: 0.8;
}
*/

/* Slot-machine spin (initHeroShuffle): while flickering through
   throwaway words, the real text (.hero__line-text) is swapped for a
   genuinely low-resolution canvas raster of the same word — scaled back
   up with image-rendering: pixelated so each low-res "pixel" reads as a
   visible chunky block — rather than a CSS filter trying to fake a
   blocky look on top of the full-resolution glyphs (blur+contrast just
   re-sharpens the same smooth shape; there's no fine detail in solid
   text for it to break into cells). --hero-spin-blur layers an optional
   motion-blur on top of that pixelated raster; 0px leaves the
   pixelation showing on its own. */
.hero__line-text {
  visibility: visible;
}

.hero__line--spinning .hero__line-text {
  visibility: hidden;
}

.hero__line-canvas {
  display: none;
  position: absolute;
  left: 0;
  /* Sized taller than .hero__line's own tight line-height box (see
     drawPixelatedLine's GLYPH_HEIGHT_RATIO in script.js) to hold the
     font's real glyph bounds without clipping them — centered here
     rather than top-aligned so that extra height grows evenly
     above/below instead of pushing the glyphs down. */
  top: 50%;
  transform: translateY(-50%);
  pointer-events: none;
  image-rendering: pixelated;
  image-rendering: -moz-crisp-edges;
  --hero-spin-blur: 0px;
  filter: blur(var(--hero-spin-blur));
}

.hero__line--spinning .hero__line-canvas {
  display: block;
}

/* The actual hover/click target for the line (see hitTargets in
   initHeroShuffle) — shorter than the line's own box and vertically
   centered within it, rather than the box's full height, so a mouse
   passing through the gap between two adjacent lines lands in dead
   space instead of grazing both and shuffling them together. */
.hero__line-hit {
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  height: 80%;
  transform: translateY(-50%);
}

@media (prefers-reduced-motion: reduce) {
  .hero__line-canvas {
    filter: none;
  }
}

/* .pill's own look (border, radius, padding, font, hover-invert) plus
   .hero__shuffle just for its icon+label layout and positioning: flush
   with the bottom edge of .hero__headline's own box — and since that
   box now has a fixed, viewport-driven height (not a content-driven
   one), this position is genuinely stable across shuffles, not just
   visually close. Hidden on desktop (hovering a word already shuffles
   it there — see initHeroShuffle in script.js — so the button is
   redundant); shown again at tablet and below (see that breakpoint
   further down this file), where it stacks under the text, left-
   aligned, instead of sitting in the corner, and still works as a
   click-to-shuffle-all fallback since hover isn't a touch gesture. */
.hero__shuffle {
  display: none;
  position: absolute;
  right: 0;
  bottom: 14px;
  padding: 12px 12px 12px;
  align-items: center;
  gap: 8px;
  background: none;
  color: var(--fg);
  cursor: pointer;
  transition: background 0.15s ease, color 0.15s ease;
}

/* The icon spins continuously and unconditionally (not gated on hover —
   that version only reliably played the first time: each hover-out
   dropped the animation with no fill-mode, and a new one starting
   mid-way through the leftover transform transition could snapshot an
   odd angle as its implicit "from", so later hovers could visually stall).
   Not the whole pill, just the icon. The icon is inline SVG (fill:
   currentColor) rather than an <img>, so it recolors along with the
   pill's own text on .pill:hover's background/color invert instead of
   staying stuck white against a now-white background. */
@keyframes hero-shuffle-spin {
  to {
    transform: rotate(360deg);
  }
}

.hero__shuffle-icon {
  display: block;
  width: 17px;
  height: 14px;
  flex-shrink: 0;
  animation: hero-shuffle-spin 0.8s linear infinite;
}

@media (prefers-reduced-motion: reduce) {
  .hero__shuffle {
    transition: background 0.15s ease, color 0.15s ease;
  }

  .hero__shuffle-icon {
    animation: none;
  }
}

/* Selected Work gallery (see PROJECTS / initWorkGallery /
   initWorkGallerySticky in script.js, and the markup notes in
   index.html) — third version of this section. .work-gallery__info
   starts showing only its <h2>; its .project-meta-group fields
   cross-fade in on .is-revealed (hovering/focusing a grid item — see
   initWorkGallery). Reuses .card/.card-link/.media/.caption/
   .project-name/.project-tags from the old card grid further down this
   file (still live — index2.html, the untouched original, still uses
   it) rather than duplicating that styling; every card here is the
   same 325/376 size, so none of .card--wide/--narrow apply. */
.work-gallery {
  display: flex;
  flex-direction: column;
  padding-bottom: 40px;
}

/* A dedicated full-width element for the border above the gallery,
   rather than putting it on .work-gallery__info or .work-gallery__row
   — it needs to span both columns (unlike .work-gallery__info, which
   is only as wide as column 1) and stay stuck the whole time
   .work-gallery__info does (unlike .work-gallery__row, which can't
   itself be sticky without taking the full-height media grid along
   with it — see .work-gallery__row below). It's a sibling of
   .work-gallery__row instead of a child of it for exactly that reason:
   as a direct child of .work-gallery (the gallery's full height, top
   to bottom), its own containing block for position: sticky purposes
   is that full height too, so it un-sticks at the same point
   .work-gallery__info does. initWorkGallerySticky sets its top to the
   same value as .work-gallery__info's. */
.work-gallery__line {
  position: sticky;
  top: 90px; /* see .work-gallery__info's own top for why this default */
  height: 0;
  border-top: 1px solid var(--border);
  /* Without this, the grid's cards — later in DOM order, so painted on
     top by default at equal (auto) stacking level — would scroll right
     over this line once it's stuck rather than staying visibly under
     it. */
  z-index: 2;
}

/* The site's regular 4-column content grid (same --grid-columns-work/
   var(--gap-sm) the footer's own .footer-row uses), rather than a
   flex row with its own hand-picked gap — that was landing this
   section's column boundaries out of step with the footer's below it.
   .work-gallery__info takes column 1; .work-gallery__grid spans
   columns 2-4 and, internally, is its own 3-column grid using the same
   var(--gap-sm) column-gap — since it occupies exactly 3 of this
   grid's tracks plus the 2 gaps between them, using that identical gap
   value makes its own 3 columns land pixel-for-pixel on this grid's
   columns 2/3/4, without needing CSS subgrid. */
.work-gallery__row {
  display: grid;
  grid-template-columns: repeat(var(--grid-columns-work), 1fr);
  gap: var(--gap-sm);
  align-items: flex-start;
  padding-top: 30px;
}

.work-gallery__info {
  grid-column: span 1;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 20px;
  /* position: sticky's required top — a reasonable static estimate
     until initWorkGallerySticky sets the real, measured value (20px
     below the header, plus .work-gallery__row's own 30px padding-top,
     which sticky positioning stops honoring once actually stuck)
     inline. */
  position: sticky;
  top: 120px;
  /* Same reasoning as .work-gallery__line's own z-index above — this
     panel's text needs to stay above the grid's cards scrolling past
     underneath it too. */
  z-index: 2;
}

.work-gallery__info h2 {
  margin: 0;
  font-family: var(--font-nikkei-line);
  font-weight: 800;
  font-size: 3.5vw;
  letter-spacing: 0.02em;
  line-height: .82em;
  text-transform: uppercase;
  padding-bottom: 10px;
}

/* Cross-fades in/out (see initWorkGallery's show()/FADE_HALF in
   script.js, which times a text swap to land at the invisible midpoint
   of this transition) rather than snapping — opacity only, so the
   panel's layout height stays reserved (and stable) even before the
   first hover, instead of the heading alone growing the fields in
   underneath it. */
.work-gallery__info .project-meta-group {
  opacity: 0;
  transition: opacity 0.3s ease-in-out;
  padding-right: 3vw;
}

.work-gallery__info.is-revealed .project-meta-group {
  opacity: 1;
}

.work-gallery__info.is-fading .project-meta-group {
  opacity: 0;
}

@media (prefers-reduced-motion: reduce) {
  .work-gallery__info .project-meta-group {
    transition: none;
  }
}

/* A plain CSS Grid (rather than rows of 3 built in the DOM — see
   buildWorkGalleryGrid in script.js) so the column count can just
   change per breakpoint (3/2/1 — see the media queries further down
   this file) without leaving a dangling gap next to whatever card
   happened to end a hand-grouped row alone at a narrower column count,
   and so every card-to-card gap, in both directions, is the same
   single value instead of the row-grouping wrapper's own gap and the
   grid's between-wrappers gap silently differing. */
.work-gallery__grid {
  grid-column: span 3;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  /* Column-gap matches .work-gallery__row's var(--gap-sm) exactly —
     see the comment on that rule for why that's what keeps these 3
     columns aligned with the footer's 4-column grid below. Row-gap
     (40px) is unrelated to alignment, just this grid's own vertical
     rhythm. */
  gap: 40px var(--gap-sm);
  min-width: 0;
}

.work-gallery .media {
  aspect-ratio: 670 / 764;
}

/* Oversized by 1px on every side so the rounded, overflow-hidden .media
   always clips it: at fractional card sizes Safari can leave a hairline of
   the background showing along a video's left/bottom edge. */
.work-gallery .media video {
  width: calc(100% + 2px);
  height: calc(100% + 2px);
  margin: -1px;
}

/* Shown only at mobile (see the breakpoint further down this file),
   where there's no hover state and so no info panel to show these in
   instead. */
.work-gallery .project-tags {
  display: none;
}

/* Superseded by the gallery above — kept only because index2.html (the
   untouched original) still uses it; not otherwise reachable from
   index.html any more (its own copy of this markup, further down this
   file, is commented out alongside the carousel that preceded it). */
.work-grid {
  display: flex;
  flex-direction: column;
  gap: var(--gap-lg);
  border-top: 1px solid var(--border);
  padding-top: 30px;
  padding-bottom: 40px;
}

.content-row {
  display: grid;
  grid-template-columns: repeat(var(--grid-columns-work), 1fr);
  gap: var(--gap-sm);
  align-items: start;
}

.col-start-2 { grid-column-start: 2; }

.card {
  display: flex;
  flex-direction: column;
  gap: 16px;
  min-width: 0;
}

.card-link {
  display: flex;
  flex-direction: column;
  gap: 16px;
  min-width: 0;
}

.card--wide { grid-column-end: span 2; }
.card--narrow { grid-column-end: span 1; }

.media {
  position: relative;
  width: 100%;
  aspect-ratio: 670 / 376;
  overflow: hidden;
  border-radius: var(--radius-media);
  background: #1a1a1a;
}

.card--narrow .media { aspect-ratio: 325 / 376; }

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

.media::after {
  content: "";
  position: absolute;
  inset: 0;
  background: #ffffff;
  opacity: 0;
  transition: opacity 0.3s ease-in-out;
  pointer-events: none;
}

/* Hover dim removed (replaced by the hover chroma effect on clickable
   text — see initHoverChroma in script.js).
.media:hover::after {
  opacity: 0.3;
}
*/

/* Selected Work gallery only (see initWorkGallery in script.js, which
   pauses/resumes the video itself on hover instead) — the plain
   white-wash hover above stays as-is for that grid's static images,
   and for every other .media (the old, commented-out grid; index2.html,
   the untouched original) regardless of image or video. */
/* Hover dim removed (replaced by the hover chroma effect on clickable
   text — see initHoverChroma in script.js).
.work-gallery .media:has(video):hover::after {
  opacity: 0;
}
*/

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

.caption { display: flex; flex-direction: column; gap: 4px; padding-right: 3vw;}

.project-name {
  display: inline-block;
  width: max-content;
  max-width: 100%;
  font-weight: 400;
  font-size: 20px;
  letter-spacing: 0.02em;
}

.project-name .letter {
  display: inline-block;
}

@keyframes letter-wave {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(-6px); }
}

.card-link .media:hover ~ .caption .project-name .letter,
.card-link .project-name:hover .letter {
  animation: letter-wave 0.7s ease-in-out infinite;
  animation-delay: calc(var(--letter-index, 0) * 0.1s);
  opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
  .card-link .media:hover ~ .caption .project-name .letter,
  .card-link .project-name:hover .letter {
    animation: none;
  }
}

/* .work-select__name:hover .letter shared this same letter-wave
   animation — commented out along with the rest of .work-select
   further down this file. */

.project-tags {
  font-family: var(--font-mono);
  font-weight: 400;
  font-size: 30px;
  letter-spacing: 0.04em;
  line-height: .8em;
  opacity: 1;
}

/* Selected Work — auto-advancing carousel, reverted in favor of the
   original card grid above (see "Work grid" further up this file, plus
   index.html and script.js, where this same carousel is commented out
   too — search script.js for WORK_PROJECTS / initWorkSelect). Kept,
   commented out rather than removed, for easy re-enable. Each rule
   below is individually wrapped (rather than one single comment
   spanning all of them) since CSS comments can't nest and several of
   these rules have their own inline explanatory comments. */
/* .work-select {
  display: flex;
  flex-direction: column;
  border-top: 1px solid var(--border);
  padding-top: 30px;
} */

/* Also used by the "Selected Work" carousel above (before it was
   commented out), and shares its look with the other .footer-row
   headings (Services, Industries) further down this file. */
.footer-row--works h2 {
  margin: 0;
  font-family: var(--font-nikkei-line);
  font-weight: 800;
  font-size: 3.5vw;
  letter-spacing: 0.02em;
  line-height: .8em;
  text-transform: uppercase;
}

/* Bottom-aligned the meta panel (+ its View Project pill) against the
   media beside it, rather than both starting flush at the top — media
   is taller, so this was what actually read as "aligned." */
/* .work-select__content {
  display: grid;
  grid-template-columns: repeat(var(--grid-columns-work), 1fr);
  column-gap: var(--gap-sm);
  row-gap: 20px;
  align-items: end;
  padding-top: 30px;
} */

/* Full width at every breakpoint, wrapping into as many lines as it
   needs — .project-name's own width: max-content keeps each name
   shrink-wrapped to its content so a wrapped line's names don't stretch
   apart from each other. */
/* .work-select__names {
  grid-column: 1 / -1;
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-start;
  align-items: flex-start;
  row-gap: 8px;
  column-gap: 20px;
}

.work-select__name {
  font-weight: 400;
  opacity: 1;
  transition: opacity 0.5s ease-in-out;
}

.work-select__name.is-active {
  opacity: 1;
} */

/* 3 of the site's 4 base grid columns at desktop, matched by
   .work-select__meta's 1 — the tablet/mobile breakpoints (also
   commented out, further down this file) overrode both. */
/* .work-select__media {
  grid-column: span 3;
  padding-right: 10px;
} */

/* Client/year/services/description + a View Project pill. Same
   .project-meta-group/.meta-label/.meta-value markup as the project
   pages' own .project-meta (see below), including its DOM order
   (Client, Year, Services, Description) — the Client/Description/
   Services/Year reading order shown here at desktop was a flex `order`
   reorder on top of that shared markup via the --client/--year/
   --services/--description modifier classes, not a different element
   order, so the tablet/mobile layouts (which reused .project-meta's own
   responsive rules) didn't need a second, differently-ordered markup. */
/* .work-select__meta {
  grid-column: span 1;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 20px;
}

.work-select__meta .project-meta-group--client { order: 1; }
.work-select__meta .project-meta-group--description { order: 2; }
.work-select__meta .project-meta-group--services { order: 3; }
.work-select__meta .project-meta-group--year { order: 4; } */

/* .project-meta's own multi-column desktop layout uses padding-right on
   .meta-value to keep space between adjacent fields — irrelevant in
   this single narrow column, where it would just eat into the little
   width available. */
/* .work-select__meta .meta-value {
  padding-right: 0;
}

.work-select__view-btn {
  order: 5;
} */

/* The image/video was itself a link (href kept in sync with the active
   project in initWorkSelect) to that project's page. Wide (full width
   of .work-select__media) was the default, matching the old
   .card--wide media's own aspect ratio. .work-select__image--narrow
   was unused (every project rendered "wide" — see WORK_PROJECTS in
   script.js) but left in place for whenever a project needed it. */
/* .work-select__image {
  display: block;
  position: relative;
  overflow: hidden;
  aspect-ratio: 670 / 376;
  border-radius: var(--radius-media);
  background: #1a1a1a;
}

.work-select__image--narrow {
  width: 50%;
  margin-right: auto;
  aspect-ratio: 325 / 376;
} */

/* Wipe transition: the incoming image/video started translated fully
   below the frame and wiped up over whatever was already there
   (initWorkSelect appended the new one on top, then removed the
   previous one once this transition finished — see MEDIA_TRANSITION_MS
   there, which had to match this duration exactly: initWorkSelect used
   it to keep the carousel "busy" for the full wipe, so a fast hover
   across several names — or the auto-advance timer landing mid-wipe —
   couldn't start the next slide-up before this one had actually
   finished playing out). */
/* .work-select__image img,
.work-select__image video {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  transform: translateY(100%);
  transition: transform .8s ease-in-out;
}

.work-select__image img.is-active,
.work-select__image video.is-active {
  transform: translateY(0%);
} */

/* Faded out, swapped client/year/services/description/View Project
   while invisible, faded back in — see META_FADE_HALF in
   initWorkSelect. */
/* .work-select__meta {
  transition: opacity 0.3s ease-in-out;
}

.work-select__meta.is-fading {
  opacity: 0;
}

@media (prefers-reduced-motion: reduce) {
  .work-select__image img,
  .work-select__image video,
  .work-select__name,
  .work-select__meta {
    transition: none;
  }
} */

/* Project page */

.project-main {
  display: flex;
  flex-direction: column;
  gap: var(--gap-lg);
}

.project-meta {
  display: grid;
  grid-template-columns: repeat(var(--grid-columns-footer), 1fr);
  gap: var(--gap-sm);
  border-top: 1px solid var(--border);
  padding-top: 30px;

}

.project-meta-group {
  display: flex;
  flex-direction: column;
  gap: 0px;
}

.meta-label {
  font-size: 22px;
  letter-spacing: 0.02em;
  font-weight: 400;
  opacity: 0.5;
  line-height: 1.25em;
}

/* Same title-in-full-white/content-dimmed treatment as the footer's own
   text (e.g. .list-title vs. .list-group li) — opacity: 0.5 is that
   same "chosen grey," not a separate value. */
.meta-value {
  font-size: 22px;
  letter-spacing: 0.02em;
  padding-right: 2vw;
  opacity: 1;
}

.project-gallery {
  display: flex;
  flex-direction: column;
  gap: var(--gap-sm);
}

.gallery-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--gap-sm);
}

.gallery-media {
  position: relative;
  width: 100%;
  aspect-ratio: 1360 / 764;
  overflow: hidden;
  border-radius: var(--radius-media);
  background: #1a1a1a;
}

.gallery-media--half {
  aspect-ratio: 670 / 764;
}

.gallery-media--wide-short {
  aspect-ratio: 1360 / 316;
}

.gallery-media img,
.gallery-media video {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Placeholder text under the first media — one continuous block split
   across columns by the browser (balanced by line count, so the
   columns come out roughly equal regardless of text length). Desktop
   copy sits inside .project-gallery (hidden on mobile with it); mobile
   copy is a sibling placed right after the carousel. */
.project-text {
  --project-text-column-gap: 30px;
  column-count: 4;
  column-gap: var(--project-text-column-gap);
  font-size: 22px;
  line-height: 1.1;
  letter-spacing: 0.02em;
  padding-top: 30px;
  padding-bottom: 40px;
}

.project-text p {
  margin: 0;
}

.project-text p + p {
  margin-top: 1.2em;
}

.project-text--mobile {
  display: none;
}

@media (max-width: 1023px) {
  .project-text {
    column-count: 2;
  }
}

@media (max-width: 639px) {
  .project-text {
    column-count: 1;
    font-size: 18px;
  }
  .project-text--mobile {
    display: block;
    order: 0;
    border-top: 1px solid var(--border);
    padding-top: 30px;
    padding-right: 30px;
    padding-bottom: 0px;
  }
}

/* Mobile carousel — replaces .project-gallery at the mobile breakpoint
   only (hidden above it; see the max-width: 639px block below, which
   also hides .project-gallery there). A swipeable row of .carousel-
   slide media using native CSS scroll-snap rather than a JS gesture
   library — the browser handles the swipe physics for free, and
   initMobileCarousel() in script.js only needs to track which slide is
   currently snapped (via IntersectionObserver against .mobile-
   carousel__track as the root) to drive the dots/counter and to play/
   pause each slide's video. Most projects use the portrait 670/764
   ratio (same as .gallery-media--half above); fortnite/marcjacobs use
   the shorter data-ratio="landscape" variant instead (see their pages'
   own comment for why). A data-ratio="landscape" slide can also be a
   .carousel-slide--pair — two images shown side by side, counted as
   one slide/one dot — for the cases where two portrait crops sit next
   to each other on desktop but don't reorient cleanly into a single
   portrait frame. */
.mobile-carousel {
  display: none;
  margin-bottom: -30px;
}

.carousel-slide {
  flex: 0 0 100%;
  scroll-snap-align: start;
  aspect-ratio: 670 / 764;
  overflow: hidden;
  background: #1a1a1a;
}

.mobile-carousel[data-ratio="landscape"] .carousel-slide {
  aspect-ratio: 402 / 226;
}

.carousel-slide img,
.carousel-slide video {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.carousel-slide--pair {
  display: flex;
  gap: 0;
}

.carousel-slide--pair img,
.carousel-slide--pair video {
  flex: 1 1 0;
  min-width: 0;
  width: auto;
}

.mobile-carousel__nav {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--gap-sm);
}

.mobile-carousel__dots {
  display: flex;
  align-items: center;
  gap: 6px;
}

.mobile-carousel__dot {
  width: 4px;
  height: 4px;
  padding: 0;
  border: none;
  border-radius: 50%;
  background: var(--fg);
  opacity: 0.3;
  cursor: pointer;
}

.mobile-carousel__dot.is-active {
  opacity: 1;
}

.mobile-carousel__counter {
  margin: 0;
  font-family: var(--font-display);
  font-size: 12px;
  color: var(--fg);
}

.video-toggle {
  position: absolute;
  left: 10px;
  bottom: 10px;
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  padding: 0;
  border: none;
  border-radius: 2px;
  background: rgba(255, 255, 255, 0.2);
  cursor: pointer;
}

.video-toggle__icon {
  display: block;
}

.video-toggle__icon--play {
  display: none;
}

.video-toggle.is-paused .video-toggle__icon--pause {
  display: none;
}

.video-toggle.is-paused .video-toggle__icon--play {
  display: block;
}

/* Row nav — fixed bottom-right, added by initGalleryRowNav() only on
   pages with a .project-gallery. Locks scroll position to each gallery
   row's vertical center (also scrolljacked: see initGalleryRowNav);
   the arrow at either end hides itself when there's no row further in
   that direction. The whole nav fades out (.row-nav--hidden) outside
   the "track" between the first and last row's centered scroll
   position. Within the track, an arrow at either end fades out with
   .row-nav__btn--invisible. Both use opacity + a delayed visibility
   flip (transitioned at 0s but staggered by the fade duration) so they
   fade smoothly yet still leave the *other* arrow's slot undisturbed
   and become genuinely non-interactive once fully hidden. */
.row-nav {
  position: fixed;
  right: 40px;
  bottom: 50px;
  z-index: 60;
  display: flex;
  flex-direction: column;
  gap: 16px;
  opacity: 1;
  visibility: visible;
  transition: opacity 0.3s ease-in-out, visibility 0s linear 0s;
}

.row-nav--hidden {
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: opacity 0.3s ease-in-out, visibility 0s linear 0.3s;
}

.row-nav__btn {
  display: block;
  width: 28px;
  padding: 0;
  border: none;
  background: none;
  cursor: pointer;
  opacity: 1;
  visibility: visible;
  transition: opacity 0.25s ease-in-out, visibility 0s linear 0s;
}

/* Hover dim removed (replaced by the hover chroma effect on clickable
   text — see initHoverChroma in script.js).
.row-nav__btn:hover {
  opacity: 0.6;
}
*/

.row-nav__btn--invisible {
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: opacity 0.25s ease-in-out, visibility 0s linear 0.25s;
}

.row-nav__icon {
  display: block;
  width: 100%;
  height: auto;
}

/* Baked-in white fill (assets/up.svg, assets/down.svg, and
   assets/logo.svg — .logo-mark--footer's own image, reverted back from
   text — are all fill="white") — external image files, not inline SVG
   with fill="currentColor", so they don't pick up --fg's light-mode
   flip on their own the way the rest of the site's icons (and, now
   that it's live text, the header's own .logo-mark) do. A flat invert
   turns the solid white shape solid black, which is exactly what's
   needed since these are single-color marks. */
html.is-light .row-nav__icon,
html.is-light .logo-mark--footer {
  filter: invert(1);
}

@media (prefers-reduced-motion: reduce) {
  .row-nav,
  .row-nav--hidden,
  .row-nav__btn,
  .row-nav__btn--invisible {
    transition: none;
  }
}

/* Credits — a "Credits" heading plus a variable number of .credits-col
   (usually 1, sometimes up to 3 — dominos.html is the current
   3-column case), laid out on the same fixed 4-column grid as
   .project-meta right above it (rather than flex: 1 1 0, which used to
   stretch a lone .credits-col out to fill all the remaining width —
   landing it across where columns 2-4 would be instead of sitting in
   just column 2). Within a column, people are grouped by shared role
   into a .credit-group (role heading + one or more names) rather than
   each person getting their own row. */
.project-credits {
  display: grid;
  grid-template-columns: repeat(var(--grid-columns-footer), 1fr);
  gap: var(--gap-sm);
  border-top: 1px solid var(--border);
  padding-top: 30px;
}

.credits-heading {
  margin: 0;
  font-size: 22px;
  line-height: 1.4em;
  letter-spacing: 0.02em;
  opacity: 1;
  font-weight: 800;
}

/* Wraps the .credits-col elements so the tablet reflow (below) can
   treat them as one flex sibling of .credits-heading — at this
   (desktop) grid, it drops out of the box tree entirely so each
   .credits-col keeps landing in its own fixed grid column. */
.credits-content {
  display: contents;
}

.credits-col {
  display: flex;
  flex-direction: column;
  gap: 8px;
  min-width: 0;
}

.credit-group {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.credit-names {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.credit-role {
  margin: 0;
  font-family: var(--font-mono);
  font-weight: 400;
  font-size: 20px;
  line-height: 1.11;
  letter-spacing: 0.02em;
  opacity: 1;
}

.credit-name {
  margin: 0;
  font-size: 22px;
  line-height: 26px;
  letter-spacing: 0.02em;
}

/* Mobile-only truncated-credits UI (see initCreditsTruncation in
   script.js and the max-width: 639px block further down) — hidden
   everywhere else since desktop/tablet always show every credit. */
.credits-fade,
.credits-see-all {
  display: none;
}

.project-nav {
  display: flex;
  gap: var(--gap-sm);
  border-top: 1px solid var(--border);
  padding-top: 40px;
}

.project-nav-side {
  display: flex;
  flex: 1 1 0;
  min-width: 0;
  gap: var(--gap-sm);
  align-items: center;
}

.project-nav-side--next {
  justify-content: flex-end;
}

.project-nav-side .pill {
  flex-shrink: 0;
}

.project-nav-label {
  /* Default flex-grow:0 on purpose — growing to fill the side's leftover
     space (flex:1 1 auto) is what pushed the "next" side's label text
     to the far left of its box while flex-end grouped the now-oversized
     box's trailing edge against the pill, leaving a big gap between the
     text and the pill instead of a small one. flex-shrink stays at its
     default 1 (with min-width:0 below) so the label can still shrink
     below its content width and ellipsize when space is tight. */
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-family: var(--font-mono);
  font-weight: 700;
  font-size: 20px;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  padding-top: 1px;
  opacity: 1;
}

/* Footer */

.site-footer {
  display: flex;
  flex-direction: column;
  gap: 60px;
  margin-top: 40px;
}

/* Big closing wordmark — reverted back to assets/logo.svg (an <img>
   again, not text — see the matching markup change in every page's
   footer), filling the page's regular content width (not a viewport-
   edge bleed) at every breakpoint the same way it always did. Two
   classes on the element (rather than relying on source order) so this
   reliably beats .logo-mark's font-size-based rules at every
   breakpoint, including inside the tablet/mobile media queries defined
   later in this file. */
.logo-mark.logo-mark--footer {
  width: 100%;
  height: auto;
}

.logo-mark--footer:hover {
  opacity: 1;
}

.footer-row {
  display: grid;
  grid-template-columns: repeat(var(--grid-columns-footer), 1fr);
  gap: var(--gap-sm);
  border-top: 1px solid var(--border);
  padding-top: 30px;
}


.footer-row--about .statement {
  grid-column-end: span 2;
  padding-right: 5vw;
  font-family: var(--font-nikkei-line);
  font-weight: 800;
  font-size: 3.5vw;
  text-transform: uppercase;
  line-height: .9em;
  letter-spacing: 0.01em;
}

.footer-row--about .bio {
  grid-column-end: span 2;
  font-size: 22px;
  line-height: 1.2;
  opacity: 1;
  letter-spacing: 0.02em;
}

.footer-row--services h2,
.footer-row--clients h2,
.footer-row--industries h2 {
  grid-column-end: span 1;
  font-family: var(--font-nikkei-line);
  font-weight: 800;
  font-size: 3.5vw;
  margin: 0;
  letter-spacing: 0.02em;
  line-height: .8em;
  text-transform:uppercase;
}

.list-group {
  grid-column-end: span 1;
  display: flex;
  flex-direction: column;
  gap: 2px;
  letter-spacing: 0.02em;
}

.list-title {
  font-weight: 400;
  font-size: 22px;
  letter-spacing: 0.02em;
  line-height: 1.1em;
  opacity: .5;
}

.list-group ul {
  display: flex;
  flex-direction: column;
  gap: 0px;
}

.list-group li {
  font-size: 22px;
  line-height: 1.1;
  letter-spacing: 0.02em;
  font-weight: 100;
  opacity: 1;
}

/* Grid (not flex) so .clock lands in a real, fixed-width column 1 —
   matching .footer-row/.project-meta/.project-credits above it —
   instead of sizing off its own min-width. */
.footer-bottom {
  display: grid;
  grid-template-columns: repeat(var(--grid-columns-footer), 1fr);
  gap: var(--gap-sm);
  padding-top: 40px;
}

/* Removed per request: the mailto link is hidden for now.
.contact {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.email {
  font-weight: 400;
  font-size: 32px;
  letter-spacing: 0.02em;
}
*/

.copyright {
  font-family: var(--font-mono);
  font-weight: 700;
  font-size: 14px;
  opacity: 1;
}

/* Groups the closing wordmark with the copyright line sitting just
   underneath it, left-aligned, at the very bottom of the footer — a
   tighter gap than .site-footer's own 60px between its other sections. */
.footer-closing {
  display: flex;
  flex-direction: column;
  gap: 20px;
  text-align: right;
}

/* grid-column: -2 / -1 (the last track) rather than a literal 4 so it
   stays the rightmost column as --grid-columns-footer drops to 2, then
   1, at tablet/mobile — "4th column" only means something literally at
   desktop's 4-column grid. */
.clock {
  display: flex;
  flex-direction: column;
  gap: 2px;
  grid-column: -2 / -1;
}

.clock-row {
  display: flex;
  justify-content: space-between;
  gap: 16px;
}

.clock-row .city {
  font-weight: 600;
  font-size: 22px;
}

.clock-row .time {
  font-family: var(--font-mono);
  font-weight: 700;
  font-size: 22px;
}

/* Scroll chromatic aberration, driven by scroll velocity.
   initScrollChroma (script.js) measures scroll speed every frame and maps it
   to an intensity from 0 to 1 through an ease-in-out curve, reaching 1 at
   --chroma-velocity-max (px per second), smoothed with --chroma-attack
   (rising) and --chroma-release (falling), in ten steps. Each step change is
   written inline on the target elements as a CSS transition: over
   --chroma-attack going up, --chroma-release stepping down while the page
   still moves, and --chroma-fade-out to zero once it stops. A short, slow
   scroll leaves a short, faint trail and a fast flick a longer, stronger one,
   and the effect fades as momentum dies out. The -x/-y offsets, --chroma-blur,
   --chroma-glow and --media-blur are the values at full intensity. Both
   blurs follow intensity raised to --chroma-blur-curve (1 = linear, higher
   = drops off faster), so they finish fading with the glows. The
   vertical glow offsets follow the scroll direction (mirrored scrolling up).
   Filters are only attached while scrolling or fading, so the page carries
   no filter at rest. Text-bearing sections get the blur and glows (truncated
   credits filter their children instead of the section, so the fade gradient
   stays clean); images and video get a plain blur. The hero spin
   aberration is currently disabled (commented out further down; it uses
   --chroma and --chroma-dir below). Light mode has its own
   --chroma-strength. */
:root {
  --chroma: 0;
  --chroma-dir: 1;
  --chroma-strength: 1.15;
  --media-blur: 0px;
  --chroma-velocity-max: 1500;
  --chroma-attack: 0.01s;
  --chroma-release: 0.2s;
  --chroma-fade-out: 0.4s;
  --chroma-blur: .5px;
  --chroma-blur-curve: 4;
  --chroma-glow: 3px;
  --chroma-red: 255, 0, 0;
  --chroma-red-alpha: 0.7;
  --chroma-red-x: -3px;
  --chroma-red-y: -6px;
  --chroma-green: 00, 255, 0;
  --chroma-green-alpha: 0.6;
  --chroma-green-x: 0px;
  --chroma-green-y: -5px;
  --chroma-blue: 00, 00, 255;
  --chroma-blue-alpha: 0.7;
  --chroma-blue-x: 4.5px;
  --chroma-blue-y: -6px;
}

html.is-light {
  --chroma-strength: .9;
}

/* Applied to the text-bearing sections rather than .page so images and
   video stay untouched (a filter on an ancestor can't exempt its
   descendants). */
/* The scrolling effect itself is written inline on each element by
   initScrollChroma (script.js) as CSS transitions between intensity steps,
   not through these variables or per-frame writes: Safari mis-renders a
   filter that changes every frame. The rule below only covers what inline
   styles can't reach: the truncated-credits border pseudo-element, which reads the current step's
   filter string the script publishes on <html>. */
html.is-chroma-active .project-credits:has(.credits-fade)::before {
  filter: var(--chroma-text-filter, none);
}

/* Hero spin aberration — disabled for now. Uncomment this rule, the
   --chroma pin and the reduced-motion override below to restore it.
.hero__line--spinning {
  filter:
    blur(calc(var(--chroma) * var(--chroma-strength) * var(--chroma-blur)))
    drop-shadow(calc(var(--chroma) * var(--chroma-strength) * var(--chroma-red-x)) calc(var(--chroma) * var(--chroma-strength) * var(--chroma-dir) * var(--chroma-red-y)) calc(var(--chroma) * var(--chroma-strength) * var(--chroma-glow))
      rgba(var(--chroma-red), calc(var(--chroma) * var(--chroma-strength) * var(--chroma-red-alpha))))
    drop-shadow(calc(var(--chroma) * var(--chroma-strength) * var(--chroma-green-x)) calc(var(--chroma) * var(--chroma-strength) * var(--chroma-dir) * var(--chroma-green-y)) calc(var(--chroma) * var(--chroma-strength) * var(--chroma-glow))
      rgba(var(--chroma-green), calc(var(--chroma) * var(--chroma-strength) * var(--chroma-green-alpha))))
    drop-shadow(calc(var(--chroma) * var(--chroma-strength) * var(--chroma-blue-x)) calc(var(--chroma) * var(--chroma-strength) * var(--chroma-dir) * var(--chroma-blue-y)) calc(var(--chroma) * var(--chroma-strength) * var(--chroma-glow))
      rgba(var(--chroma-blue), calc(var(--chroma) * var(--chroma-strength) * var(--chroma-blue-alpha))));
}
*/

/* Truncated credits filter their children, not the section (keeps the
   fade gradient clean), so the section's top border is redrawn as a
   ::before line that can carry the filter on its own. */
.project-credits:has(.credits-fade) {
  position: relative;
  border-top-color: transparent;
}

.project-credits:has(.credits-fade)::before {
  content: "";
  position: absolute;
  top: -1px;
  left: 0;
  right: 0;
  height: 1px;
  background: var(--border);
}

/* Disabled with the hero spin aberration above.
A spinning hero line runs its own full-strength pass, independent of
   scroll; --chroma is pinned to 1 there.
.hero__line--spinning {
  --chroma: 1;
}

@media (prefers-reduced-motion: reduce) {
  .hero__line--spinning {
    filter: none;
  }
}
*/

/* Hover chroma: a looping version of the scroll aberration for clickable
   text (the header logo, pills, and the light/dark toggle). initHoverChroma (script.js) reads these on each hover and runs it
   with the Web Animations API, which Safari renders reliably: it eases in
   over --hover-chroma-in to the glows' smaller size, then loops them
   growing to full size and shrinking back (--hover-chroma-period each
   way), and on leave eases out from wherever the loop is over
   --hover-chroma-out. Offsets/blur are the values at full size; the loop's
   smallest size is --hover-chroma-min of that. Independent of the scroll
   effect's parameters. */
:root {
  --hover-chroma-in: 0.3s;
  --hover-chroma-out: 0.45s;
  --hover-chroma-period: .75s;
  --hover-chroma-min: 0.2;
  --hover-chroma-strength: 1.1;
  --hover-chroma-blur: 0.25px;
  --hover-chroma-glow: 3.5px;
  --hover-chroma-red: 255, 0, 0;
  --hover-chroma-red-alpha: 0.6;
  --hover-chroma-red-x: -4px;
  --hover-chroma-red-y: -4px;
  --hover-chroma-green: 0, 255, 0;
  --hover-chroma-green-alpha: 0.4;
  --hover-chroma-green-x: 4px;
  --hover-chroma-green-y: 0px;
  --hover-chroma-blue: 0, 0, 255;
  --hover-chroma-blue-alpha: 0.6;
  --hover-chroma-blue-x: 4px;
  --hover-chroma-blue-y: 4px;
}

html.is-light {
  --hover-chroma-strength: 0.5;
}

/* Responsive — desktop breakpoints first, stepping down to mobile */
/* Above 1440px the container itself scales fluidly (see --content-width), */
/* capping at 1813px (1920px viewport) so it never grows unbounded. */

/* Laptop: tighten spacing, still a 4-column grid */
@media (max-width: 1439px) {
  :root {
    --gap-sm: 10px;
    --gap-lg: 40px;
  }
}

/* Tablet: collapse the 4-column grid to 2 columns */
@media (max-width: 1023px) {
  :root {
    --grid-columns-work: 2;
    --grid-columns-footer: 2;
  }
  /* Row-lock up/down nav is desktop-only. */
  .row-nav {
    display: none;
  }
  .logo-mark {
  font-size: 6vw;
  }
  .tagline {
  font-size: 3.5vw;
  }
  .hero {
  padding-top: 30vw;
  padding-bottom: 15vw;
  }
  .hero__line {
  font-size: 11.5vw;
  }
  /* Shown again here (hidden on desktop — see the base rule above) and
     stacks under the headline, left-aligned, instead of sitting in the
     corner. */
  .hero__shuffle {
    display: inline-flex;
    position: static;
    margin-top: 20px;
  }

  /* Selected Work gallery reflows here: .work-gallery__line and
     .work-gallery__info both drop out of their sticky roles entirely
     (just .work-gallery__info's <h2> stays, full width, above the
     grid) since there's no hover state on a touch device for either to
     respond to, and each card shows its own name + tags inline instead
     (same .project-tags markup the old grid used, just hidden at
     desktop — see .work-gallery .project-tags above). The grid itself
     goes 2 columns here; full single-column width is mobile-only (see
     the breakpoint further down this file). */
  .work-gallery__line {
    position: static;
  }
  /* display: flex overrides the desktop base rule's display: grid —
     .work-gallery__row's 4-column grid (see its own base-rule comment)
     is desktop-only; stacked here, there's no alignment-with-the-
     footer concern left to solve, just the two pieces on top of each
     other. */
  .work-gallery__row {
    display: flex;
    flex-direction: column;
    gap: 20px;
  }
  .work-gallery__info {
    position: static;
    width: 100%;
  }
  /* display: none rather than the desktop opacity fade — these were
     taking up their full reserved layout height while merely invisible
     (opacity: 0 still lays out normally), leaving a large blank gap
     between the heading and the grid below it for no reason, since
     there's no hover state here to ever fade them in anyway. */
  .work-gallery__info .project-meta-group {
    display: none !important;
  }
  .work-gallery__grid {
    grid-template-columns: repeat(2, 1fr);
  }
  .work-gallery .project-tags {
    display: block;
  }

  .project-name {
    font-size: 18px;
  }

    .project-tags {
      font-size: 25px;
  }

  /* .col-start-2 { grid-column-start: auto; } — old gallery only */

  /* Selected Work carousel reflow — reverted along with the rest of
     .work-select (see further up this file); the old card grid this
     breakpoint restored doesn't need a media/meta split at all.
  .work-select__media {
    grid-column: 1 / -1;
  }
  .work-select__meta {
    grid-column: 1 / -1;
  } */

  /* Client/Year/Services stack in column 1, Description fills column 2
     alongside them, via the --client/--year/--services/--description
     modifier classes each field's markup carries. (.work-select__meta,
     the carousel's own version of this same reflow plus a third row for
     its View Project pill, is commented out above along with the rest
     of the carousel.) */
  .project-meta {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    grid-template-areas: "client description" "year description" "services description";
    gap: 30px;
  }
  /* .work-select__meta {
    grid-template-areas: "client description" "year description" "services description" "button button";
  } */
  .project-meta-group--client { grid-area: client; }
  .project-meta-group--year { grid-area: year; }
  .project-meta-group--services { grid-area: services; }
  .project-meta-group--description { grid-area: description; }
  /* Grid items stretch to fill their area on the inline axis by
     default, same as align-self does on the block axis — without this
     the pill stretched full width instead of staying its own natural
     (content-sized) width.
  .work-select__view-btn {
    grid-area: button;
    align-self: start;
    justify-self: start;
  } */

  /* Same left-heading/right-stacked-content split as
     .footer-row--services/.list-group below, but via flex + stretch
     rather than a grid row-span. grid-row: 1 / -1 on .credits-heading
     (a previous attempt at this) depended on the implicit grid's final
     row count — which isn't resolved yet when the heading, first in
     DOM order, is placed, so it silently collapsed to a single row;
     every .credit-group after it then auto-placed into the now-"free"
     column 1 on alternating rows instead of stacking in column 2 (the
     reported "names/roles in the left column, order jumbled" bug).
     Flex's default align-items: stretch has no such ordering
     dependency — .credits-heading simply stretches to match
     .credits-content's real height. */
  .project-credits {
    display: flex;
    gap: var(--gap-sm);
  }
  .credits-heading {
    flex: 1 1 0;
    min-width: 0;
  }
  .credits-content {
    display: flex;
    flex-direction: column;
    gap: 8px;
    flex: 1 1 0;
    min-width: 0;
  }

  /* Project meta (both .project-meta on the project pages and
     .work-select__meta in the carousel, via their shared .meta-label/
     .meta-value) and credits shrink at tablet and mobile alike. */
  .meta-label,
  .meta-value,
  .credits-heading,
  .credit-name,
  .credit-role,
  .project-text {
    font-size: 18px;
    line-height: 1.25em;
  }

  .footer-row--services,
  .footer-row--clients,
  .footer-row--industries {
    grid-template-columns: 1fr 1fr;
  }

  .footer-row--services h2,
  .footer-row--clients h2,
  .footer-row--industries h2 {
    grid-column-start: 1;
    grid-row-end: span 3;
  }
  .footer-row--about .statement, .footer-row--services h2,
  .footer-row--clients h2,
  .footer-row--industries h2,
  .footer-row--works h2,
  .work-gallery__info h2,
  .site-header.is-scrolled .logo-mark {
  font-size: 5vw;
}
  .footer-row--services .list-group,
  .footer-row--clients .list-group,
  .footer-row--industries .list-group {
  grid-column-start: 2;
  }

  .footer-row--clients,
  .footer-row--industries {
  row-gap: 6px;
  }
  .footer-row--about .bio, .list-group li {
  font-size: 18px;
  }
  .list-title {
  font-size: 18px;
  }

}

/* Mobile: work grid stays 2 columns (landscape cards full width, */
/* one-column cards at half width); footer text collapses to 1 column */
@media (max-width: 639px) {
  :root {
    --grid-columns-work: 2;
    --grid-columns-footer: 1;
  }
  /* Narrows the tablet breakpoint's 2-column Selected Work grid (see
     further up this file) down to 1 — display: grid itself still comes
     from that same tablet rule, unoverridden here. gap goes to a single
     uniform 40px (the larger of the two axes' values at wider
     breakpoints) now that there's only one column's worth of gaps —
     between cards — left to set. */
  .work-gallery__grid {
    grid-template-columns: 1fr;
    gap: 40px;
  }
  .site-header { margin-bottom: 0px; }
  .logo-mark { font-size: 10.5vw; }
  .tagline {
  font-size: 5vw;
  }
  /* Work/About/Inquiries stack under the tagline instead of sitting
     beside the logo, and (initHeaderScrollState) hide together with the
     tagline past the scroll threshold — but unlike the tagline, come
     back on any short upward scroll rather than only at the very top. */
  .nav-pills {
    order: 3;
    flex-basis: 100%;
    margin-top: 10px;
    opacity: 1;
    max-height: 60px;
    overflow: hidden;
    transition:
      opacity 0.3s ease-in-out,
      transform 0.3s ease-in-out,
      max-height 0.3s ease-in-out,
      margin-top 0.3s ease-in-out;
  }

  .site-header.is-pills-hidden .nav-pills {
    opacity: 0;
    transform: translateY(-12px);
    max-height: 0;
    margin-top: 0;
    pointer-events: none;
  }
  .hero {
  padding-top: 20vw;
  }
  .hero__line {
  font-size: 13vw;
  }
  .project-name {
    font-size: 18px;
  }
  .project-tags {
  font-size: 25px;
  line-height: .8;
}
  .footer-row--about .statement,
  .footer-row--about .bio,
  .footer-row--services h2,
  .footer-row--clients h2,
  .footer-row--industries h2,
  .list-group { grid-column-end: span 1; }
  /* One continuous list: no gap between the stacked .list-groups. */
  .footer-row--clients,
  .footer-row--industries {
  row-gap: 0;
  }

  /* .mobile-align-end { grid-column-start: 2; } — old gallery only */

  /* .email { font-size: 24px; } — removed, see the commented-out .email/.contact above */
  .footer-row--about .statement, .footer-row--services h2,
  .footer-row--clients h2,
  .footer-row--industries h2,
  .footer-row--works h2,
  .work-gallery__info h2,
  .site-header.is-scrolled .logo-mark {
  font-size: 8vw;
  letter-spacing: 0.02em;
}
  .footer-row--about .bio, .list-group li {
  font-size: 18px;
  }
  .footer-row--about .bio {
  margin-top: 20px;
  }
  .list-title {
  font-size: 18px;
  }

  /* Stacks the paired half-media into one column — but they keep their
     own 670/764 portrait ratio (the base .gallery-media--half rule)
     rather than being forced into the wide 1360/764 ratio, which was
     squishing/cropping portrait images and videos into landscape. */
  .gallery-row { grid-template-columns: 1fr; }

  /* Previous/next stay on one row (base .project-nav-side/.pill/.label
     rules above handle the label truncating instead of wrapping or
     pushing the pill off-screen) rather than stacking. */
  .project-nav { gap: 12px; }
  .project-nav-side { gap: 8px; }

  /* A second (or third...) credits column stacks directly under the
     first, no gap, reading as one continuous list — rather than sitting
     side by side, cramped, at this width. Reset the desktop row's
     flex: 1 1 0 so the heading doesn't stretch to share height with the
     stacked columns below it now that the axis has changed to column. */
  .project-credits {
    flex-direction: column;
    gap: 0;
  }
  .credits-heading {
    flex: 0 0 auto;
    margin-bottom: 20px;
  }

  /* Client + Year side by side (columns 1 and 2), Services and
     Description each stacked underneath spanning both columns —
     likewise its own 2-column layout rather than --grid-columns-footer's
     1 column. The --client/--year/--services/--description grid-area
     mapping is set once, at the tablet breakpoint above, and carries
     over unchanged here — only which cells those areas occupy does.
     (.work-select__meta, the carousel's own version of this reflow plus
     a fourth full-width row for its View Project pill, is commented out
     along with the rest of .work-select further up this file.) */
  .project-meta {
    grid-template-columns: repeat(2, 1fr);
    grid-template-areas: "client year" "services services" "description description";
  }
  /* .work-select__meta {
    grid-template-areas: "client year" "services services" "description description" "button button";
  } */

  /* Mobile carousel takes over from .project-gallery here — order: -1
     (rather than moving its markup) puts it before .project-meta in
     source order's usual position, since .project-main is already a
     column flex; everything else keeps its default order: 0, i.e. its
     existing document position. */
  .project-gallery {
    display: none;
  }
  .mobile-carousel {
    display: flex;
    flex-direction: column;
    gap: 12px;
    order: -1;
  }
  .mobile-carousel__track {
    display: flex;
    overflow-x: auto;
    scroll-snap-type: x mandatory;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
    /* Full-bleed: breaks out of .page's 20px side padding (which, at
       this breakpoint, IS the viewport gutter — .page is never wider
       than the viewport here) so only the media itself runs edge to
       edge; .mobile-carousel__nav below stays inset like everything
       else on the page. */
    margin-left: -20px;
    margin-right: -20px;
    width: calc(100% + 40px);
  }
  .mobile-carousel__track::-webkit-scrollbar {
    display: none;
  }

  /* Credits truncation — only for projects with more than one
     .credits-col on desktop (initCreditsTruncation in script.js adds
     hides names past the 4th inline plus adds .credits-fade/.credits-see-all;
     removes all three on click, permanently revealing the rest). A
     single-column project's credits (e.g. picnic) are short enough
     that JS never adds any of this, matching the Figma "main design"
     state, which has no gradient/button at all. */
  .credits-content {
    display: flex;
    flex-direction: column;
    gap: 8px;
    flex: 0 0 auto;
    position: relative;
  }
  .credits-content.is-truncated {
    overflow: hidden;
  }
  .credits-fade {
    display: block;
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    background: linear-gradient(to bottom, transparent, var(--bg));
    pointer-events: none;
  }
  .credits-see-all {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    align-self: center;
    margin-top: 10px;
  }
}

/* Password gate — full-page interstitial for confidential project
   pages (e.g. apple.html). Hidden by default; initPasswordGate() in
   script.js adds .is-open unless this browser already unlocked this
   particular gate (localStorage, keyed by data-gate-id) before. This is
   a soft, client-side-only confidentiality gate, not real access
   control — there's no server here to enforce it. */
.password-gate {
  display: none;
  position: fixed;
  inset: 0;
  z-index: 200;
  flex-direction: column;
  padding: 20px;
}

/* Always renders in its original dark-glass look (backdrop blur over a
   translucent black modal), regardless of light/dark mode — pins --fg/
   --bg/--border back to their dark-mode values within this subtree, so
   .password-gate-* rules that reference them further down (e.g.
   .password-gate__input's color: var(--fg)) don't go dark-on-dark once
   html.is-light flips those variables for the rest of the page. */
html.is-light .password-gate {
  --fg: rgba(255, 255, 255, 0.9);
  --bg: #000000;
  --border: rgba(255, 255, 255, 0.85);
}

.password-gate.is-open {
  display: flex;
}

/* Centers the modal within the password-gate. Used to fill only the
   space above .password-gate__nav (now removed — see below) rather
   than the full viewport; with the nav gone, flex: 1 simply gives it
   the whole height, which is the desired behavior. */
.password-gate__center {
  flex: 1;
  min-height: 0;
  display: flex;
  align-items: center;
  justify-content: center;
}

body.is-gated {
  overflow: hidden;
}

.password-gate__backdrop {
  position: absolute;
  inset: 0;
  background: rgba(20, 20, 20, 0.8);
  backdrop-filter: blur(100px);
  -webkit-backdrop-filter: blur(100px);
}

.password-gate__modal {
  position: relative;
  z-index: 1;
  width: 375px;
  max-width: 100%;
  display: flex;
  flex-direction: column;
  gap: 20px;
  background: rgba(0, 0, 0, 0.6);
  border-radius: 4px;
  padding: 24px 20px;
}

.password-gate__close {
  position: absolute;
  top: 30px;
  right: 30px;
  z-index: 1;
  width: 32x;
  height: 32px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: none;
  background: none;
  color: var(--fg);
  cursor: pointer;
  transition: opacity 0.15s ease-in-out;
}

/* Hover dim removed (replaced by the hover chroma effect on clickable
   text — see initHoverChroma in script.js).
.password-gate__close:hover {
  opacity: 0.6;
}
*/

.password-gate__close svg {
  display: block;
  width: 28px;
  height: 28px;
}

.password-gate__title {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 22px;
  line-height: 1.2;
  letter-spacing: 0.02em;
}

.password-gate__body {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 400;
  font-size: 16px;
  line-height: 1.3;
}

.password-gate__form {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.password-gate__label {
  font-family: var(--font-mono);
  font-weight: 700;
  font-size: 20px;
  letter-spacing: 0.04em;
}

.password-gate__input {
  height: 45px;
  padding: 0 16px;
  border: none;
  border-radius: 4px;
  background: rgba(255, 255, 255, 0.05);
  color: var(--fg);
  font-family: var(--font-display);
  font-size: 18px;
  letter-spacing: 0.02em;
}

.password-gate__input:focus {
  outline: 1px solid var(--border);
  outline-offset: 2px;
}

.password-gate__error {
  margin: 0;
  font-family: var(--font-display);
  font-size: 14px;
  color: #ff8a8a;
}

/* Disabled (default) state — enabled once .password-gate__input has a
   value (see initPasswordGate in script.js, which toggles the native
   disabled attribute on input). */
.password-gate__submit {
  height: 45px;
  border: none;
  border-radius: 4px;
  background: rgba(169, 169, 169, 0.2);
  color: var(--fg);
  font-family: var(--font-mono);
  font-weight: 700;
  font-size: 18px;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  cursor: not-allowed;
  transition: background 0.15s ease-in-out, color 0.15s ease-in-out;
}

.password-gate__submit:not(:disabled) {
  background: rgba(255, 255, 255, 1);
  color: var(--bg);
  cursor: pointer;
}

.password-gate__submit:not(:disabled):hover {
  background: rgba(255, 255, 255, 0.8);
}

.password-gate__request {
  align-self: center;
  font-family: var(--font-mono);
  font-weight: 700;
  font-size: 18px;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  text-decoration: underline;
  text-underline-offset: 2px;
}

/* Removed per request: the password gate no longer shows its own
   Previous/Next nav (getLeaveHref() in script.js still harmlessly
   queries for .password-gate__nav a[href] and simply finds nothing).
.password-gate__nav {
  flex-shrink: 0;
  z-index: 1;
  padding-bottom: 20px;
}
*/
