/* ==========================================================================
   ofthefuture — base styles
   Monochrome, subtly brutalist.
   Two typefaces, nothing else: Azeret Mono (primary -- display/headline
   text and the wordmark) and Geist Mono (secondary -- body copy and UI).
   Azeret Mono only ships a Regular (400) file, so anything set in it
   uses that weight explicitly rather than requesting 700 and letting
   the browser fake a synthetic bold, which tends to look muddy on a
   monospace face's distinct letterforms.
   ========================================================================== */

@font-face {
  font-family: "Geist Mono";
  src: url("../static/fonts/GeistMono-Regular.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "Geist Mono";
  src: url("../static/fonts/GeistMono-Medium.woff2") format("woff2");
  font-weight: 500;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "Geist Mono";
  src: url("../static/fonts/GeistMono-SemiBold.woff2") format("woff2");
  font-weight: 600;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "Azeret Mono";
  src: url("../static/fonts/AzeretMono-Regular.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

:root {
  /* Dark is the site's only theme (there used to be a light/dark switch
     in the header -- see js/main.js and README for the removed
     initThemeToggle()/data-theme mechanism if it ever needs reviving).
     --bg is a neutral near-black (0C0C0C) -- --bg-gradient below is
     re-derived off the same hue, shifted up by the same delta the
     previous swatch used (0C0C0C -> 191717, matching 141413 -> 211f1e),
     so the animated backdrop and this flat fallback (card placeholders,
     the marquee band -- anywhere a moving gradient would look wrong)
     still read as the same swatch. --gray/--gray-line/--card-placeholder
     still carry a faint blue tint left over from an earlier pass; flag
     if they should come back to neutral too. */
  --bg: #0c0c0c;
  --ink: #f7f6f9;
  --gray: #8f97a2;
  --gray-line: #2e3742;
  --card-placeholder: #22272e;
  /* Only used by the contact form's validation/error states (see
     .contact-modal-form). Nothing else on the site needed a red/error
     color before this -- picked a muted, desaturated red-orange rather
     than a pure alarm red so it stays inside the site's otherwise
     monochrome-plus-violet-gradient palette instead of sticking out as a
     generic "form plugin" red. */
  --contact-error: #d9776a;
  /* The page's own animated backdrop (see body below) -- kept separate
     from --bg rather than replacing it, since --bg is still needed as a
     flat, non-animated fill on things like card placeholders and the
     marquee band, where a moving gradient would look wrong. */
  --bg-gradient: linear-gradient(60deg, #0c0c0c 0%, #191717 100%);

  --font-display: "Azeret Mono", "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
  --font-body: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
  /* Same typeface as --font-display (no third font) -- kept as its own
     token because it's semantically distinct (the "ofthefuture"
     wordmark itself, header logo included, rather than display type in
     general), not because it resolves to anything different. */
  --font-wordmark: var(--font-display);

  /* How much vertical room --card-h and .home-hero-mark's font-size have
     left to *share* between them, inside .home-main's fixed height:
     calc(100vh - header) box (see that rule) -- total viewport height,
     minus the header (the same var(--header-h) that box itself
     subtracts), minus everything else in that box that does NOT scale
     down on a short viewport: .gallery's 80px margin-top + 56px
     padding-bottom (136px, spent regardless of card size) and
     .home-hero's own 80px padding-top (216px total -- see that
     padding's own comment for why it stays fixed rather than sharing
     this budget too). What's left is split between just --card-h and
     the wordmark's font-size, below.

     This was the real bug behind "the site isn't responsive": a fixed
     518px card plus a vw-only hero wordmark (see .home-hero-mark) have
     no way to make room for each other on a short/wide viewport --
     .home-hero's min-height: 0 let it get squeezed to nothing while the
     wordmark stayed full-size and got clipped clean off by the header.
     A plain `Nvh` clamp isn't enough to fix that either -- it scales
     proportionally with the *whole* viewport, not with what's actually
     left after everything fixed above, so it either still overflows on
     medium-short screens or shrinks the card even on tall ones (the two
     don't scale as the same linear function). Sizing off a plain
     subtraction instead means --card-h and the wordmark always sum to
     fit whatever room is actually left, at any height. */
  --gallery-hero-budget: calc(100vh - var(--header-h, 70px) - 216px);

  /* 0.25x bigger than the 414px this used to be (414 * 1.25 = 517.5,
     rounded). --card-w is gone -- every card is a fixed 4:5 now (see
     .card's aspect-ratio below), so width is derived from height rather
     than set independently; a separate width token could only ever
     fight that ratio.
     Sized off --gallery-hero-budget (see that variable, above) rather
     than a flat px value or a plain vh percentage, for the reasons
     explained there. 0.772 is this card's share of the budget at the
     original numbers (518 / 670.7, where 670.7 = 518 + this typeface's
     ~152.7px rendered height at the wordmark's own 166px font-size --
     see that rule's comment for the matching 0.247 on its side of the
     same split), so nothing changes at the ~950px+ viewport heights
     this was tuned on, and both this and the wordmark shrink together,
     in proportion, below that instead of one clipping while the other
     stays full-size.
     The `min()` against (budget - 34px) alongside the plain 0.772
     share matters just as much as the share itself -- a flat px floor
     here (an earlier version of this used 160px) kicks in below a
     certain height and, because it doesn't know anything about what
     the wordmark still needs, happily eats *more* than this 0.772
     share out of the shared budget -- which starves the wordmark of
     its own share and reopens the exact clipping this variable exists
     to prevent, just at a more extreme height. Capping this at
     (budget - 34px) instead guarantees at least 34px -- the
     wordmark's own font-size floor, i.e. its smallest possible
     rendered height -- is always left over for it, no matter how small
     this card gets; the small 40px floor below that only stops this
     from going to zero or negative once the budget itself is nearly
     gone. */
  --card-h: clamp(40px, min(calc(var(--gallery-hero-budget) * 0.772), calc(var(--gallery-hero-budget) - 34px)), 518px);
  /* Max extra space js/main.js's updateGalleryGaps() adds after a card,
     once that card's right edge has drifted all the way to the gallery
     viewport's own right edge -- see the function for the curve (gap
     stays ~0 through the left/middle of the screen and only opens up
     near the right edge). Kept subtle -- this is a hairline-gap row
     (see the reference), not a row with visibly wide gaps -- rather
     than a plain flex `gap`, since the point is that the (small) space
     between cards depends on where they currently sit on screen. */
  --gallery-gap-max: 20px;

  /* A deliberate, one-off pop of color (the marquee's "All Things
     Hardware" highlight) -- fixed rather than theme-derived like --ink
     since the point is that it reads the same bright blue regardless of
     light/dark mode. */
  --marquee-accent: #2f7fff;

  /* The site's one recurring "highlight" gradient -- originally built
     for "All Things Hardware"'s chrome sheen (see
     .focus-dial-item--accent .focus-dial-item-text::before), reused
     as-is for the pricing Retainer card's animated stroke and for the
     magnetic-gradient-text cursor reveal (see that section further down).
     Fixed rather than theme-derived, same reasoning as --marquee-accent
     above. Recolored from the original navy/blue chrome sheen to the
     violet family introduced for the gallery card hover scrim -- same
     0/26/51/76/100 stop structure and center D2E8FF highlight, just
     retinted (4003EE/4200F7 in place of 0C1F38/0756AE). */
  --highlight-gradient: linear-gradient(
    120deg,
    #4003ee 0%,
    #4200f7 26%,
    #d2e8ff 51%,
    #4200f7 76%,
    #4003ee 100%
  );
}

* { box-sizing: border-box; }

html {
  margin: 0;
  padding: 0;
  background: var(--bg);
  color: var(--ink);
}

body {
  margin: 0;
  padding: 0;
  /* min-height (not height) -- a fixed height:100% here would clip body
     at the viewport and quietly break position:sticky on any content
     taller than one screen, like a project page's scrolling image
     column. min-height still guarantees the homepage's short page fills
     the viewport, while letting taller pages grow normally. */
  min-height: 100vh;
  /* Flat color first (the transition below still fades this on a theme
     toggle) with the animated gradient painted over it -- background
     is a shorthand that would otherwise wipe out background-image, so
     color and image are set as two separate properties instead of one
     "background: var(--bg)" line. background-attachment: fixed pins it
     to the viewport instead of the (very tall) document, which is what
     lets .site-header share this exact same gradient, size, position
     and animation below and have it line up pixel-for-pixel with the
     body showing through everywhere else -- same canvas, same window
     onto it, rather than two separately-colored panels that only ever
     matched by coincidence.

     background-size is a deliberately modest 120% (not the much larger
     size a lot of "animated gradient" tutorials use): this is a plain
     two-color gradient, not a multi-stop rainbow one, so blowing it up
     to e.g. 400% spreads that one color transition over a canvas 4x the
     viewport in each dimension -- any single viewport-sized crop of it
     then only ever shows a tiny, barely-changing sliver of the total
     transition, which reads as a flat, static color rather than a
     gradient at all (confirmed by sampling opposite corners at several
     sizes: contrast dropped from ~12 RGB units at 100% to ~1 at 200%).
     120% keeps the transition compressed enough to still clearly read
     as a gradient while leaving just enough excess canvas for the
     position keyframe below to pan across and actually move it. */
  background-color: var(--bg);
  background-image: var(--bg-gradient);
  background-size: 120% 120%;
  background-attachment: fixed;
  animation: bg-gradient-drift 9.8s ease infinite;
  position: relative;
  color: var(--ink);
  transition: background-color 0.3s ease, color 0.3s ease;
  display: flex;
  flex-direction: column;
  font-family: var(--font-body);
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;
}

/* Diagonal sweep (both X and Y animate together) rather than a
   horizontal-only pan -- the light theme's gradient is a plain
   "to top" one (varies only vertically), so animating X alone while Y
   sat fixed at 50% panned across a dimension that gradient doesn't
   vary in at all, which is why it visibly wasn't moving. Moving both
   axes guarantees real, visible drift no matter what angle a given
   theme's gradient happens to use. */
@keyframes bg-gradient-drift {
  0% { background-position: 0% 0%; }
  50% { background-position: 100% 100%; }
  100% { background-position: 0% 0%; }
}

@media (prefers-reduced-motion: reduce) {
  body,
  .site-header,
  .omni-gallery {
    animation: none;
  }
}

/* Very faint tiled noise, fixed over the whole viewport -- a static
   texture (not itself animated: flickering grain reads as noisy/busy
   rather than subtle) that sits above everything via z-index but
   affects nothing else, since pointer-events:none takes it out of the
   hit-testing tree entirely. mix-blend-mode: overlay is what lets it
   read as film grain sitting on top of the page rather than a flat
   gray haze; the opacity is deliberately low enough that it's felt
   more than seen. */
body::after {
  content: "";
  position: fixed;
  inset: 0;
  z-index: 999;
  pointer-events: none;
  opacity: 0.035;
  mix-blend-mode: overlay;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/><feColorMatrix type='saturate' values='0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
  background-repeat: repeat;
  background-size: 180px 180px;
}

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

/* ---------------------------------- header ---------------------------- */

.site-header {
  flex: 0 0 auto;
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: start;
  padding: 22px 40px;
  position: relative;
  /* Same gradient, size, fixed attachment and animation as body (see
     above) instead of a flat var(--bg) fill -- a solid color was a
     visibly different swatch from the animated gradient it sits on top
     of (worst on the sticky pages, where the two sat side by side for
     the entire scroll). Because both are attachment: fixed and running
     the identical keyframe, the header shows exactly the same slice of
     the same canvas the body shows right below it, at every scroll
     position and every animation frame -- not an approximation of a
     match, an actual one -- while staying fully opaque so scrolled-
     under content never shows through. */
  background-color: var(--bg);
  background-image: var(--bg-gradient);
  background-size: 120% 120%;
  background-attachment: fixed;
  animation: bg-gradient-drift 9.8s ease infinite;
  z-index: 20;
}

/* used on project pages, where the page scrolls vertically and the
   header needs to stay put above the scrolling image column -- see
   .project-info below, which reads the header's rendered height (set
   as --header-h by js/main.js: syncHeaderHeight) to sit flush under it */
.site-header--sticky {
  position: sticky;
  top: 0;
}

.nav {
  display: flex;
  align-items: center;
  gap: 28px;
  font-family: var(--font-body);
  font-size: 12px;
  font-weight: 500;
  letter-spacing: 0.02em;
  text-transform: uppercase;
}

/* Pinned to explicit grid lines (not just source order) so this still
   works on pages with no middle item to occupy column 2 -- without an
   explicit column there, grid auto-placement would put nav--right into
   the "auto" middle column instead of the right one, landing it (and
   About/Pricing/Contact inside it) dead center instead of at the edge. */
.nav--left { grid-column: 1; justify-self: start; }
.nav--right { grid-column: 3; justify-self: end; }

/* The homepage's own header logo (#headerLogo) -- invisible until
   initHeaderLogoOnScroll() (js/main.js) toggles .is-scrolled on
   .site-header, which happens once the big hero wordmark
   (.home-hero-mark) scrolls out from under the header. A plain
   .logo-block (as used for the "back home" logo on services.html/
   gallery.html/project pages) would render immediately; this variant
   layers a fade + slight scale-up on top of that shared look instead of
   replacing it, so once revealed it's styled identically to those. */
.logo-block--scroll {
  grid-column: 2;
  justify-self: center;
  background: none;
  border: none;
  font: inherit;
  color: inherit;
  cursor: pointer;
  opacity: 0;
  transform: scale(0.92);
  transition: opacity 0.3s ease, transform 0.3s ease;
  pointer-events: none;
}

.site-header.is-scrolled .logo-block--scroll {
  opacity: 1;
  transform: scale(1);
  pointer-events: auto;
}

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

.nav a {
  position: relative;
  padding-bottom: 2px;
}

.nav a:hover {
  opacity: 0.55;
}

/* Marks whichever nav item matches the page you're already on (set in the
   HTML, not computed) -- muted rather than removed, since it's still a
   real link that takes you home instead of reloading the current page. */
.nav a.is-current {
  color: var(--gray);
}

/* The project page's (Back) link, now just an arrow glyph -- sized up
   and released from .nav's uppercase/letter-spacing (a glyph doesn't
   need either, and this is the primary way back to the homepage, not
   just another nav label, so it reads at a glance rather than blending
   into "Services"/"Glance" next to it). */
.project-back {
  font-size: 18px;
  text-transform: none;
  letter-spacing: normal;
}

.logo-block {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: 10px;
}

.logo-mark {
  white-space: nowrap;
  font-family: var(--font-wordmark);
  font-weight: 400;
  font-size: clamp(28px, 5.2vw, 72px);
  /* -8px at this element's own max (72px) -- in em so the same tightness
     holds at every size in the clamp() above instead of collapsing into
     overlapping letters at the small end (a flat px value would stay
     -8px even at 28px, where that's most of a character's width). */
  letter-spacing: -0.1111em;
  line-height: 0.92;
  text-transform: lowercase;
}

.logo-sub {
  white-space: nowrap;
  font-family: var(--font-body);
  /* Scales down just enough to stay on one line at narrow widths instead
     of wrapping -- 11px is the desktop size; below ~414px viewport width
     it shrinks along with the viewport, down to an 8.5px floor. */
  font-size: clamp(8.5px, 2.66vw, 11px);
  font-weight: 500;
  letter-spacing: 0.03em;
  text-transform: uppercase;
  color: var(--gray);
}

/* ------------------------------ services marquee ------------------------ */

/* A looping ticker band between the header and the hero -- reads like a
   tour-poster strip of everything the studio does. Two matched groups
   sit side by side in one inline-flex track; sliding the track exactly
   -50% moves precisely one group's width, so the moment it "loops" is
   invisible and it reads as endless. Hairline borders (rather than an
   inverted fill) keep it a distinct band without darkening the page.
   js/main.js's initServicesMarquee() re-fills each group with however
   many repeats of the service list it takes to individually exceed the
   band's own width -- with only one repeat each (the markup's starting
   point), a wide enough window shows more than one group's worth of
   space at once, and the band runs out of content and goes blank before
   the loop resets. Keeping both groups equal, however many repeats that
   takes, is what keeps the -50% = "one group" math correct. */
.services-marquee {
  flex: 0 0 auto;
  overflow: hidden;
  background: var(--bg);
  border-top: 1px solid var(--gray-line);
  border-bottom: 1px solid var(--gray-line);
  white-space: nowrap;
}

.services-marquee-track {
  display: inline-flex;
  width: max-content;
  animation: services-marquee-scroll 26s linear infinite;
}

.services-marquee:hover .services-marquee-track {
  animation-play-state: paused;
}

.services-marquee-set {
  display: inline-flex;
  align-items: center;
  flex: 0 0 auto;
}

.services-marquee-item {
  /* em padding so the band's own breathing room scales up right along
     with the oversized type instead of looking cramped at the top end
     of the clamp() or bloated at the bottom. */
  padding: 0.3em 0.55em;
  font-family: var(--font-body);
  font-size: clamp(26px, 5.1vw, 74px);
  font-weight: 600;
  line-height: 1;
  letter-spacing: -0.01em;
  text-transform: lowercase;
  color: var(--ink);
}

.services-marquee-item--accent {
  color: var(--marquee-accent);
}

.services-marquee-dot {
  font-size: clamp(26px, 5.1vw, 74px);
  line-height: 1;
  color: var(--gray);
}

@keyframes services-marquee-scroll {
  from { transform: translateX(0); }
  to { transform: translateX(-50%); }
}

@media (prefers-reduced-motion: reduce) {
  .services-marquee-track {
    animation: none;
  }
}

/* ---------------------------------- home hero ---------------------------- */

/* The wordmark's real moment -- promoted out of the header into its own
   section once the header settled into the small, universal mark above.
   Fills whatever vertical space is left above the gallery and centers
   the mark + tagline inside it. */
.home-hero {
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  /* This padding is NOT what controls how high the row sits any more --
     .home-main now has an explicit height (calc(100vh - header height),
     see below) and .home-hero's flex:1 1 auto always grows to consume
     every pixel of that fixed height .gallery doesn't use, so any change
     to this padding just changes how much of .home-hero's own (already
     stretched) box sits above vs. below the row, and its box height
     re-expands to erase it -- a straight cut here that isn't mirrored
     against .gallery's height by the same math is invisible. That's why
     moving the row (and the gallery below it) up is done with a single
     shared transform on .home-main itself instead -- see
     --home-hero-shift below -- rather than by tuning padding here. */
  /* padding-top stays a flat 80px rather than shrinking with
     --gallery-hero-budget the way --card-h and the wordmark's font-size
     do (both below) -- .home-hero-mark carries its own fixed
     transform: translateY(-60px) (a flat pixel nudge that does NOT
     shrink with anything), measured from wherever this padding places
     the wordmark before the nudge. Shrinking this padding on a short
     viewport would just hand the nudge more room to pull the wordmark
     back up past the header, reopening the exact clipping the budget
     split on --card-h and the font-size exists to prevent -- so this
     one stays fixed and the other two absorb all of the shrinking. */
  padding: 80px 24px 0;
  min-height: 0;
}

.home-hero-mark {
  margin: 0;
  /* Down one step from 3x to 2x the original clamp (17/4.4vw/83 -- see
     git history for the two earlier passes before that) -- each value
     reduced by exactly that original 1x amount (51-17, 13.2-4.4, 249-83)
     so the clamp keeps scaling the same way across viewport widths, just
     at double rather than triple the original size.
     min(8.8vw, ...) instead of a plain 8.8vw -- sizing this purely off
     viewport WIDTH was the actual bug behind "the site isn't
     responsive": on a wide-but-short window this grew right up to its
     166px ceiling regardless of how little vertical room .home-main
     actually had (a hard-clipped height: calc(100vh - header) box, see
     its comment), and the excess got clipped clean off by the header
     instead of the text ever shrinking to fit.
     The added term is this wordmark's own share of
     --gallery-hero-budget (see that variable and --card-h, both above)
     rather than a plain vh percentage -- a straight Nvh clamp can't
     both hold the current 166px at today's normal viewport heights AND
     guarantee no overflow at short ones, because it scales with the
     *whole* viewport while the actual overflow risk scales with what's
     left after a fixed header + gallery overhead; --gallery-hero-budget
     already does that subtraction once, so every clamp reading from it
     inherits a guarantee the others don't fight over.
     0.247 comes from the same budget breakdown as --card-h's 0.772: of
     the 670.7px this wordmark + --card-h split between them at the
     original numbers (518px card + this text's ~152.7px rendered
     height, i.e. 166px font at this typeface's ~0.92 height-to-font-size
     ratio), the text itself is 152.7/670.7 = 0.2277 of the budget --
     divided by that same 0.92 ratio to convert a target rendered-height
     share back into a font-size share gives 0.247 (0.772 + 0.2277 =
     1.0 is the same budget accounted for twice, once as each side's
     share, not two independent guesses -- that's what actually keeps
     the card and the wordmark fitting together at any height instead of
     just individually avoiding their own overflow; .home-hero's own
     fixed 80px padding-top sits outside this split entirely -- see its
     comment and --gallery-hero-budget's for why). */
  font-size: clamp(34px, min(8.8vw, calc(var(--gallery-hero-budget) * 0.247)), 166px);
  letter-spacing: -0.0595em;
  /* A direct nudge on the wordmark itself, not a tweak to .home-hero's
     padding-top -- that padding is silently re-absorbed by .home-hero's
     own flex: 1 1 auto growth inside .home-main's fixed height (see the
     comment on .home-hero's padding above), so it can't actually move
     anything on screen. A transform on this element bypasses that
     entirely. */
  transform: translateY(-60px);
}

.home-hero-tagline {
  margin: 18px 0 0;
}

/* ---------------------------------- gallery ---------------------------- */

/* Now that the hero above takes the centered position the gallery used
   to hold, the gallery settles low in the remaining space instead --
   flex: 0 0 auto keeps it at its own content height rather than sharing
   .home-main's flex space with the hero, and the bottom padding keeps it
   off the very edge of the viewport.

   height is pinned to exactly one viewport (minus the header) rather
   than left to size from content -- flex: 1 1 auto with no explicit
   height used to mean .home-main just sized to whatever the hero+
   gallery content added up to, which could (and at most realistic
   viewport heights, did) fall short of 100vh, leaving the services
   divider bar and the top sliver of the dial peeking into view before
   any scrolling happened. Fixing the height instead means the hero
   screen always fills the viewport exactly, on every screen size, and
   the services section stays fully offscreen until you actually
   scroll -- overflow:hidden above is what crops hero+gallery content
   if it's ever taller than the space this leaves, same trade-off the
   old content-sized box made implicitly. */
.home-main {
  flex: 0 0 auto;
  height: calc(100vh - var(--header-h, 70px));
  display: flex;
  flex-direction: column;
  overflow: hidden;
  min-height: 0;
  /* Nudges the whole hero screen -- wordmark, rotary dial, gallery grid,
     all of it -- up together as one rigid unit, leaving a bit more
     background showing at the bottom of this screen instead (still
     cropped to exactly one viewport by the fixed height + overflow:
     hidden above, so nothing new peeks into view early). This has to be
     a transform on the whole block rather than tuning .home-hero's
     padding-top or .gallery's margin-top individually: both of those
     live inside this now-fixed-height flexbox, where .home-hero's
     flex: 1 1 auto always grows to soak up whatever height .gallery
     doesn't use (see the comment above this rule) -- so a change to
     either one just gets silently re-absorbed by that growth instead of
     actually moving anything on screen. 24px is a 30% cut off the 80px
     gap used elsewhere on this screen (.home-hero's own padding-top,
     .gallery's margin-top) -- picked to match that scale, not tied to
     it structurally. */
  transform: translateY(-24px);
}

/* the gallery itself is the horizontally-scrollable region -- vertical
   wheel/trackpad input is redirected to scrollLeft in main.js, so a
   normal scroll gesture moves the row sideways instead of the page
   scrolling down. Native horizontal trackpad swipes and the scrollbar
   (hidden below, but still draggable on some platforms) keep working
   as-is since this is real scroll, not an animation. */
.gallery {
  flex: 0 0 auto;
  width: 100%;
  overflow-x: auto;
  overflow-y: hidden;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
  padding-bottom: 56px;
  margin-top: 80px;
  /* margin-top on .gallery does NOT move it on screen -- .home-main is a
     fixed-height flex column and .home-hero has flex: 1 1 auto, so any
     margin-top removed here is silently re-absorbed by .home-hero growing
     to fill the freed space (verified: forcing margin-top from 20px to
     80px produced an identical gallery.getBoundingClientRect().top). This
     is the same absorption problem already documented on .home-main's own
     transform rule below, just not exempted by flex: 0 0 auto the way it
     was assumed to be. margin-top is left at a sensible 80px (spacing from
     the hero) and the actual vertical position is controlled entirely by
     the transform below, the same fix already used on .home-hero-mark. */
  transform: translateY(-60px);
}

.gallery::-webkit-scrollbar {
  display: none;
}

.gallery-track {
  display: flex;
  align-items: flex-start;
  /* No flex `gap` -- js/main.js's updateGalleryGaps() sets each card's
     own margin-right per frame instead, since the spacing needs to
     track each card's live position on screen (see --gallery-gap-max
     above), not stay a flat constant. */
  width: max-content;
  padding: 0 40px;
}

.card {
  /* width is intentionally unset -- aspect-ratio derives it from
     height (--card-h below), so every card is the same fixed 4:5 size. */
  aspect-ratio: 4 / 5;
  height: var(--card-h);
  flex: 0 0 auto;
  position: relative;
  /* .card-image-wrap below is absolutely positioned against this box, and
     is what actually grows on hover -- .card itself never changes size,
     so .gallery-track's auto height (and everything upstream of it: the
     fixed-height .home-main, the hero) never reflows when a card grows.
     See the comment on .card-image-wrap for how the growth stays visible
     instead of getting clipped. */
}

.card-image-wrap {
  position: absolute;
  inset: 0;
  overflow: hidden;
  width: 100%;
  background: var(--card-placeholder);
  /* Pinned to top/left/right; only `bottom` moves on hover (see below),
     so the card visually grows taller downward, into the slack .gallery's
     padding-bottom leaves under the row, without .card's own box (and so
     .gallery-track's auto-sized height) ever changing. Transform would've
     been the safer choice on paper, but scaling this box would stretch
     the photo inside it -- a real height/inset change instead lets
     object-fit: cover reveal more of the source image, undistorted. */
  transition: bottom 0.45s cubic-bezier(0.16, 1, 0.3, 1);
}

.card:hover .card-image-wrap {
  /* 40px of growth against 56px of padding-bottom slack on .gallery --
     comfortably inside it, so nothing gets clipped by .gallery's
     overflow-y: hidden. */
  bottom: -40px;
}

/* Sized 1px larger than the wrap on every edge (inset: -1px) instead of a
   plain 100%/100% -- the wrap's height comes from flex layout and can land
   on a fractional pixel, and a flush-fit image rounds to the nearest device
   pixel independently of its box, which sometimes leaves a hairline gap at
   the top or bottom edge where the dark --card-placeholder shows through as
   a thin black stroke. Overscanning by 1px always covers that gap, and at
   this size it's not visible as any kind of crop. */
.card-image-wrap img {
  position: absolute;
  inset: -1px;
  width: calc(100% + 2px);
  height: calc(100% + 2px);
  object-fit: cover;
  display: block;
}

/* Bottom scrim, faded in on hover behind the title/category text below --
   colors and stop positions taken directly from the reference gradient
   (9822B9 -> 4200FA), not derived from the site's own dark palette, since
   this is a deliberate new accent introduced specifically for this hover
   state. Covers the full image height (not just the text's padding box)
   so the transition reads the same way the reference does: invisible
   until ~41% down, then warming into solid violet-blue at the bottom. */
.card-scrim {
  position: absolute;
  inset: 0;
  background: linear-gradient(
    to bottom,
    rgba(152, 34, 185, 0) 41%,
    rgba(66, 0, 250, 0.7) 67%,
    rgba(66, 0, 250, 1) 100%
  );
  opacity: 0;
  transition: opacity 0.4s ease;
  pointer-events: none;
}

.card:hover .card-scrim {
  opacity: 1;
}

/* Title/category, overlaid bottom-left on the image itself (on top of the
   scrim above) instead of living below the image in normal flow. */
.card-info {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  padding: 18px;
  opacity: 0;
  transform: translateY(10px);
  transition: opacity 0.3s ease, transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
  pointer-events: none;
}

.card:hover .card-info {
  opacity: 1;
  transform: translateY(0);
}

.card-title {
  /* Same display face/weight/tracking as every other title on the site
     (.services-card-title, .pricing-card-title, .stat-number) -- this
     used to run on the body font at a bold weight with positive
     tracking, the one title on the page that didn't match the rest of
     the type system. Project names are the main event of this page, so
     they get the same display treatment as everything else that's
     labeled a "title." clamp() instead of a flat px so it scales with
     the viewport like every other sized-up text on the page now does.
     Doubled back up (clamp(15px, 1.7vw, 22px) -> 30/3.4vw/44) since the
     smaller size was reading as an afterthought against how much
     louder the rest of the site's display type has gotten since this
     was last tuned (services titles, stat numbers).
     Was white-space: nowrap + overflow: hidden, with the name and year
     forced onto one line no matter what -- at this bigger size,
     "Station XT1 - 2026" and "Google G2 - 2026" (this card's actual
     content, not a hypothetical edge case) both run past the card's
     own width. text-overflow: ellipsis looked like the fix but doesn't
     apply inside a flex row (it only works on a single block/inline-
     block box), so it was silently doing nothing -- the name/year was
     just getting hard-clipped mid-character with no "..." at all.
     flex-wrap: wrap stopped the clipping but produced its own bad
     outcome: "- 2026" dropping to a same-size orphan line under the
     name looked broken, not intentional. The real fix lives on
     .card-year now -- it's sized in em off this element's own
     font-size instead of inheriting it at full size, so "- 2026" is
     small enough to sit on the same line as even the longest name
     ("Station XT1 - 2026") at any viewport width. flex-wrap stays here
     only as a safety net for viewports narrow enough that even the
     shrunk year can't fit -- not the primary fix anymore. */
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0 6px;
  font-family: var(--font-display);
  font-weight: 400;
  font-size: clamp(30px, 3.4vw, 44px);
  line-height: 1.05;
  letter-spacing: -0.01em;
  color: var(--ink);
  margin: 0;
}

.card-year {
  /* Used to inherit .card-title's own huge clamp() size, which is why
     "Station XT1 - 2026" and "Google G2 - 2026" were wide enough to
     wrap "- 2026" onto its own line after the title size was doubled
     (see .card-title's comment) -- flex-wrap stopped the text from
     clipping, but a same-size orphan line reading "- 2026" under the
     name doesn't look intentional. Sized relative to the title's own
     font-size (em, not a flat px) so it scales down with it at any
     viewport width instead of one fixed value that's right at only one
     size. Small and muted enough that it reads as a caption next to the
     name rather than a second half of the same headline, and short
     enough that even the longest project name + year now fits on one
     line without wrapping; flex-wrap stays on .card-title as a safety
     net for very narrow viewports rather than the fix itself. */
  font-size: 0.34em;
  font-weight: 400;
  letter-spacing: 0.01em;
  color: var(--gray);
  white-space: nowrap;
  flex: none;
  align-self: baseline;
}

.card-category {
  font-family: var(--font-body);
  font-size: 11px;
  font-weight: 400;
  letter-spacing: 0.02em;
  color: var(--gray);
  margin: 6px 0 0;
}

/* ------------------------- section label (shared) ------------------------ */

/* A real chapter title, not a small in-card eyebrow -- "Services",
   "About" are the only two headings this page has, so they get the
   site's display face (same family as .services-card-title,
   .pricing-card-title, .stat-number, the hero wordmark) at a size that
   actually reads as a title next to the body copy it's labeling,
   instead of blending into micro-copy like .pricing-card-eyebrow or
   .stat-label. Uppercase + tracking is what keeps it looking like a
   label/marker rather than just another item title at this size --
   that distinction is what separates "chapter heading" from "list item
   name" now that both are the same font family. */
.section-label {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 400;
  font-size: clamp(22px, 2.4vw, 30px);
  letter-spacing: 0.02em;
  text-transform: uppercase;
  line-height: 1;
  color: var(--ink);
}

/* ---------------------------------- services ------------------------------ */

/* Centered, stacked rows -- one huge title per service with its subtext
   and tags underneath, modeled on ondastudio.co's services section
   structure (not its copy: these are still Pran's own six service
   names/descriptions, just laid out the way Onda lays theirs out).
   Full-bleed like .stats-grid/.pricing-grid elsewhere on the site --
   the section itself carries no horizontal padding, so its border-top/
   border-bottom and every row's own border-top all run true edge to
   edge; .section-label and .services-card each carry their own 40px
   side padding to stay inset from those edges without breaking the
   lines. scroll-margin-top keeps the #services nav link from landing
   under the sticky header. */
.services-section {
  border-top: 1px solid var(--gray-line);
  border-bottom: 1px solid var(--gray-line);
  scroll-margin-top: var(--header-h, 70px);
}

/* In normal flow above the list (not position:absolute) -- there's no
   second column for it to float beside anymore, so it reads as the
   section's own opening line, same as Onda's top-left "OUR SERVICES"
   eyebrow sitting above their title stack. Its own border-bottom is
   what closes the "no incomplete lines" rule here: the label's padding
   insets the text, but a border always draws at the full border-box
   edge regardless of padding (same trick .services-card uses below),
   so this still runs the section's true full width, not just the width
   of the padded text. Without it, the label sat in open air with no
   line separating "//SERVICES" from "01 AUDIT" -- every other labeled
   chapter on this page (#about, #pricing's own cards) closes off its
   heading with a hairline before the content starts; this was the one
   gap. */
.services-section > .section-label {
  margin: 0;
  padding: 40px 40px 32px;
  border-bottom: 1px solid var(--gray-line);
}

.services-list {
  display: flex;
  flex-direction: column;
}

/* One row per service, center-aligned, hairline-divided between rows --
   border-top on every card but the first (the label above already
   closes that gap without needing its own line; see :first-child
   below), no border-bottom on the last since the section's own
   border-bottom already closes it out. Generous vertical padding is
   what gives each huge title room to breathe -- this is a stack of six
   standalone statements, not a dense list. No card-wide cursor effect
   here (a whole-row radial glow was tried and cut -- it read as busy
   competing with the title's own magnetic-gradient-text reveal, not a
   complementary layer), so this row no longer needs position:relative/
   overflow:hidden either -- nothing inside it is positioned relative to
   the card anymore. */
.services-card {
  padding: 64px 40px;
  text-align: center;
  border-top: 1px solid var(--gray-line);
}

.services-list .services-card:first-child {
  /* Would otherwise sit right under .services-section's own border-top,
     doubling it into two near-touching lines. */
  border-top: none;
}

/* Keyboard focus (tabbing to a card, which is what actually drives the
   :focus-within description reveal -- see .services-card-desc-wrap
   below) gets the site's own hairline-outline language instead of the
   browser default ring, same reasoning as .pricing-cta:focus-visible. */
.services-card:focus-visible {
  outline: 1px solid var(--gray-line);
  outline-offset: -1px;
}

/* The headline move this whole redesign is built around: one huge,
   centered title per row -- ondastudio.co's stacked-title services
   pattern, applied to Pran's own six service names rather than theirs.
   The row's ordinal now rides inline with the title itself
   ("01//audit") instead of floating above it as its own small line --
   a standalone 12px number read as an afterthought bolted onto the
   design rather than a considered part of it; folding it into the
   headline as a run of real body-weight text fixes that. .services-card-
   index below carries the "01//" in Geist Mono SemiBold (a real bold
   file, unlike the display face -- see that rule), now at the SAME
   font-size as the title itself (1em, not a fraction of it) so the two
   read as one continuous headline of equal weight-by-scale rather than
   a big word with a small tag stuck to it; the word itself sits in a
   nested .magnetic-gradient-wordmark span (not this outer h3) so the
   cursor-follow gradient reveals over "audit" alone, never over its
   ordinal. See the note further down at .services-card-title
   .magnetic-gradient-text for why that inner span is itself split into
   per-cluster leaves rather than being one leaf for the whole word. */
.services-card-title {
  margin: 0;
  font-family: var(--font-display);
  /* Azeret Mono only has a Regular file -- see the note at the top of
     this file. Lowercase + negative tracking is what reads as a
     considered, weighted headline without requesting a synthetic bold
     that would look muddy on this face's letterforms -- the same
     scale-not-weight trick .stat-number and the hero wordmark use.
     Tracking matches .logo-mark's own -0.1111em exactly (same em-based
     value, so it stays proportionally this tight at every size in the
     clamp() below, not just at one fixed px width) -- this section's
     titles and the site's own wordmark are meant to read as the same
     display-type family of moves, not two different tightness rules. */
  font-weight: 400;
  font-size: clamp(40px, 7.4vw, 100px);
  letter-spacing: -0.1111em;
  text-transform: lowercase;
  line-height: 0.96;
  color: var(--ink);
}

/* The "01//" ordinal -- generated in renderServicesList(), not
   hand-typed per entry, so it stays correct if a service is added,
   removed, or reordered. Previously sat at 1em (the same size as the
   title word next to it) so the two would read as one continuous run --
   in practice that made the ordinal look like it was supposed to catch
   the title's cursor-follow gradient too (see .magnetic-gradient-text
   below), and its staying flat gray while "audit" lit up read as a
   broken effect rather than a deliberate exclusion. Shrunk to a small
   fraction of the title's size (same "caption riding next to a huge
   headline" move as .card-year elsewhere) so it now reads unambiguously
   as a small ordinal, not a second half of the gradient-eligible title.
   vertical-align: middle centers it against the title's midline instead
   of sitting low on its own baseline, which is what "center aligned
   with the title" means here -- the two are wildly different point
   sizes now, so baseline alignment alone left it looking like it was
   sinking below the headline. Geist Mono SemiBold is a real weight file
   (unlike the display face above), so this genuinely bolds rather than
   faking it. */
.services-card-index {
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 0.28em;
  vertical-align: middle;
  /* Geist Mono is monospace -- every character, including "/", sits in
     the same fixed-width cell, and a "/" glyph's own ink is a thin
     diagonal stroke that only fills a fraction of that cell. Two of
     them back to back at letter-spacing:0 (each keeping its full cell
     width) read as a wide gap, not a doubled slash. Tightened to pull
     "0", "1", and both "/"s into one dense run instead. */
  letter-spacing: -0.08em;
  font-variant-numeric: tabular-nums;
  color: var(--gray);
  /* Bigger than the old 0.02em now that font-size dropped -- em margins
     resolve against the element's OWN font-size, so the same 0.02em
     would shrink to a near-invisible gap at this much smaller size.
     0.35em (of the now-smaller ordinal) reads as a clear, deliberate
     space between ordinal and title rather than a tight kerned run --
     that tight-run reading was the point at 1em, but no longer fits
     now that the two are different scales. */
  margin-right: 0.35em;
}

/* Titles used to be one plain .magnetic-gradient-text leaf covering the
   whole word -- a single ::before duplicate of the entire string (content:
   attr(data-text)) laid out under this title's own heavy negative tracking
   (-0.1111em, see .services-card-title above). That duplicate is a SEPARATE
   text run from the real one next to it, and Chromium doesn't shape the two
   identically character-by-character under tracking that tight: the
   duplicate's letters drift a fraction of a pixel out of alignment with the
   real ones as the string gets longer, most visible as a sliver of the
   flat base color surviving at the trailing edge of specific letters
   ("it", "rch", "egy" -- the sharp white edge Pran kept flagging).
   Rounding the overlay's total WIDTH up a couple of px (fixOverlayWidths()
   in js/main.js) only reconciled where the run ENDS, not the per-character
   alignment running through it, so the edge survived every radius/width
   tuning pass.

   Fixed by copying the hero wordmark's own architecture instead of
   patching this one further: renderServicesList() now wraps each title in
   a .magnetic-gradient-wordmark span (see that class further down), and
   applyLigatureSafeTracking() in js/main.js splits it into small
   per-character (ligature-safe) clusters with real DOM margins standing in
   for tracking -- so there is no shared CSS letter-spacing left on any
   multi-character run for Chromium to shape inconsistently. Each cluster
   becomes its own tiny .magnetic-gradient-text leaf (inline-block via the
   generic .magnetic-gradient-wordmark .magnetic-gradient-text rule below),
   and initMagneticWordmarks() gives every title its own independent root
   with its own cursor state -- unlike the hero mark and footer marquee,
   which are each a single shared wordmark, the six service titles are six
   separate roots, so hovering one never drives the others.

   Radius still matches .home-hero-mark .magnetic-gradient-text (120px) --
   the wordmark is the one Pran confirmed reads as genuinely magnetic, and
   this cascades onto the new per-cluster leaves exactly the way it did
   onto the old single-span leaf, since this is still a plain descendant
   selector. 120px stays proportionate at the per-character scale a cluster
   leaf is measured at, same as it does for the hero mark's own clusters. */
.services-card-title .magnetic-gradient-text {
  display: inline-block;
  --magnetic-r: 120px;
}

/* The real bug behind every "g"/"y" seam report, finally found: the
   ::before overlay is absolutely positioned with inset:0, so its
   background-clip: text paint area is exactly the leaf's own box --
   and this title's line-height (0.96, tighter than the font's natural
   line box) already lets "g"/"y" descenders draw BELOW that box on the
   plain base text. Invisible there, because normal painted text has no
   clip boundary and just overflows the line box. Not invisible on the
   overlay: background-clip: text confines the actual PAINTING of the
   gradient image to the box's padding edges, even though the clip
   SHAPE follows the glyph outline -- so the sliver of descender poking
   out below the box gets no gradient paint at all, and the flat base
   color shows through there as a seam (or, briefly, as fully missing
   ink when an earlier attempt at this fix put the SAME background-clip
   on the base layer too, removing its always-visible fallback color).
   Fixing the base layer was never the right target -- it was never
   clipped in the first place. Extending the overlay's own box past the
   line box (below, via bottom instead of inset's implicit 0) so its
   background-clip paint area comfortably contains the full descender
   is the actual fix, and it costs nothing: this box is absolutely
   positioned, so a taller one doesn't push on layout, and the extra
   area below the glyph is simply background with no text shape to
   clip to, so nothing paints there either. */
.services-card-title .magnetic-gradient-text::before {
  bottom: -0.4em;
}

/* Always-visible subtext -- the one-line "(subtext)" copy under the
   title in every row, as opposed to the longer per-tag detail copy in
   .services-card-detail below, which only shows when the title itself
   is hovered/focused. Centered and margin:auto so its max-width block
   sits in the middle of the row, not just its own text. */
.services-card-subtext {
  position: relative;
  margin: 16px auto 0;
  max-width: 560px;
  font-family: var(--font-body);
  font-size: 13px;
  font-weight: 400;
  line-height: 1.6;
  color: var(--gray);
}

.services-card-tags {
  position: relative;
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 10px;
  margin-top: 20px;
}

/* Pills, restored -- this is how service tags read in the earlier
   rotary-dial version of this section; the plain unstyled <span> they'd
   been left as (no rule existed for this class at all) was a
   regression, not a redesign. Fully rounded per Pran's call -- a
   deliberate one-off break from the site's otherwise-square,
   zero-radius language, scoped to just this chip. Shared with
   .pricing-card-tags, which uses the same class and gets the same
   pill back. Padding/font trimmed down from the first pass (was 6px
   14px / 12px) -- a bordered, filled-looking shape carries more visual
   weight than plain text at the same size, and three of them per row
   were outweighing .services-card-title above them. Smaller and
   tighter reads as metadata attached to the title, not a second
   headline competing with it. */
.services-tag {
  display: inline-flex;
  align-items: center;
  padding: 4px 10px;
  border: 1px solid var(--gray-line);
  border-radius: 999px;
  font-family: var(--font-body);
  font-size: 11px;
  font-weight: 400;
  line-height: 1.4;
  letter-spacing: 0;
  color: var(--gray);
  white-space: nowrap;
}

/* Services tags only -- not .pricing-card-tags's, which reuses the bare
   .services-tag class but is scoped out here via the .services-card-tags
   ancestor -- get a cursor-follow glow on the pill's own FILL, not its
   text. A text-clip gradient (like the title's) was tried first and
   read as the wrong effect at 11px; this washes the whole chip's
   background instead, the same radial-wash idea the row itself used to
   have (see the removed .services-card::before), just re-scoped down to
   each pill. position:relative + overflow:hidden contains the glow to
   the pill's own rounded box -- border-radius on the pill clips
   ::before's square corners for free since it inherits the parent's
   clipping via overflow:hidden, no separate border-radius needed on the
   pseudo itself. initMagneticServiceTags() in js/main.js drives
   --mx/--my per tag with the same lerp-toward-the-pointer physics as
   every other magnetic element on this page. */
.services-card-tags .services-tag {
  position: relative;
  overflow: hidden;
}

.services-card-tags .services-tag::before {
  content: "";
  position: absolute;
  inset: 0;
  background: radial-gradient(
    circle 46px at var(--mx, 50%) var(--my, 50%),
    rgba(66, 0, 247, 0.32),
    transparent 70%
  );
  opacity: 0;
  transition: opacity 0.4s ease;
  pointer-events: none;
}

.services-card-tags .services-tag.is-active::before {
  opacity: 1;
}

/* Collapsed to zero height at rest -- grid-template-rows: 0fr -> 1fr is
   what lets this animate open to its real (auto) height on hover/focus
   without JS ever measuring it, and .services-card-desc-inner's
   overflow:hidden is what actually clips the collapsed row (a 0fr grid
   track still has to have something inside it clipping, or the content
   would just overflow the zero-height row instead of hiding in it).
   Expands from the bottom of the card, not the top -- it's the last
   thing in the card, directly under the tags, so growing it is what
   pushes the card's own bottom edge down while the title/tags above
   stay put. */
.services-card-desc-wrap {
  position: relative;
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}

/* Trigger is the whole row -- hovering or keyboard-focusing anywhere on
   .services-card (title, subtext, or tags) opens the detail paragraph,
   same :focus-within pairing as .pricing-card and the rest of the site
   uses for hover-revealed content. Separate from the title's own
   magnet-text effect, which fires independently on just the word. */
.services-card:hover .services-card-desc-wrap,
.services-card:focus-within .services-card-desc-wrap {
  grid-template-rows: 1fr;
}

.services-card-desc-inner {
  overflow: hidden;
}

/* The hover-expanded content: one flowing paragraph -- the per-tag copy
   (intro, when a service has one, then each "Label: sentence" run
   together as prose) is body copy, not a list of sub-headings. Centered
   and margin:auto to sit under the title/subtext above it, same
   treatment as .services-card-subtext. */
.services-card-detail {
  margin: 24px auto 0;
  max-width: 560px;
  font-family: var(--font-body);
  font-size: 12px;
  font-weight: 400;
  line-height: 1.7;
  color: var(--gray);
  opacity: 0;
  transform: translateY(6px);
  transition: opacity 0.35s ease, transform 0.35s ease;
}

.services-card:hover .services-card-detail,
.services-card:focus-within .services-card-detail {
  transition-delay: 0.08s;
  opacity: 1;
  transform: translateY(0);
}

/* ---------------------------------- about (homepage) ---------------------- */

/* Full-bleed label-above-content shape -- the exact same system
   .services-section uses (border-top on the section, the label carrying
   its own padding + border-bottom divider before the content starts),
   not the old side-by-side 160px label column. */
.about-section {
  border-top: 1px solid var(--gray-line);
  scroll-margin-top: var(--header-h, 70px);
}

/* Same padding/divider as .services-section > .section-label -- see
   that rule's own comment for why the border sits at the full
   border-box edge regardless of this padding. */
.about-section > .section-label {
  margin: 0;
  padding: 40px 40px 32px;
  border-bottom: 1px solid var(--gray-line);
}

/* Two-column split below the label: statement copy on the left, the
   numbers on the right (see .stats-grid below) -- stats used to be
   their own full-bleed band underneath the whole statement; now they
   sit right beside the claim they back up instead of trailing after
   it. 3fr/2fr (60/40), not an even 50/50 -- the statement is the
   thing being said and earns the larger half; the numbers are
   supporting evidence and read fine in a tighter column, especially
   now that they're set big enough to still carry real weight there
   (see .stat-number below). Grid's default align-items: stretch is
   what makes the vertical hairline between them (border-left on
   .stats-grid) run the full height of whichever column ends up
   taller. Collapses to a single stacked column -- text above, stats
   below -- at the mobile breakpoint, where any side-by-side split has
   no room to breathe. */
.about-split {
  display: grid;
  grid-template-columns: 3fr 2fr;
}

.about-inner {
  padding: 40px 40px 64px;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 28px;
}

.about-inner p {
  margin: 0;
  /* Secondary (Geist Mono), not the primary display face -- and
     lowercase/justified/regular-weight, all called out explicitly
     rather than left at this section's old display-type defaults. */
  font-family: var(--font-body);
  font-weight: 400;
  text-transform: lowercase;
  text-align: justify;
  font-size: clamp(11px, 3.2vw, 27px);
  line-height: 1.3;
  letter-spacing: -0.01em;
  color: var(--ink);
}

/* ---------------------------------- stats --------------------------------- */

/* The right half of .about-split now, not a separate full-bleed band
   below it -- no section-label heading here (the old "Experience"
   eyebrow is gone), just the vertical hairline (border-left) that
   seams it to the text column. Stacked vertically (single column,
   border-top dividers) rather than the old 3-across row, since this
   column is only half the page width now -- the same border-left ->
   border-top swap the old mobile breakpoint already used for exactly
   this shape, just live at every width instead of narrow ones only. */
.stats-grid {
  display: grid;
  grid-template-columns: 1fr;
  border-left: 1px solid var(--gray-line);
}

.stat {
  /* Less horizontal padding than the old 3-across cards had (this
     column is under half the page width now) so the bigger number
     below still gets real room to run before it'd have to wrap. */
  padding: 56px 24px;
  text-align: center;
  border-top: 1px solid var(--gray-line);
}

.stat:first-child {
  border-top: none;
}

.stat-number {
  margin: 0 0 14px;
  font-family: var(--font-display);
  /* Azeret Mono only has a Regular file -- see note at top of file.
     Sized and tracked to actually read as a statement in this column,
     not a caption -- clamp's ceiling is more than 1.5x the old one,
     and the tighter letter-spacing is the same scale-not-weight trick
     used everywhere else large text on this page needs to look
     considered rather than just stretched (.services-card-title, the
     hero wordmark). */
  font-weight: 400;
  font-size: clamp(44px, 7.5vw, 100px);
  letter-spacing: -0.02em;
  line-height: 1;
  color: var(--ink);
}

.stat-label {
  margin: 0;
  font-family: var(--font-body);
  /* Bumped up from 12px/500 -- the number above it grew a lot in the
     last pass (clamp ceiling went 64px -> 100px) and left this
     caption looking thin and undersized by comparison. Semibold is
     the same weight .services-card-index already uses for a small
     caps run that needs to hold its own against big display type next
     to it, not a one-off pick. */
  font-size: 13px;
  font-weight: 600;
  letter-spacing: 0.03em;
  text-transform: uppercase;
  color: var(--gray);
}

/* ---------------------------------- pricing ------------------------------ */

/* "Pricing" now gets the same full-bleed label + divider treatment as
   #services/#about above it -- border-top on the section closes the
   Stats -> Pricing seam (moved here from .pricing-grid's own border-top,
   see below), and the label carries its own padding + border-bottom
   divider before the grid starts, same system as
   .services-section > .section-label. No bottom padding on the section
   itself -- .pricing-grid closes itself with its own border-bottom (see
   below), so trailing padding here on top of that plus the footer's own
   top padding was just stacking up as dead space between two things
   that already read as separate blocks. */
.pricing-section {
  padding-bottom: 0;
  border-top: 1px solid var(--gray-line);
}

.pricing-section > .section-label {
  margin: 0;
  padding: 40px 40px 32px;
  border-bottom: 1px solid var(--gray-line);
}

/* Edge-to-edge, hairline-divided columns -- same border-left-divider
   language .stats-grid above used when it was still a 3-across row,
   not a set of individually-boxed cards. A row of bordered boxes reads
   as a bolted-on template on a page built entirely out of hairlines and
   divider bands; this keeps the pricing row speaking the same visual
   language as everything above it. No border-top of its own anymore --
   .pricing-section's label already supplies the line directly above
   this grid (see above), so a second one here would double it up. */
.pricing-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  width: 100%;
  /* Closes the row at its own foot the same way the label's divider
     closes it at the top, instead of leaving the cards open-ended
     until whatever comes next (now the footer) supplies a line of its
     own -- the grid should read as a complete, framed block by itself. */
  border-bottom: 1px solid var(--gray-line);
}

.pricing-card {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 16px;
  padding: 64px 40px;
  border-left: 1px solid var(--gray-line);
}

.pricing-card:first-child {
  border-left: none;
}

/* Every card gets one -- reserves the row even on the two cards without
   copy here yet, so titles still line up, and gives each tier a plain-
   language answer to "what kind of engagement is this" before the
   price/title even register. */
.pricing-card-eyebrow {
  margin: 0;
  min-height: 1em;
  font-family: var(--font-body);
  font-size: 12px;
  font-weight: 500;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--gray);
}

.pricing-card-title {
  margin: 0;
  font-family: var(--font-display);
  /* Azeret Mono only has a Regular file -- see note at top of file. */
  font-weight: 400;
  font-size: 22px;
  letter-spacing: -0.01em;
  color: var(--ink);
}

/* The one big headline number/line every card leads with -- a real
   price, "Custom quote", or "Scoped per task" -- so all three read as
   parallel, comparable tiers at a glance instead of Task's tag list
   being the only thing without an equivalent-weight headline above it. */
.pricing-card-price {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 400;
  font-size: clamp(26px, 2.6vw, 34px);
  letter-spacing: -0.01em;
  line-height: 1.1;
  color: var(--ink);
}

/* -------------------------- magnetic gradient text ----------------------- */

/* Cursor-reactive gradient reveal used on three text groups (the about
   statement, the stat numbers, the pricing card titles). The real text
   underneath is untouched -- a duplicate copy is layered on top via
   ::before (content: attr(data-text), same overlay technique as
   .focus-dial-item--accent's chrome sheen further up this file), filled
   with --highlight-gradient and clipped to text, then masked down to a
   soft circle that follows the cursor. initMagneticGradientText() in
   js/main.js drives --mx/--my, lerping toward the live pointer position
   each frame rather than snapping 1:1 to it -- that trailing lag is what
   reads as "magnetic/fluid" instead of a rigid spotlight -- and toggles
   .is-active on enter/leave, which this rule crossfades via opacity
   (gradient-to-gradient can't be transitioned directly; see the note by
   .focus-dial-item--accent for the same constraint). Touch devices get the
   plain, un-overlaid text (see the hover:none query below) since there's
   no persistent pointer position to react to. */
.magnetic-gradient-text {
  position: relative;
  --magnetic-r: 160px;
}

.magnetic-gradient-text::before {
  content: attr(data-text);
  position: absolute;
  inset: 0;
  background-image: var(--highlight-gradient);
  /* Without an explicit size, a background-image maps its full 0%-100%
     run of stops onto the exact width of whatever element it's on --
     fine for a short word, but on a wide one (a long service title, a
     full sentence) that same 0-26-51-76-100% run gets stretched across
     hundreds of extra pixels, and 76%-100% is a return trip back down
     to the SAME dark #4003ee the gradient started at. Hover anywhere
     near the right edge of a wide element and the mask circle mostly
     just uncovers that flat closing stretch -- one solid dark tone with
     none of the light-to-dark sweep visible elsewhere on the same
     word, which reads as the gradient hitting a wall rather than
     continuing to move. 300% + centering fixes this the same way
     .pricing-card--highlight::before already oversizes this exact
     gradient (see that rule): stretching the run out to 3x the
     element's own width means only the MIDDLE third of it (roughly the
     26%-76% span, the medium-violet-to-D2E8FF-peak-and-back sweep) ever
     lands inside the visible box, at any width, so the reveal never
     reaches the flat dark endpoints at all -- there's no "end" left to
     see. */
  background-size: 300% 100%;
  background-position: 50% 50%;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  -webkit-text-fill-color: transparent;
  opacity: 0;
  transition: opacity 0.4s ease;
  pointer-events: none;
  /* A pure 0%->100% linear fade meant the outer half or so of the radius was
     already so faint the base flat color dominated anyway -- on a wide
     title that "barely-there tail" covered a big enough span to read as the
     effect just stopping partway across the word rather than continuing to
     fade. Holding full opacity out to 55% of the radius, THEN fading, gives
     a much bigger fully-lit core and confines the actual soft edge to the
     outer 45% -- more of a wide title reads as genuinely revealed at any
     given hover position. */
  -webkit-mask-image: radial-gradient(circle var(--magnetic-r) at var(--mx, 50%) var(--my, 50%), #000 0%, #000 55%, transparent 100%);
  mask-image: radial-gradient(circle var(--magnetic-r) at var(--mx, 50%) var(--my, 50%), #000 0%, #000 55%, transparent 100%);
}

.magnetic-gradient-text.is-active::before {
  opacity: 1;
}

/* Per-group mask radius -- the about statement's line-height is much
   taller than a stat number or a pricing title, so it gets a wider circle
   to read as the same gesture rather than a proportionally tinier one. */
.about-inner p.magnetic-gradient-text {
  --magnetic-r: 220px;
}

.stat-number.magnetic-gradient-text {
  /* Scaled up along with the number itself -- the old 130px radius was
     sized for the smaller pre-redesign figure and would now cover less
     than half a "$310mn+" at its new size. */
  --magnetic-r: 190px;
}

.pricing-card-title.magnetic-gradient-text {
  --magnetic-r: 110px;
}

@media (hover: none) {
  .magnetic-gradient-text::before {
    display: none;
  }
}

/* Compound wordmark leaves -- the hero heading and the footer marquee's
   "ofthefuture" are each split into per-cluster spans by
   applyLigatureSafeTracking() in js/main.js (to keep the "ft" ligature
   intact under tight tracking), so every cluster is its own
   .magnetic-gradient-text leaf rather than one leaf covering the whole
   word -- see initMagneticWordmarks(), which drives them all from one
   shared cursor position. inline-block (instead of the default inline)
   gives each span's ::before a box it can reliably inset:0 into -- a
   plain inline element is a shakier containing block for an absolutely
   positioned child -- and since these are short, non-wrapping runs this
   has no visible effect on the word's own layout. */
.magnetic-gradient-wordmark .magnetic-gradient-text {
  display: inline-block;
}

/* Per-group mask radius, same reasoning as the plain-text groups above --
   sized to each wordmark's own scale rather than one-size-fits-all. */
.home-hero-mark .magnetic-gradient-text {
  --magnetic-r: 120px;
}

.site-footer-marquee-item .magnetic-gradient-text {
  --magnetic-r: 260px;
}

.pricing-card-price-period {
  font-family: var(--font-body);
  font-size: 15px;
  font-weight: 400;
  color: var(--gray);
}

.pricing-card-desc {
  margin: 0;
  /* Pushes the CTA to the same bottom edge across all three cards
     regardless of how much room the copy/tags above actually take. */
  flex: 1 0 auto;
  font-family: var(--font-body);
  font-size: 15px;
  font-weight: 400;
  line-height: 1.7;
  color: var(--gray);
}

.pricing-card-tags {
  display: flex;
  flex-wrap: wrap;
  align-content: flex-start;
  gap: 10px;
  flex: 1 0 auto;
}

.pricing-cta {
  align-self: flex-start;
  margin-top: 8px;
  padding: 15px 28px;
  border: 1px solid var(--ink);
  font-family: var(--font-body);
  font-size: 13px;
  font-weight: 500;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  text-decoration: none;
  color: var(--ink);
  background: none;
  transition: background-color 0.25s ease, color 0.25s ease, border-color 0.25s ease;
}

.pricing-cta:hover,
.pricing-cta:focus-visible {
  background: var(--ink);
  color: var(--bg);
}

/* Retainer is picked out with weight and motion, not a foreign color --
   the only color anywhere on this page besides --marquee-accent's own
   one-off use is the highlight-gradient rule below, and even that is a
   thin line, not a fill. Hierarchy comes mostly from the CTA: filled by
   default here (vs. outlined-until-hover on its siblings) is the same
   visual argument a bigger button would make, just made with the site's
   existing ink/bg swap instead of a new element. */
.pricing-card--highlight .pricing-cta {
  background: var(--ink);
  color: var(--bg);
  border-color: var(--ink);
}

/* On hover, the flat --ink fill above becomes the same moving gradient
   as the card's own top stroke below (--highlight-gradient +
   bg-gradient-drift) -- a plain darken-on-hover, which is all the
   un-highlighted siblings get, would've undersold the one button on
   this row that's actually meant to be clicked. Text switches to --ink
   (white) rather than staying --bg: the gradient's stops are mid-to-
   dark violet across most of the sweep, so dark text would sit right
   at the edge of legible for most of the animation, not just dip at
   the pale D2E8FF midpoint. border-color drops out to transparent too
   -- the button's default border is what reads as "outlined" against
   a flat fill, but here the moving gradient already carries all the
   emphasis the border was doing, and keeping it just traces a static
   line on top of a field that's meant to read as pure motion. */
.pricing-card--highlight .pricing-cta:hover,
.pricing-card--highlight .pricing-cta:focus-visible {
  background-image: var(--highlight-gradient);
  background-size: 300% 100%;
  animation: bg-gradient-drift 3s ease infinite;
  border-color: transparent;
  color: var(--ink);
}

/* The "stroke" the brief actually asked for -- scaled to fit this
   page's hairline grammar instead of a full boxed outline: a thin,
   slowly-drifting gradient rule sitting on this one column's top edge,
   as if that single segment of the divider above had been highlighted.
   background-position is what bg-gradient-drift already animates (see
   body/.site-header/the services band), so reusing it here rather than
   writing a second copy just works, unmodified, against this element's
   own background-size. */
.pricing-card--highlight::before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 3px;
  background-image: var(--highlight-gradient);
  background-size: 300% 100%;
  animation: bg-gradient-drift 9.8s ease infinite;
}

@media (prefers-reduced-motion: reduce) {
  .pricing-card--highlight::before,
  .pricing-card--highlight .pricing-cta:hover,
  .pricing-card--highlight .pricing-cta:focus-visible {
    animation: none;
  }
}

@media (max-width: 780px) {
  .pricing-card {
    padding: 40px 24px;
    border-left: none;
    border-top: 1px solid var(--gray-line);
  }

  .pricing-card:first-child {
    border-top: none;
  }

  .pricing-grid {
    grid-template-columns: 1fr;
  }
}

/* ---------------------------------- footer ------------------------------- */

/* Closes the page directly off the bottom of pricing (see .pricing-grid's
   own border-bottom above) -- no separate "Contact" band in between, since
   there's no dedicated contact section built yet to label. The header's
   "Contact" link and the pricing CTAs are mailto: links straight to
   a@ofthefuture.co now, not anchors into this element -- id="contact" and
   this rule's scroll-margin-top are both left in place regardless (harmless
   if unused, and either could still be linked to directly), same reasoning
   as .services-section/.about-section elsewhere.
   Info/nav content sits on top (real content first), the huge looping
   wordmark fills the rest of the band below it -- reversed from the
   reference this was modeled on (wordmark first, links after) at Pran's
   request. "ofthefuture" is nearly triple "all in"'s character count, so
   holding it static at this size would either overflow or force the type
   down to nothing -- looping it solves both, same two-group/-50%
   infinite-scroll trick as the (unused) .services-marquee elsewhere in
   this file, just built fresh here at wordmark scale/casing (.logo-mark's
   family, weight, and text-transform, not .services-marquee-item's). */
.site-footer {
  scroll-margin-top: var(--header-h, 70px);
}

.site-footer-top {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: 32px 64px;
  padding: 48px 40px 32px;
}

.site-footer-col {
  display: flex;
  flex-direction: column;
  gap: 14px;
  max-width: 420px;
}

.site-footer-label {
  margin: 0;
  font-family: var(--font-body);
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--gray);
}

.site-footer-blurb {
  margin: 0;
  font-family: var(--font-body);
  font-size: 13px;
  font-weight: 400;
  line-height: 1.7;
  letter-spacing: 0.01em;
  text-transform: uppercase;
  color: var(--gray);
}

.site-footer-copyright {
  margin: 0;
  font-family: var(--font-body);
  font-size: 12px;
  font-weight: 500;
  letter-spacing: 0.02em;
  color: var(--gray);
}

.site-footer-col--nav {
  gap: 10px;
}

/* Same font and color as .site-footer-blurb (the About-derived line) --
   weight 400, var(--gray) -- instead of nav links standing out bolder
   and darker than the rest of the footer's text. */
.site-footer-col--nav a {
  font-family: var(--font-body);
  font-size: 13px;
  font-weight: 400;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  text-decoration: none;
  color: var(--gray);
}

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

/* Social/Policies: real columns, no real destinations yet -- plain text
   (not <a>) so nothing here reads as clickable before there's somewhere
   for it to go. Same type treatment as the nav links above so the four
   columns still read as one consistent block. */
.site-footer-placeholder {
  margin: 0;
  font-family: var(--font-body);
  font-size: 13px;
  font-weight: 400;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  color: var(--gray);
}

.site-footer-soon {
  margin-left: 0.4em;
  font-size: 11px;
  letter-spacing: 0.02em;
  text-transform: none;
  color: var(--gray-line);
}

/* Sized by its own type, same as before -- not stretched to fill
   leftover space, so it stays predictable at any viewport height. The
   ~30% bigger font on .site-footer-marquee-item below is what actually
   makes this band (and the wordmark in it) read as taller than before,
   on top of the new info/nav row above it. */
.site-footer-marquee {
  padding: 12px 0;
  overflow: hidden;
  border-top: 1px solid var(--gray-line);
  white-space: nowrap;
}

.site-footer-marquee-track {
  display: inline-flex;
  width: max-content;
  animation: site-footer-marquee-scroll 40s linear infinite;
}

.site-footer-marquee:hover .site-footer-marquee-track {
  animation-play-state: paused;
}

.site-footer-marquee-set {
  display: inline-flex;
  align-items: center;
  flex: 0 0 auto;
}

.site-footer-marquee-item {
  margin: 0;
  font-family: var(--font-wordmark);
  font-weight: 400;
  /* ~30% bigger than the first pass (72-260px), on top of a flex-grown
     band that's already taller -- this is the one moment on the page
     allowed to be this loud, and looping rather than fitting it
     statically is what makes that safe. */
  font-size: clamp(94px, 19.5vw, 338px);
  letter-spacing: -0.045em;
  line-height: 0.92;
  text-transform: lowercase;
  color: var(--ink);
}

.site-footer-marquee-dot {
  flex: 0 0 auto;
  margin: 0 0.35em;
  font-size: clamp(10px, 1.6vw, 28px);
  color: var(--gray-line);
}

@keyframes site-footer-marquee-scroll {
  from { transform: translateX(0); }
  to { transform: translateX(-50%); }
}

@media (prefers-reduced-motion: reduce) {
  .site-footer-marquee-track {
    animation: none;
  }
}

@media (max-width: 780px) {
  .site-footer-top {
    padding: 40px 24px 32px;
    gap: 28px 40px;
  }
}

/* ---------------------------------- project page ------------------------ */

/* The header's own wordmark stays small and out of the way on every page
   -- it's just a mark to get back home, never the main event. The big
   display version lives on its own: .home-hero-mark below on the
   homepage, .omni-heading on the gallery page. Scoped to .site-header
   (rather than repeated per page-body-class) so it applies uniformly. */
.site-header .logo-mark {
  font-size: 16px;
  letter-spacing: 0;
}

.site-header .logo-sub {
  display: none;
}

.site-header .logo-block {
  gap: 0;
  /* The wordmark itself is small at this size -- pad out its actual
     clickable/hoverable box well past the visible text (the negative
     margin keeps that padding from shifting anything else in the
     header's layout) so "back to home" is easy to hit and to hover. */
  padding: 10px 16px;
  margin: -10px -16px;
}

.project-main {
  flex: 1 1 auto;
  display: flex;
  align-items: flex-start;
}

/* a real column in the flex row -- not floating over the image -- so
   the image gets its own inset space next to it, matching the
   reference's three-column layout (thumbs / image / info) */
.project-thumbs {
  flex: 0 0 100px;
  position: sticky;
  top: var(--header-h, 70px);
  height: calc(100vh - var(--header-h, 70px));
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: flex-start;
  gap: 6px;
  padding-left: 24px;
  /* A project with enough images (google-g2's 12, at 80px each plus
     gaps) can add up to more than this column's own height -- without
     this, flexbox's default flex-shrink:1 would squeeze every thumb's
     HEIGHT down to fit while width stayed pinned at 64px, quietly
     breaking the 4:5 ratio .project-thumb just set. Letting the column
     scroll instead (with flex-shrink: 0 below) keeps every thumbnail
     its true shape regardless of how many there are. */
  overflow-y: auto;
}

.project-thumb {
  display: block;
  width: 64px;
  /* 4:5, matching the source photos (64 * 5/4 = 80) -- was a plain 43px
     (roughly 3:2), which cropped every thumbnail the same way the main
     image column used to. flex-shrink: 0 keeps it exactly this shape
     even when the full stack is taller than .project-thumbs itself
     (see that rule's own comment). */
  height: 80px;
  flex-shrink: 0;
  overflow: hidden;
  opacity: 0.35;
  transition: opacity 0.25s ease;
}

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

.project-thumb:hover {
  opacity: 0.65;
}

.project-thumb.is-active {
  opacity: 1;
}

.project-images {
  flex: 3 1 0;
  min-width: 0;
  display: flex;
  flex-direction: column;
}

/* jump targets for .project-thumbs below -- offset by the sticky
   header's height so a click (or a plain #hash link) doesn't scroll an
   image's top edge underneath it */
.project-image {
  margin: 0;
  width: 100%;
  /* Every source photo is a 4:5 portrait -- this used to be a flat
     height: calc(100vh - header), which forced each one into whatever
     shape the browser window happened to be (rarely 4:5) and let
     object-fit: cover silently crop the difference. aspect-ratio
     instead derives the box from the actual image shape, so cover no
     longer has anything to crop. */
  aspect-ratio: 4 / 5;
  overflow: hidden;
  background: var(--card-placeholder);
  border-bottom: 1px solid var(--gray-line);
  scroll-margin-top: var(--header-h, 70px);
}

.project-image:last-child {
  border-bottom: none;
}

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

/* stays pinned directly under the sticky header while the image column
   scrolls past underneath it -- --header-h is measured and kept in sync
   by js/main.js (syncHeaderHeight) since the header's real height
   varies slightly by breakpoint */
.project-info {
  flex: 2 1 0;
  min-width: 0;
  position: sticky;
  top: var(--header-h, 70px);
  height: calc(100vh - var(--header-h, 70px));
  display: flex;
  /* align-items: flex-start (rather than center) is what stopped a long
     write-up from either getting clipped by this fixed-height box or
     loading scrolled to the middle of a paragraph instead of the
     title -- see .project-info-inner's own margin below for how it's
     centered again without reopening that. overflow-y: auto is the
     other half: long copy scrolls inside its own box instead of
     breaking the layout. */
  align-items: flex-start;
  overflow-y: auto;
  padding: 48px 40px;
}

.project-info-inner {
  width: 100%;
  /* margin: auto (not align-items: center on the parent, above) is
     what centers this vertically -- short copy gets equal space above
     and below, same look align-items: center would give. The
     difference only shows up once a write-up is taller than the
     column: auto margins collapse to 0 instead of forcing a centered
     *overflow*, which is what previously loaded scrolled to a
     paragraph in the middle rather than the title, or hid the top of
     the text entirely in browsers that can't scroll "above" a
     centered overflowing flex item. This gives the same centered rest
     state with none of that failure mode -- long copy still starts
     at the top, scrollable normally. */
  margin: auto 0;
}

.project-meta-row {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 16px;
  font-family: var(--font-body);
  font-size: 13px;
  font-weight: 600;
  letter-spacing: 0.02em;
  text-transform: uppercase;
}

.project-title-group {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.project-category {
  font-family: var(--font-body);
  font-size: 11px;
  font-weight: 400;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  color: var(--gray);
}

.project-year {
  font-weight: 400;
  color: var(--gray);
}

/* Short pitch line under the title/category row, above the divider --
   bigger and on the display face (matching every other title-adjacent
   line on the site) so it reads as the project's one-line thesis, not
   just more body copy. */
.project-tagline {
  margin: 16px 0 0;
  font-family: var(--font-display);
  font-weight: 400;
  font-size: 17px;
  line-height: 1.35;
  color: var(--ink);
}

.project-divider {
  margin: 20px 0;
  border: none;
  border-top: 1px solid var(--gray-line);
}

/* Holds one .project-description per paragraph (populated by
   renderProjectPage() in js/main.js) -- gap does the between-paragraph
   spacing so each <p> itself can sit at margin:0, same pattern as
   .about-inner's paragraphs above. */
.project-description-group {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.project-description {
  margin: 0;
  /* Was capped at max-width: 300px regardless of how wide .project-info
     actually is (flex: 2 of the row, several hundred px more than that
     at typical desktop widths) -- text sat in a narrow sliver on the
     left with a dead gap next to it. .project-info-inner's own
     width: 100% is the real constraint now, same as .project-tagline
     just above. */
  font-family: var(--font-body);
  font-size: 13px;
  font-weight: 400;
  line-height: 1.7;
  color: var(--ink);
}

.project-next {
  display: block;
  width: 100%;
  margin-top: 28px;
  text-align: right;
  font-family: var(--font-body);
  font-size: 12px;
  font-weight: 500;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  color: var(--gray);
}

.project-next:hover {
  opacity: 0.7;
  color: var(--ink);
}

/* shown by renderProjectPage() in js/main.js in place of the normal
   three-column layout when ?id= doesn't match a project in PROJECTS */
.project-missing {
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 12px;
  min-height: 60vh;
  width: 100%;
  text-align: center;
  font-family: var(--font-body);
  font-size: 14px;
  color: var(--gray);
}

.project-missing a {
  color: var(--ink);
}

/* ---------------------------------- gallery page -------------------------- */

.gallery-main {
  flex: 1 1 auto;
  display: flex;
  min-height: 0;
}

/* the pannable viewport -- overflow:hidden crops the (much larger) tiled
   field inside it; touch-action:none stops a touch drag from also
   triggering the browser's own scroll/pull-to-refresh gestures, since
   initOmniGallery() in js/main.js handles panning itself via Pointer
   Events, which fire uniformly for mouse and touch. */
.omni-gallery {
  position: relative;
  flex: 1 1 auto;
  overflow: hidden;
  touch-action: none;
  /* Same gradient/size/fixed-attachment/animation as body and
     .site-header (see body's own comment for the full reasoning) --
     was a flat var(--bg), the exact mismatch .site-header already had
     fixed once: a solid fill reads as a visibly different, flatter
     swatch next to the animated gradient showing everywhere else on
     the page (worst here, since this panel is opaque and fills nearly
     the whole viewport below the header). background-attachment: fixed
     + the identical keyframe is what makes this show the same slice of
     the same canvas the header and body do, at every frame. */
  background-color: var(--bg);
  background-image: var(--bg-gradient);
  background-size: 120% 120%;
  background-attachment: fixed;
  animation: bg-gradient-drift 9.8s ease infinite;
}

/* positioned by translate3d() in js/main.js as the pointer drags or the
   wheel scrolls -- width/height stay 0 since every tile inside is
   absolutely placed against this element's own top-left corner, not
   flowed by it */
.omni-canvas {
  position: absolute;
  top: 0;
  left: 0;
  width: 0;
  height: 0;
}

.omni-tile {
  position: absolute;
  overflow: hidden;
  background: var(--card-placeholder);
}

.omni-tile img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  /* a native browser image-drag (the ghost thumbnail + drop cursor)
     would fight with our own pointer-driven panning */
  pointer-events: none;
  -webkit-user-drag: none;
  user-select: none;
}

/* the big center wordmark -- rendered in flat white with
   mix-blend-mode:difference so its actual visible color comes from
   whatever's behind it: against the plain --bg it inverts to read as
   solid ink (dark in light mode, light in dark mode, automatically,
   with no theme-specific color needed here), and against a tile it
   inverts that tile's colors into a negative silhouette through the
   letterforms. pointer-events:none lets drags/clicks pass straight
   through to the canvas underneath. */
.omni-heading {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  z-index: 5;
  margin: 0;
  padding: 0 24px;
  max-width: 100%;
  font-family: var(--font-wordmark);
  font-weight: 400;
  font-size: clamp(40px, 9vw, 140px);
  line-height: 0.95;
  /* -8px at the size fitHeading() typically settles on at desktop widths
     (~165px) -- in em so the tightness holds instead of the letters
     colliding at the small end of the clamp() (mobile, where JS hands
     sizing back to plain CSS) or at a narrow-but-still-desktop width. */
  letter-spacing: -0.0485em;
  text-align: center;
  text-transform: lowercase;
  color: #fff;
  mix-blend-mode: difference;
  pointer-events: none;
  user-select: none;
}

/* The tap-to-enlarge lightbox -- see openLightbox()/closeLightbox() in
   js/main.js. Stays in the DOM (and in layout) at all times rather than
   being added/removed, so its size can be measured before it's shown;
   opacity/pointer-events are what actually show and hide it. A click
   anywhere within it (the dark backdrop or the image itself) closes it,
   per the reference this was modeled on. */
.omni-lightbox {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0);
  opacity: 0;
  pointer-events: none;
  cursor: pointer;
  transition: background 0.45s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.45s cubic-bezier(0.16, 1, 0.3, 1);
}

.omni-lightbox.is-open {
  opacity: 1;
  pointer-events: auto;
  background: rgba(0, 0, 0, 0.82);
}

/* Sized in px by JS to the image's own aspect ratio (fit within ~86% of
   the viewport) before each open, so the FLIP-style pop animation has a
   real target box to animate the tile's rect into. */
.omni-lightbox-frame {
  position: relative;
  will-change: transform;
  transition: transform 0.45s cubic-bezier(0.16, 1, 0.3, 1);
}

.omni-lightbox-img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  user-select: none;
  -webkit-user-drag: none;
}

/* ------------------------------- contact modal ---------------------------- */

/* Every mailto: link on the site -- Contact in the header, plus the three
   pricing CTAs -- opens this instead of handing off to a mail client
   directly: that hand-off turned out to be unreliable without a default
   mail app configured, and could leave a blank tab behind instead of
   opening anything. Built once in JS (initContactModal()) and shared by
   every mailto trigger on the page, same "build once, toggle visibility"
   pattern as .omni-lightbox above, rather than duplicating this markup
   across four HTML files. Same dark panel + backdrop language as the
   lightbox, but a bordered box rather than a full-bleed image frame --
   this is a small utility surface, not a media viewer.

   The panel now leads with a real form (submitted to Formspree -- see
   initContactModal() in js/main.js) rather than only the copy-to-clipboard
   email, per Pran's own framing: the copy-email flow was always the
   stand-in "for now", the form is the real thing. Both stay in the same
   modal -- some visitors will always prefer opening their own mail client
   over trusting a web form -- separated by .contact-modal-divider. */
.contact-modal {
  position: fixed;
  inset: 0;
  z-index: 300;
  display: flex;
  /* The panel used to be short enough (just the copy-email row) that plain
     `center` never mattered. Now that it leads with a full form, a short
     viewport -- a phone with the on-screen keyboard open, most reliably --
     can be shorter than the panel itself. Plain `center` on an overflowing
     flex item is a known trap: the browser centers it by pushing it equally
     off BOTH edges, but a scroll container's scrollTop can't go negative,
     so the portion pushed off the top (the close button, the name field)
     becomes permanently unreachable no matter how far you scroll -- only
     the bottom overflow is ever visible. `safe center` is the standards
     fix for exactly this: it centers normally while there's room, and
     falls back to start-alignment (so the overflow lands entirely on the
     bottom, reachable via ordinary scroll) once there isn't. */
  align-items: safe center;
  justify-content: center;
  overflow-y: auto;
  padding: 24px 0;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.3s ease;
}

.contact-modal.is-open {
  opacity: 1;
  pointer-events: auto;
}

.contact-modal-backdrop {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.82);
  cursor: pointer;
}

.contact-modal-panel {
  position: relative;
  /* Sized up from the original 420px copy-email-only box now that this
     leads with an actual form -- a form this size felt cramped in that
     narrower panel during review, and the extra room also gives the
     header block (logo + label + response-time line, below) space to
     breathe above the fields instead of crowding them. */
  width: min(560px, calc(100vw - 48px));
  padding: 48px 44px 44px;
  background: var(--bg);
  border: 1px solid var(--gray-line);
  text-align: left;
  transform: translateY(8px);
  transition: transform 0.3s ease;
}

.contact-modal.is-open .contact-modal-panel {
  transform: translateY(0);
}

/* Four corner marks framing the panel -- same "+" language as the custom
   cursor's own .cursor-icon (two crossed bars, see that rule further down)
   rather than a new motif invented just for this modal, so it reads as
   this site's own mark rather than a generic "viewfinder" decoration
   borrowed from somewhere else. Centered exactly on each corner (half in,
   half out of the panel's own border) like a print registration mark or
   camera-viewfinder AF bracket -- a precision/technical framing device
   that fits a product-design studio's site. Drawn in --gray-line, the same
   faint hairline already used for the panel's own border and every other
   divider on the page, rather than the cursor's full-opacity --ink, since
   this is a passive frame accent, not something meant to draw the eye the
   way the live cursor mark does. Purely decorative (aria-hidden, no
   content) and the panel itself has no overflow:hidden, so these are free
   to sit half outside its edge without getting clipped. */
.contact-modal-corner {
  position: absolute;
  width: 16px;
  height: 16px;
  pointer-events: none;
}

.contact-modal-corner::before,
.contact-modal-corner::after {
  content: "";
  position: absolute;
  background: var(--gray-line);
}

.contact-modal-corner::before {
  top: 50%;
  left: 0;
  width: 100%;
  height: 1px;
  transform: translateY(-50%);
}

.contact-modal-corner::after {
  left: 50%;
  top: 0;
  width: 1px;
  height: 100%;
  transform: translateX(-50%);
}

.contact-modal-corner--tl {
  top: 0;
  left: 0;
  transform: translate(-50%, -50%);
}

.contact-modal-corner--tr {
  top: 0;
  right: 0;
  transform: translate(50%, -50%);
}

.contact-modal-corner--bl {
  bottom: 0;
  left: 0;
  transform: translate(-50%, 50%);
}

.contact-modal-corner--br {
  bottom: 0;
  right: 0;
  transform: translate(50%, 50%);
}

.contact-modal-close {
  position: absolute;
  top: 14px;
  right: 14px;
  width: 28px;
  height: 28px;
  border: 1px solid var(--gray-line);
  background: none;
  color: var(--ink);
  font-size: 16px;
  line-height: 1;
  cursor: pointer;
  transition: background-color 0.2s ease, color 0.2s ease;
}

.contact-modal-close:hover,
.contact-modal-close:focus-visible {
  background: var(--ink);
  color: var(--bg);
}

/* Centered header block -- logo, "(Contact)" label, response-time line --
   sits above the (left-aligned, like every form on the web reads best)
   form fields. Centering just this block rather than the whole panel is
   what keeps it feeling like a considered header on top of an ordinary
   form, instead of either a lopsided single centered line or a fully
   centered panel where input fields themselves would look strange. */
.contact-modal-header {
  margin-bottom: 28px;
  text-align: center;
}

.contact-modal-header .contact-modal-label {
  margin: 0 0 10px;
}

/* Plain text rather than reusing the global .logo-mark class -- that
   class's own font-size is a clamp() tuned for the header/hero contexts
   (up to 72px at wide viewports), which would badly overpower this small
   utility panel. A dedicated, fixed size here keeps it consistent at
   every viewport instead of fighting that clamp. */
.contact-modal-logo {
  margin: 0 0 14px;
  font-family: var(--font-wordmark);
  font-weight: 400;
  font-size: 22px;
  letter-spacing: -0.06em;
  line-height: 1;
  text-transform: lowercase;
  color: var(--ink);
}

.contact-modal-response-time {
  margin: 0;
  font-family: var(--font-body);
  font-size: 13px;
  color: var(--gray);
}

.contact-modal-label {
  margin: 0 0 12px;
  font-family: var(--font-body);
  font-size: 12px;
  font-weight: 500;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--gray);
}

.contact-modal-email-row {
  display: flex;
  align-items: center;
  gap: 12px;
  padding-bottom: 20px;
  border-bottom: 1px solid var(--gray-line);
}

.contact-modal-email {
  flex: 1;
  font-family: var(--font-display);
  font-size: clamp(18px, 3vw, 24px);
  letter-spacing: -0.01em;
  color: var(--ink);
  word-break: break-all;
}

.contact-modal-copy {
  flex: none;
  padding: 10px 16px;
  border: 1px solid var(--ink);
  background: none;
  font-family: var(--font-body);
  font-size: 12px;
  font-weight: 500;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  color: var(--ink);
  cursor: pointer;
  transition: background-color 0.2s ease, color 0.2s ease;
}

.contact-modal-copy:hover,
.contact-modal-copy:focus-visible {
  background: var(--ink);
  color: var(--bg);
}

/* Only shown next to the copy-email fallback when opened from a link whose
   mailto: href carries a ?subject= -- the header Contact link has no
   subject to show, the three pricing CTAs each do. The form above sends
   its subject straight through in a hidden field, so it doesn't need this
   hint; this is only for someone who scrolls past the form and emails
   directly instead, so they know what to mention once they've copied the
   address into their own mail client. */
.contact-modal-subject {
  margin: 10px 0 0;
  font-family: var(--font-body);
  font-size: 13px;
  color: var(--gray);
}

.contact-modal-note {
  margin: 16px 0 0;
  font-family: var(--font-body);
  font-size: 12px;
  line-height: 1.5;
  color: var(--gray);
}

/* ---- the form itself ---- */

.contact-modal-form {
  display: flex;
  flex-direction: column;
  gap: 16px;
  margin-top: 4px;
}

.contact-modal-field {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.contact-modal-field label {
  font-family: var(--font-body);
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--gray);
}

/* Same hairline-border language as every other bordered surface on the
   site (.pricing-cta, .contact-modal-copy) rather than a filled input --
   a solid fill would be the first filled-background element anywhere on
   this page and would read as a foreign, generic "web form" component
   dropped into an otherwise flat, line-drawn design. Transparent
   background keeps it consistent; the border alone (and its color shift
   on focus) is what signals "this is editable." */
.contact-modal-field input,
.contact-modal-field textarea {
  width: 100%;
  padding: 11px 12px;
  background: none;
  border: 1px solid var(--gray-line);
  border-radius: 0;
  font-family: var(--font-body);
  font-size: 14px;
  color: var(--ink);
  transition: border-color 0.2s ease;
}

.contact-modal-field textarea {
  resize: vertical;
  min-height: 84px;
  line-height: 1.5;
}

.contact-modal-field input::placeholder,
.contact-modal-field textarea::placeholder {
  color: var(--gray);
  opacity: 0.6;
}

.contact-modal-field input:focus-visible,
.contact-modal-field textarea:focus-visible {
  outline: none;
  border-color: var(--ink);
}

/* :invalid only takes the red treatment once the visitor has actually
   tried to submit (.was-validated, toggled by initContactModal() on the
   form's first submit attempt) -- without that gate, required fields
   would render red from the moment the modal opens, before anyone's done
   anything wrong. */
.contact-modal-form.was-validated :invalid {
  border-color: var(--contact-error);
}

.contact-modal-submit {
  align-self: stretch;
  margin-top: 4px;
  padding: 13px 20px;
  border: 1px solid var(--ink);
  background: none;
  font-family: var(--font-body);
  font-size: 13px;
  font-weight: 500;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  color: var(--ink);
  cursor: pointer;
  transition: background-color 0.25s ease, color 0.25s ease, opacity 0.2s ease;
}

.contact-modal-submit:hover,
.contact-modal-submit:focus-visible {
  background: var(--ink);
  color: var(--bg);
}

.contact-modal-submit:disabled {
  opacity: 0.5;
  cursor: default;
  pointer-events: none;
}

.contact-modal-status {
  min-height: 16px;
  margin: 0;
  font-family: var(--font-body);
  font-size: 12px;
  color: var(--gray);
}

.contact-modal-status.is-error {
  color: var(--contact-error);
}

/* Replaces the form entirely on a successful submit (see
   initContactModal()) rather than showing a banner above it -- the fields
   have already done their job and re-showing an empty form right below a
   "message sent" line invites a confused second submission. */
.contact-modal-success {
  padding: 24px 0 4px;
  text-align: center;
}

.contact-modal-success-text {
  margin: 0;
  font-family: var(--font-display);
  font-size: 18px;
  color: var(--ink);
}

/* Thin "or" divider between the form and the copy-email fallback --
   a single hairline would read as an accidental leftover rule; the
   centered label makes the break a deliberate one. */
.contact-modal-divider {
  display: flex;
  align-items: center;
  gap: 12px;
  margin: 28px 0 20px;
  font-family: var(--font-body);
  font-size: 11px;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--gray);
}

.contact-modal-divider::before,
.contact-modal-divider::after {
  content: "";
  flex: 1;
  height: 1px;
  background: var(--gray-line);
}

/* ---------------------------------- custom cursor ------------------------ */

/* Only applied once js/main.js (initCustomCursor) has actually built the
   circle -- it adds this class itself, so a page where that JS fails or
   is skipped (touch devices) keeps the normal system cursor instead of
   quietly losing it. The `*` is needed because links/buttons get their
   own `cursor: pointer` from the browser's UA stylesheet, which plain
   inheritance from html/body wouldn't override. */
.has-custom-cursor,
.has-custom-cursor * {
  cursor: none;
}

/* A zero-size anchor point positioned at the raw pointer coordinates --
   the dot and label are positioned absolutely off of *this* origin
   (each centering or offsetting itself independently) rather than laid
   out side by side in a flex row, so the label's width (even while
   invisible) can never drag the dot off-center from the actual pointer
   position. */
.cursor {
  position: fixed;
  top: 0;
  left: 0;
  /* Was 200 -- comfortably above the gallery lightbox's z-index: 100 (see
     .omni-lightbox), which is why the cursor stays visible on top of THAT
     overlay along with its is-lightbox-open "x" state. .contact-modal
     came later at z-index: 300, above the cursor, so the cursor rendered
     invisibly behind the modal panel -- both this and .cursor-line's own
     150 sit below it (both have pointer-events: none regardless, so
     nothing was ever actually unclickable, but with no visible pointer at
     all over the modal it read as broken). Bumped above every overlay on
     the page, contact modal included, to restore the same "cursor stays
     on top of anything" behavior the lightbox already had. */
  z-index: 400;
  pointer-events: none;
  opacity: 0;
  transition: transform 0.12s ease-out, opacity 0.2s ease;
}

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

/* The other half of the checkpointresearch.ca reference -- full-height/
   full-width plumb lines through the pointer, on top of the "+"/"x" mark
   itself (.cursor-icon below). Separate elements from .cursor rather
   than children of it: .cursor eases toward the pointer (see its own
   transition above) and these two need to snap there instantly, the
   same way they do on the reference -- nesting them would mean either
   fighting that easing with !important or losing it for the icon too.
   Positioned with transform (translateX/Y, set in
   initCustomCursor()) rather than left/top so moving them doesn't
   trigger layout, same reasoning as .cursor's own transform. Color
   reuses --gray-line, the same faint divider already drawn between
   sections/stats/etc elsewhere on this page, rather than inventing a
   new "faint line" color just for this. */
.cursor-line {
  position: fixed;
  top: 0;
  left: 0;
  background: var(--gray-line);
  pointer-events: none;
  /* Kept 50 below .cursor's own z-index (same gap as before: 150/200,
     now 350/400) so the "+"/"x" mark still draws on top of these plumb
     lines, just both raised above .contact-modal now -- see .cursor's own
     comment for why. */
  z-index: 350;
  opacity: 0;
  transition: opacity 0.2s ease;
}

.cursor-line.is-visible {
  opacity: 1;
}

.cursor-line--v {
  width: 1px;
  height: 100vh;
}

.cursor-line--h {
  width: 100vw;
  height: 1px;
}

/* The "+" mark itself: two bars drawn with ::before/::after rather than
   an inline SVG, since a plain background-color square rotates cleanly
   with nothing to keep aligned. This is the cursor now, full stop --
   there's no separate resting dot and no growing-ring hover state
   underneath it any more (that was the old design; it stayed active
   alongside this icon by mistake for a round, which is why hover kept
   showing a circle no matter what this rule said). Two states only:
   the plain "+" at rest, and the same two bars rotated 45deg into an
   "x" below for every kind of "you can act on this" moment -- a link
   or button, the gallery/omni-gallery grid's scroll hint, or the
   lightbox open for closing. */
.cursor-icon {
  position: absolute;
  top: 0;
  left: 0;
  width: 14px;
  height: 14px;
  opacity: 1;
  transform: translate(-50%, -50%) rotate(0deg);
  transition: transform 0.25s ease;
}

.cursor-icon::before,
.cursor-icon::after {
  content: "";
  position: absolute;
  background: var(--ink);
}

.cursor-icon::before {
  top: 50%;
  left: 0;
  width: 100%;
  height: 2px;
  transform: translateY(-50%);
}

.cursor-icon::after {
  left: 50%;
  top: 0;
  width: 2px;
  height: 100%;
  transform: translateX(-50%);
}

/* "+" becomes "x" -- see initCustomCursor() in js/main.js for what sets
   each of these three classes (a link/button/tile hover, the gallery
   grid's scroll hint, or the lightbox open to close). All three read the
   same way: this is a spot you can act on, not just move through. */
.cursor.is-over-link .cursor-icon,
.cursor.is-near-grid .cursor-icon,
.cursor.is-lightbox-open .cursor-icon {
  transform: translate(-50%, -50%) rotate(45deg);
}

.cursor-label {
  position: absolute;
  top: 0;
  left: 20px;
  font-family: var(--font-body);
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  color: var(--ink);
  white-space: nowrap;
  opacity: 0;
  transform: translate(0, -50%) translateX(-6px);
  transition: opacity 0.2s ease, transform 0.2s ease;
}

.cursor.is-near-grid .cursor-label,
.cursor.is-lightbox-open .cursor-label {
  opacity: 1;
  transform: translate(0, -50%) translateX(0);
}

/* ---------------------------------- responsive -------------------------- */

@media (max-width: 780px) {
  :root {
    /* Same 0.25x bump as the desktop cut above (324 * 1.25 = 405) --
       width still comes from .card's 4:5 aspect-ratio, not a card-w
       token. Same --gallery-hero-budget treatment as the desktop value,
       including the min() against (budget - 34px) that keeps this from
       eating into the wordmark's own minimum (see --card-h's comment
       up in :root for why that matters more than the plain 0.772 share
       does) -- just with this breakpoint's own 405px ceiling. Holds at
       a flat 405px on a phone held upright, where the budget comfortably
       clears it, and only shrinks below that on a short/landscape
       phone, instead of staying a hard 405px and clipping the wordmark
       above it the same way the old flat desktop value used to. */
    --card-h: clamp(40px, min(calc(var(--gallery-hero-budget) * 0.772), calc(var(--gallery-hero-budget) - 34px)), 405px);
    --gallery-gap-max: 12px;
  }

  .site-header {
    grid-template-columns: 1fr;
    justify-items: center;
    gap: 12px;
    padding: 24px 16px 0;
  }

  .nav--left, .nav--right {
    /* Reset the desktop grid-column pinning -- the template here is a
       single column, and leaving grid-column: 3 set would force grid to
       generate extra implicit columns to satisfy it instead of stacking
       everything in the one column this breakpoint actually has. */
    grid-column: 1;
    justify-self: center;
  }

  /* The single-column mobile header is already a stack of rows -- a
     third one that's invisible until scrolled would just add dead space
     above it before the fade-in, then shift the two nav rows around
     it. Simpler to leave it a desktop-only touch. */
  .logo-block--scroll {
    display: none;
  }

  .project-page .site-header,
  .gallery-page .site-header,
  .services-page .site-header {
    padding: 18px 16px;
  }

  /* no room for a fixed strip alongside a full-width image column */
  .project-thumbs {
    display: none;
  }

  /* the sticky two-column split doesn't work on a narrow screen --
     images stack full-width, then the info block follows in normal
     flow underneath instead of pinning to the viewport */
  .project-main {
    flex-direction: column;
  }

  .project-images {
    flex: none;
    width: 100%;
  }

  /* No override needed here any more -- the base rule's aspect-ratio:
     4/5 already gives the right height at 100% width on its own; the
     old height: 70vh this used to set would just fight it (an explicit
     height wins over aspect-ratio when both are set on a definite-
     width box), reintroducing the same crop on mobile only. */

  .project-info {
    position: static;
    height: auto;
    padding: 32px 16px 56px;
  }

  /* .services-section/.about-section/.pricing-section's labels are all
     already static/in-flow at every width now -- narrower side padding
     here is just matching this breakpoint's tighter gutter, same as
     .services-card/.about-inner below. */
  .services-section > .section-label,
  .about-section > .section-label,
  .pricing-section > .section-label {
    padding: 24px 24px 20px;
  }

  .services-card {
    padding: 40px 24px;
  }

  /* The 50/50 text/stats split has no room to breathe at this width --
     stack them instead, text above stats. */
  .about-split {
    grid-template-columns: 1fr;
  }

  .about-inner {
    padding: 24px 24px 40px;
    gap: 22px;
  }

  /* Swap the vertical hairline for a horizontal one now that stats
     sits below the text instead of beside it (each .stat's own
     border-top, set globally, still separates the three figures from
     each other either way). */
  .stats-grid {
    border-left: none;
    border-top: 1px solid var(--gray-line);
  }

  .stat {
    padding: 32px 24px;
  }
}
