/* ==========================================================================
   Fonts
   ========================================================================== */

@font-face {
  font-family: "Satoshi";
  src: url("fonts/Satoshi-Variable.woff2") format("woff2");
  font-weight: 300 900;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "Satoshi";
  src: url("fonts/Satoshi-VariableItalic.woff2") format("woff2");
  font-weight: 300 900;
  font-style: italic;
  font-display: swap;
}

@font-face {
  font-family: "Sora";
  src: url("fonts/Sora-VariableFont_wght.woff2") format("woff2");
  font-weight: 100 800;
  font-style: normal;
  font-display: swap;
}

/* ==========================================================================
   Tokens
   ========================================================================== */

:root {
  /* Color */
  --midnight: #000000;
  --black-cat: #050014;
  --white: #ffffff;
  --off-white: #fffefd;
  --mint-pale: #e5f8f5;
  --blue-deep: #0225b7;
  --periwinkle: #5d5cff;
  /* Brighter than --periwinkle — matches the source file in Illustrator,
     which --periwinkle itself (used sitewide, for links/focus states/
     etc.) reads darker than on screen. Used where that brighter shade
     is deliberately wanted as a section background (.contact__intro,
     .capabilities), not as a sitewide swap for --periwinkle. */
  --periwinkle-bright: #6966ff;
  --green-pale: #d1ffc2;
  --shark-gray: #4f4e56;
  /* Same hue/saturation as --shark-gray, lightness raised from ~32% to
     55% — a softer, less-dark gray for contexts that want the same
     neutral family without reading as heavily as the base token. */
  --shark-gray-light: hsl(247, 5%, 55%);
  --periwinkle-link: #3f3ffc;
  --error-red: #c0392b;

  --gradient-blue: linear-gradient(135deg, #ffffff 0%, #668fff 100%);
  --gradient-green: linear-gradient(135deg, #ffffff 0%, #b2ff99 100%);

  --link-color: var(--blue-deep);

  /* Type */
  --font-heading: "Sora", system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-body: "Satoshi", system-ui, -apple-system, "Segoe UI", sans-serif;

  /* Layout */
  --site-max-width: 1600px;
  --site-padding: clamp(1.25rem, 4vw, 3rem);
  --header-height: 5.5rem;

  /* Gallery gap (project-template.html media patterns) — cqw, not vw:
     scales against .project__gallery's own resolved width (see
     container-type on that rule), not the browser viewport. Those aren't
     the same thing — opening the About panel widens .project__rail and
     leaves the gallery (its flex sibling) narrower with the viewport never
     having changed size at all, which a viewport unit can't see but a
     container-query unit can. Verified directly: cqw resolves correctly
     even in an auto-height flex column, where a percentage gap would
     silently compute to 0 (percentages need a definite dimension to
     resolve against; container query units don't have that limitation).
     1.6cqw slightly overshoots 24px right at the gallery's own max width
     (~1504px, so 1.6% ≈ 24.06px) — clamp's ceiling is what actually clips
     that to exactly 24px, not the coefficient being exact. Below that
     width — narrower viewports, or the About panel open and the rail
     eating into the gallery's space — it scales proportionally instead of
     staying fixed, which is what keeps the export-size-reference math
     (a specific gap-to-image-size ratio) accurate regardless of *why* the
     gallery got narrower. The 12px floor is a safety net; the mobile
     breakpoint (below) pins back to a flat 24px well before 1.6cqw alone
     would reach it. */
  --gallery-gap: clamp(12px, 1.6cqw, 24px);

  /* Motion */
  --ease: cubic-bezier(0.22, 1, 0.36, 1);
}

/* ==========================================================================
   Reset / base
   ========================================================================== */

*,
*::before,
*::after {
  box-sizing: border-box;
}

html {
  scroll-behavior: smooth;
  /* Guards against any stray element (even a sub-pixel amount) pushing past
     the viewport's right edge and producing a horizontal scrollbar — this
     never clips intentional full-bleed content, since those still span
     exactly 100% of this same width; it only cuts off unintentional
     overflow, which should never be scrollable to begin with. */
  overflow-x: hidden;
}

body {
  margin: 0;
  /* .site-header is position:fixed (see the comment on that rule), so it
     no longer reserves its own space in the document flow the way it
     did as position:sticky — this replaces that reserved space so every
     page's content starts exactly where it always has, just below the
     header instead of underneath it. */
  padding-top: var(--header-height);
  min-height: 100vh;
  background: var(--off-white);
  color: var(--black-cat);
  font-family: var(--font-body);
  font-weight: 400;
  font-size: 1rem;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}

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

a {
  color: var(--link-color);
  text-decoration: none;
}

a:hover,
a:focus-visible {
  text-decoration: underline;
}

button {
  font: inherit;
  color: inherit;
  background: none;
  border: 0;
  cursor: pointer;
  /* Without this, a quick double-tap on iOS Safari reads as its
     double-tap-to-zoom gesture instead of two clicks — matters most for
     buttons meant to be tapped repeatedly (hero prev/next, video
     play/mute), where zooming to an odd crop is jarring. manipulation
     drops just that gesture; pinch-zoom elsewhere is unaffected. */
  touch-action: manipulation;
}

h1,
h2,
h3,
h4 {
  font-family: var(--font-heading);
  font-weight: 400;
  margin: 0;
  line-height: 1.1;
}

p {
  margin: 0;
}

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

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

.container {
  max-width: var(--site-max-width);
  margin-inline: auto;
  padding-inline: var(--site-padding);
}

.full-bleed {
  width: 100vw;
  margin-inline: calc(50% - 50vw);
}

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

/* <site-header>/<site-footer>/<project-about> (see partials.js) are
   unstyled custom element tags wrapping the real markup they inject —
   display:contents removes the wrapper from the box tree entirely, so it
   contributes nothing to layout and can't interfere with the header's
   sticky positioning, the footer's normal-flow placement, or (for
   project-about) the flex/sticky relationship between .project__rail and
   .project__gallery, which depends on both being direct flex children of
   .project__row. */
site-header,
site-footer,
project-about {
  display: contents;
}

/* ==========================================================================
   Header / nav
   ========================================================================== */

/* Mechanics match wolffolins.com's own header (inspected directly — see
   PR notes), not a guess: .site-header is a plain fixed box with a
   *constant* height that never animates. It centers its one child via flex
   instead of margin-inline:auto, and has pointer-events:none so the empty
   space around the floating pill (once narrow) doesn't block clicks on the
   hero underneath it — .nav-wrap re-enables pointer-events for itself.

   position:fixed, not sticky — a real-device comparison (not a headless
   test; those turned out not to reproduce this reliably) between .nav-bg
   and .project__video-toggle, the video-reel play button on project
   pages, is what settled this: both are low-opacity backdrop-filter
   panels, but the play button — which sits in plain position:relative
   flow, no sticky/fixed ancestor — blurs correctly on the same real
   iPhone where the header pill shows nothing. That matches a
   documented WebKit bug where backdrop-filter on a descendant of a
   position:sticky ancestor stops sampling the page behind it once the
   ancestor actually engages/sticks. `body` carries a compensating
   padding-top below so removing this from flow doesn't change any
   page's layout. This was tried once already this session and reverted
   after a headless-WebKit test seemed to show no improvement — that
   test is now known to be unreliable, so this is a deliberate second
   attempt pending real-device confirmation. */
.site-header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  /* Comfortably above every other z-index on the site (project-template's
     mobile sheet included, which peaks at 110) — the main nav, and its
     mobile dropdown, should never end up buried under page-level content. */
  z-index: 200;
  height: var(--header-height);
  display: flex;
  justify-content: center;
  align-items: flex-start;
  pointer-events: none;
}

/* The one piece of this whole mechanism that actually animates on scroll:
   its WIDTH, between the site's own fluid content column (matching
   .container — see tokens above) and a narrower centered band once
   scrolled. Nothing about its position ever changes — `top` is a constant
   1rem gap from the header's own top edge, in both states — which is what
   keeps the logo/links pixel-fixed as the page scrolls; only the box
   around them narrows. Centering comes from .site-header's own
   justify-content:center, not margin, so this only ever needs to animate
   one property. */
.nav-wrap {
  position: relative;
  top: 1rem;
  display: flex;
  align-items: center;
  pointer-events: auto;
  width: calc(100% - 2 * var(--site-padding));
  max-width: calc(var(--site-max-width) - 2 * var(--site-padding));
  transition: width 0.65s cubic-bezier(0.23, 1, 0.32, 1);
}

/* Menu-open gets the same narrow treatment as scrolled (even at the very
   top of the page) so opening it always shows as a compact floating panel,
   never a full-bleed bar — matching the original design intent here, just
   reached through the simpler width-based mechanism above. */
.site-header.is-scrolled .nav-wrap,
.site-header.menu-open .nav-wrap {
  width: min(40ch, calc(100% - 2rem));
}

@media (min-width: 769px) {
  .site-header.is-scrolled .nav-wrap,
  .site-header.menu-open .nav-wrap {
    width: clamp(64ch, 70%, 96ch);
  }
}

/* The pill's visible white background — a real, always-present element
   (not a pseudo-element gated behind a class), so its opacity fade is a
   plain transition on a box that already exists rather than a value
   appearing to "pop in" the instant a class first makes the box exist.
   Blur + translucency stand in for a shadow entirely; the reference site
   uses no box-shadow here at all.

   backdrop-filter previously failed to render at all in WebKit (every
   iOS browser, since Apple requires that engine there) — root cause
   was view-transition-name on .site-header (see git history), which
   tags an element for special handling by the View Transitions API
   even outside an active transition. Confirmed fixed on real hardware
   after removing that CSS entirely. */
.nav-bg {
  position: absolute;
  inset: 0;
  z-index: -1;
  border-radius: 1.75rem;
  background: rgba(255, 254, 253, 0.2);
  border: 1px solid rgba(79, 78, 86, 0.3);
  backdrop-filter: blur(1rem);
  -webkit-backdrop-filter: blur(1rem);
  opacity: 0;
  transition: opacity 0.2s ease-out, background 0.2s ease-out;
}

.site-header.is-scrolled .nav-bg,
.site-header.menu-open .nav-bg {
  opacity: 1;
}

/* Hovering the pill itself (only visible once scrolled/menu-open, via the
   opacity rule above) frosts it further — no separate .is-scrolled/
   .menu-open guard needed here since .nav-bg is already invisible
   (opacity:0) the rest of the time regardless of its background color.

   Gated to real hover-capable pointers, matching every other :hover-only
   effect in this file — without it, tapping the hamburger toggle (or a
   mobile link) to close the menu left this "stuck" active, since touch
   devices simulate :hover on tap and don't clear it until something else
   is tapped. That showed up as the pill staying at 0.75 opacity instead
   of settling back to the base 0.2 once the menu closed. */
@media (hover: hover) and (pointer: fine) {
  .nav-wrap:hover .nav-bg {
    background: rgba(255, 254, 253, 0.75);
  }
}

/* menu-open only ever happens on mobile (the hamburger toggle that sets
   it is hidden above the 768px breakpoint, .nav__links shown directly
   there instead) — the expanded mobile menu wants a more opaque backing
   than the scrolled-but-closed pill does, so its content stays readable
   over whatever's scrolled behind it. Declared after :hover above (equal
   specificity either way) so menu-open wins over a mouse just happening
   to sit over the pill when the toggle is clicked, rather than the
   reverse. */
.site-header.menu-open .nav-bg {
  background: rgba(255, 254, 253, 0.9);
}

/* Tapping a mobile nav link both closes the menu AND navigates away in
   the same click — the two happen almost simultaneously, so the menu's
   normal animated close (nav's height, nav-bg's background/opacity,
   nav-wrap's width) barely gets a few milliseconds to run before the
   page starts unloading. Safari (and every iOS browser) holds the
   outgoing page's last painted frame on screen until the new page is
   ready to show, which meant that frame was usually a half-collapsed
   menu, not a closed one — reading as the old menu overlapping the new
   page's content for a moment after it had already loaded. script.js
   adds this class immediately before closing for a link click
   specifically (never for the toggle button, which should still
   animate normally), so the frame Safari holds is already fully
   closed. */
.site-header.is-closing-instantly,
.site-header.is-closing-instantly .nav,
.site-header.is-closing-instantly .nav-wrap,
.site-header.is-closing-instantly .nav-bg,
.site-header.is-closing-instantly .nav__mobile-links {
  transition: none !important;
}

/* Always rounded, at the same radius, whether scrolled or not — there's no
   radius animation anywhere in this system. Height is likewise constant
   except while the mobile menu is open, when JS sets it explicitly (see
   script.js) to reveal .nav__mobile-links below the row. */
.nav {
  position: relative;
  display: flex;
  flex-direction: column;
  width: 100%;
  height: 3.5rem;
  padding-inline: 1rem;
  border-radius: 1.75rem;
  overflow: hidden;
  transition: height 0.3s cubic-bezier(0, 0, 0.2, 1), padding-inline 0.3s cubic-bezier(0, 0, 0.2, 1);
}

/* A little extra breathing room once the nav is actually pill-shaped —
   at the full-width unscrolled state the 1rem base padding reads fine
   against the wide bar, but against the pill's own rounded edge it read
   as too tight. */
.site-header.is-scrolled .nav,
.site-header.menu-open .nav {
  padding-inline: 1.5rem;
}

.nav__row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-shrink: 0;
  height: 3.5rem;
}

.nav__logo-link {
  display: flex;
  align-items: center;
}

.nav__logo-full {
  height: 42px;
  width: auto;
  display: block;
}

/* Hidden by default (desktop and tablet keep the full lockup even when
   scrolled); the mobile breakpoint below is what actually reveals it. */
.nav__logo-icon {
  display: none;
}

.icon-mark {
  fill: var(--black-cat);
}

/* The eyes shift left on hover, then ease back. transform values on an
   inlined SVG's children are resolved in the SVG's own 210x173 user-unit
   space, not final on-screen pixels — the nav logo renders at ~42px tall,
   roughly a 4.1x scale-down, so translating by the visually-intended 5px
   needs ~20.6 user-units here to still read as 5px on screen. The footer
   logo is bigger (see .footer__logo), so the same 20.6 user-units reads as
   a proportionally larger shift there — same motion, scaled with the mark. */
.icon-eye {
  transition: transform 0.7s var(--ease);
}

.nav__logo-link:hover .icon-eye,
.footer__logo-link:hover .icon-eye {
  transform: translateX(-20.6px);
}

.nav__links {
  display: flex;
  align-items: center;
  gap: 2.0625rem;
}

.nav__links a {
  position: relative;
  display: inline-block;
  color: var(--black-cat);
  text-decoration: none;
  font-size: 1.0625rem;
  font-weight: 600;
  /* Without this, a quick double-tap on iOS Safari triggers its
     double-tap-to-zoom gesture instead of registering as two taps on the
     link — manipulation drops that gesture on this element specifically
     while leaving pinch-zoom untouched elsewhere on the page. */
  touch-action: manipulation;
  /* Base rule's transition governs mouseLEAVE (reverting to this color) —
     slow, so fading back to rest reads as a deliberate fade. The :hover
     rule below overrides it with a fast one for the reverse direction, so
     engaging on hover feels immediate while releasing it feels gradual. */
  transition: color 0.25s ease-in-out;
}

.nav__links a:hover,
.nav__links a:focus-visible {
  color: var(--periwinkle-link);
  transition: color 0.15s ease-out;
}

.nav__toggle {
  display: none;
  flex-direction: column;
  justify-content: center;
  gap: 5px;
  width: 2.25rem;
  height: 2.25rem;
}

/* :not(.visually-hidden) excludes the toggle's hidden "Menu" label — this
   selector's specificity (one class + one element) otherwise beats
   .visually-hidden's own width/height (one class alone), which blew that
   label up to a large visible-sized box instead of the intended 1x1px. */
.nav__toggle span:not(.visually-hidden) {
  display: block;
  width: 100%;
  height: 2px;
  background: var(--black-cat);
  /* Not var(--ease) — that token is a punchy curve that reaches ~100% early
     then flattens out, which reads as a harsh start and a wobbly landing.
     This curve is deliberately front-loaded (fast start) with a long, soft
     tail that keeps easing all the way to zero velocity, rather than
     stopping with any perceptible speed left — that abrupt full-stop is
     what reads as a jitter/bounce right at the end. */
  transition: transform 0.5s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}

.nav__toggle[aria-expanded="true"] span:first-child {
  transform: translateY(3.5px) rotate(45deg);
}

.nav__toggle[aria-expanded="true"] span:nth-child(2) {
  transform: translateY(-3.5px) rotate(-45deg);
}

/* Mobile links, contained in the pill */

/* No gap and no padding-block here — spacing lives on each `a` itself (see
   below) so that adjacent links sit flush against each other with zero dead
   space between them. That dead space would otherwise show as a white gap
   the hover fill can't reach, since the fill only ever covers its own link's
   box. */
/* Opacity fade to match .nav-bg's own — without it, the links had no
   transition of their own at all, so closing the menu just abruptly
   clipped fully-opaque text away as the pill's height collapsed instead
   of fading it out the way the background does. Same 0.2s ease-out as
   .nav-bg for both to read as one cohesive close, not two separately
   timed effects. */
.nav__mobile-links {
  display: flex;
  flex-direction: column;
  opacity: 0;
  transition: opacity 0.2s ease-out;
}

.site-header.menu-open .nav__mobile-links {
  opacity: 1;
}

/* The fill/text-color/arrow motion here is driven entirely in JS via the Web
   Animations API (see script.js) rather than CSS :hover + transition — it
   needs to read each element's *current* on-screen value and animate from
   there, so that moving quickly between adjacent links interrupts and
   reverses smoothly instead of snapping. These rules are just the static
   layout/positioning; they hold no hover-triggered values themselves. */
.nav__mobile-links a {
  position: relative;
  display: flex;
  align-items: center;
  gap: 0.75rem;
  /* Vertical padding lives directly on the link (not a container gap) so the
     hover fill, which covers this box's full padding area, reaches all the
     way to the next link's box with no seam. Horizontal padding bleeds out
     to the pill's true edges (negative margin cancels the pill's own 1.5rem
     padding-inline, which is what's in force whenever these links are
     actually visible — see .site-header.menu-open .nav above) so the fill
     has no white showing at either side. */
  padding: 0.7rem 1.5rem;
  margin-inline: -1.5rem;
  overflow: hidden;
  font-family: var(--font-heading);
  font-size: 2.25rem;
  text-decoration: none;
  /* See .nav__links a's comment on touch-action — these are the links a
     double-tap on mobile most often lands on, since the menu is mobile-only. */
  touch-action: manipulation;
}

/* Extra breathing room at the very top and bottom of the list, applied to
   the first/last link's own padding rather than the container so the last
   link's hover fill still reaches the pill's bottom edge — where .nav's
   overflow:hidden and rounded corners clip it into the pill's shape,
   fulfilling the "fills the entire bottom area" requirement. */
.nav__mobile-links a:first-child {
  padding-top: 1.25rem;
}

.nav__mobile-links a:last-child {
  padding-bottom: 1.5rem;
}

/* Parked one full width off-screen to the left (clipped by the link's own
   overflow:hidden). JS slides it to translateX(0) to cover the link on
   hover, then all the way to translateX(100%) — off through the right
   edge — on hover-out, so the highlight always travels the same direction
   and never reverses back the way it came. */
.nav__mobile-link-fill {
  position: absolute;
  inset: 0;
  background: var(--periwinkle);
  transform: translateX(-100%);
  z-index: 0;
  pointer-events: none;
}

.nav__mobile-link-text {
  position: relative;
  z-index: 1;
  color: var(--black-cat);
}

.nav__mobile-link-arrow {
  position: relative;
  z-index: 1;
  width: 1.625rem;
  height: 1.3rem;
  flex-shrink: 0;
  color: var(--black-cat);
}

/* ==========================================================================
   Buttons
   ========================================================================== */

.btn {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0.85rem 1.75rem;
  border: 2px solid transparent;
  border-radius: 4px;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 1.125rem;
  line-height: 1.2;
  text-decoration: none;
  overflow: hidden;
  transition: color 0.35s var(--ease), border-color 0.35s var(--ease);
}

.btn::before {
  content: "";
  position: absolute;
  inset: 0;
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 0.35s var(--ease);
  z-index: -1;
}

.btn:hover,
.btn:focus-visible {
  text-decoration: none;
}

.btn:hover::before,
.btn:focus-visible::before {
  transform: scaleX(1);
}

/* A button that's also a scroll-reveal element (e.g. .statement__cta below)
   carries both .btn's hover transitions and .reveal's entrance transition on
   the very same element. `transition` is a shorthand — whichever selector
   wins the cascade replaces the WHOLE list, it doesn't merge with a lower-
   priority one — so without this higher-specificity combined rule, .reveal's
   own transition (declared later in this file, see Scroll reveal) would
   silently drop .btn's color/border-color transitions entirely, leaving
   hover changes to snap with no animation at all. This is the actual list in
   effect for any element carrying both classes: entrance keeps .reveal's own
   opacity/transform timing, delayed further still (0.3s, not .reveal's own
   plain entrance) — on .statement__cta specifically, .reveal's own
   IntersectionObserver crosses threshold at roughly the same scroll moment
   as .statement__text's separate line-by-line reveal (see splitStatementLines
   in script.js), so with only a small delay the button was appearing
   alongside or even before that text had finished cascading in, rather than
   clearly following it. Hover itself is asymmetric: entering is quick,
   leaving is a slower ease-in fade (rather than a held pause that then
   snaps into a transition) — overridden to the quick version on hover
   below. */
.reveal.btn {
  transition: opacity 0.7s var(--ease) 0.3s, transform 0.7s var(--ease) 0.3s, color 0.3s ease-in,
    border-color 0.3s ease-in;
}

.reveal.btn:hover,
.reveal.btn:focus-visible {
  transition: opacity 0.7s var(--ease) 0.3s, transform 0.7s var(--ease) 0.3s, color 0.2s var(--ease),
    border-color 0.2s var(--ease);
}

.btn--on-light {
  background: transparent;
  color: var(--black-cat);
  border: 2px solid var(--black-cat);
}

.btn--on-light::before {
  background: var(--periwinkle);
  /* Fades in place rather than the base .btn::before left-to-right slide.
     Fading out (hover-off) is slower with an ease-in curve — overridden to
     a quicker fade-in on hover below — so releasing reads as a gradual fade
     rather than a held pause that then snaps into a transition. */
  transform: none;
  opacity: 0;
  transition: opacity 0.3s ease-in;
}

.btn--on-light:hover,
.btn--on-light:focus-visible {
  color: var(--white);
  border-color: var(--periwinkle);
}

.btn--on-light:hover::before,
.btn--on-light:focus-visible::before {
  opacity: 1;
  transition: opacity 0.2s var(--ease);
}

/* Unlike .btn--on-light, this variant starts with an opaque
   fill rather than transparent — so the shared .btn::before slide-in (a
   negative z-index layer meant to reveal itself over nothing) would paint
   *behind* that opaque background and never actually show, while the hover
   text color would still switch, leaving invisible text on an unchanged
   background. Bypassing ::before and transitioning background/color
   directly avoids that entirely. */
.btn--pale-green {
  background: var(--green-pale);
  color: var(--black-cat);
  border-color: var(--green-pale);
  transition: background-color 0.35s var(--ease), color 0.35s var(--ease),
    border-color 0.35s var(--ease);
}

.btn--pale-green::before {
  content: none;
}

.btn--pale-green:hover,
.btn--pale-green:focus-visible {
  background: var(--black-cat);
  color: var(--green-pale);
  border-color: var(--black-cat);
}

/* ==========================================================================
   Hero
   ========================================================================== */

.hero {
  position: relative;
  /* Fills exactly the viewport height remaining below the header, so the
     hero's bottom edge lands at the bottom of the browser on page load —
     matching the reference site. `svh` (small viewport height) is used
     over plain `vh` so mobile browser chrome doesn't cause the hero to
     overflow past the visible area; the `vh` line is a fallback for
     browsers that don't support `svh`. */
  height: calc(100vh - var(--header-height));
  height: calc(100svh - var(--header-height));
  min-height: 26rem;
  overflow: hidden;
}

.hero__slide {
  position: absolute;
  inset: 0;
  transform: translateX(100%);
  transition: transform 0.7s var(--ease);
  display: flex;
  align-items: flex-end;
}

/* Without an explicit size, a <video> renders at its own intrinsic pixel
   dimensions (e.g. 1920x1080) as a flex item here — left-aligned by the
   flex default and clipped by .hero's overflow:hidden, so the visible crop
   sits toward the video's left edge instead of its center. Filling the
   slide's box and letting object-fit crop the overflow keeps it centered
   and full-bleed at any viewport size instead. */
.hero__slide-video,
.hero__slide-image {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

.hero__slide-video {
  object-fit: cover;
  object-position: center;
}

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

.hero__slide.is-active {
  transform: translateX(0);
}

.hero__slide.is-leaving {
  transform: translateX(-100%);
}

.hero__slide--blue {
  background: var(--gradient-blue);
}

.hero__slide--green {
  background: var(--gradient-green);
}

.hero__scrim {
  position: absolute;
  inset: 0;
  background: linear-gradient(
    to top,
    rgba(5, 0, 20, 0.55) 0%,
    rgba(5, 0, 20, 0) 42%
  );
}

.hero__content {
  position: absolute;
  inset: auto 0 0 0;
  padding: var(--site-padding);
  padding-bottom: 3rem;
  padding-right: 12rem;
  color: var(--white);
  z-index: 1;
  /* Text swaps via a quick fade, independent of the background slide's
     transform — it should never appear to slide along with the image. */
  transition: opacity 0.18s var(--ease);
}

.hero__content.is-fading {
  opacity: 0;
}

.hero__eyebrow {
  display: block;
  font-size: 1.1rem;
  opacity: 0.85;
  margin-bottom: 0.5rem;
}

.hero__title {
  font-size: clamp(1.3rem, 2.75vw, 1.95rem);
  max-width: 20ch;
}

.hero__title-link {
  color: inherit;
  text-decoration: none;
}

.hero__title-link:hover,
.hero__title-link:focus-visible {
  text-decoration: underline;
  text-underline-offset: 0.08em;
}

.hero__meta {
  position: absolute;
  right: var(--site-padding);
  bottom: 3rem;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 1rem;
  color: var(--white);
}

.hero__counter {
  font-variant-numeric: tabular-nums;
  font-size: 0.95rem;
  opacity: 0.9;
}

#hero-current {
  display: inline-block;
  transition: transform 0.2s var(--ease), opacity 0.2s var(--ease);
}

#hero-current.is-ticking-out {
  transform: translateY(-60%);
  opacity: 0;
}

#hero-current.is-ticking-in {
  transform: translateY(60%);
  opacity: 0;
  transition: none;
}

.hero__controls {
  display: flex;
  gap: 0.75rem;
}

@media (hover: hover) and (pointer: fine) {
  .hero__controls {
    display: none;
  }
}

.hero__control {
  width: 2.75rem;
  height: 2.75rem;
  border-radius: 50%;
  border: 1px solid rgba(255, 255, 255, 0.6);
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--white);
}

.hero__control:hover,
.hero__control:focus-visible {
  background: rgba(255, 255, 255, 0.15);
}

.hero__control svg {
  width: 1.1rem;
  height: 1.1rem;
}

/* Hover-driven prev/next/view zones — mouse + trackpad only; touch devices
   keep the always-visible controls above instead. */

.hero__zones {
  display: none;
}

@media (hover: hover) and (pointer: fine) {
  .hero__zones {
    display: flex;
    position: absolute;
    inset: 0;
    z-index: 1;
  }

  .hero__zone {
    flex: 1 1 33.333%;
    height: 100%;
    cursor: none;
  }

  .hero__zone-label {
    position: absolute;
    left: 0;
    top: 0;
    transform: translate(-50%, -50%);
    color: var(--green-pale);
    font-family: var(--font-heading);
    font-size: clamp(1.75rem, 3.4vw, 2.75rem);
    white-space: nowrap;
    pointer-events: none;
    opacity: 0;
    z-index: 2;
    transition: opacity 0.2s var(--ease);
  }

  .hero__zone-label.is-visible {
    opacity: 1;
  }

  .hero__zone-label svg {
    display: block;
    width: 1.61em;
    height: 1.57em;
    fill: currentColor;
  }
}

/* ==========================================================================
   Selected Work
   ========================================================================== */

.statement {
  display: grid;
  grid-template-columns: repeat(12, 1fr);
  column-gap: 1.5rem;
  padding-block: 5rem 3rem;
}

.statement__text {
  grid-column: span 11;
  font-family: var(--font-heading);
  font-size: clamp(2.25rem, 5.1vw, 3.75rem);
  line-height: 1.15;
}

/* Always its own line regardless of viewport width — not left to natural
   wrapping — since .statement__rest picks up right where it left off. No
   font-size/weight of its own: inherits .statement__text's directly, so
   it can never drift out of sync with .statement__rest (which also has
   no override of its own) the way two separately-tuned clamp() values
   could. */
.statement__role {
  display: block;
  color: var(--periwinkle);
}

.statement__line-mask {
  display: block;
  overflow: hidden;
}

/* opacity and transform deliberately don't share --ease here (unlike
   most pairings elsewhere in this file) — --ease's fast start meant
   opacity was rushing to fully solid within the first fraction of the
   animation, so in practice each line looked like it was just sliding
   up already-opaque past the mask's hard clip edge, not fading in at
   all. A slower, plain linear fade keeps it visibly translucent for
   longer while --ease still gives the position itself a quick,
   decisive settle. */
.statement__line {
  display: block;
  opacity: 0;
  transform: translateY(100%);
  transition: opacity 0.4s linear, transform 0.6s var(--ease);
}

.statement__line.is-revealed {
  opacity: 1;
  transform: translateY(0);
}

/* Exit state for the about-intro rotator's outgoing phrase — slides up and
   out (vs. the entrance's translateY(100%), which comes from below).
   Deliberately reuses .statement__line's own transition (same durations as
   the entrance) so the per-line stagger set in script.js reads as a clear
   mirror of the entrance, not a faster/different-feeling motion. */
.statement__line.is-exiting {
  opacity: 0;
  transform: translateY(-100%);
}

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

.statement__cta {
  grid-column: span 11;
  justify-self: start;
  margin-top: 2rem;
}

.work {
  padding-block: 3rem 5rem;
  /* Clears the sticky header when the nav/footer "Work" link jumps here
     (#work resolves to this section) — same pattern and spacing as
     .legal__body > section's own scroll-margin-top for the same reason. */
  scroll-margin-top: calc(var(--header-height) + 1rem);
}

.work__divider {
  border: none;
  border-top: 2px solid var(--shark-gray);
  margin: 0 0 2.5rem;
}

.work__heading {
  font-size: clamp(1.125rem, 1.6vw, 1.375rem);
  margin-bottom: 2.5rem;
}

.work-grid {
  display: grid;
  grid-template-columns: repeat(12, 1fr);
  column-gap: 1.5rem;
  row-gap: 2.5rem;
}

.project-card--large {
  grid-column: span 7;
}

.project-card--medium {
  grid-column: span 5;
}

/* .more-to-see is a single Prev/Next nav on every breakpoint (see
   project-template.html) — no separate desktop grid layout, so there's no
   per-breakpoint show/hide here the way .project-card--large/--medium
   need. Top padding is 0 and the bottom reuses .project's own exact
   trailing clamp() (the section right before this one) rather than
   inventing a separate value: .project's bottom padding already adds
   space above .more-to-see's box before this section's own padding even
   starts, so matching that same clamp on the bottom is what actually
   lands the nav row visually centered in the gap — a plain symmetric
   padding-block here would still read as top-heavy once .project's own
   trailing space is added in on top of it. */
.more-to-see {
  padding-block: 0 clamp(3rem, 6vw, 6rem);
}

.more-to-see__nav {
  display: flex;
  align-items: center;
  justify-content: space-between;
}

/* .more-to-see__nav-item is the column: "Prev:"/"Next:" (a plain <span>,
   not link content — see project-template.html) stacked above the actual
   <a>. Splitting them at this level, rather than putting the label inside
   the link, is what keeps the caption free of any link/hover state of its
   own — only .more-to-see__nav-link (the arrow + project name) is
   interactive. */
.more-to-see__nav-item {
  display: flex;
  flex-direction: column;
}

/* Next sits on the right side of the row (.more-to-see__nav uses
   justify-content:space-between) — right-aligning its own stacked label +
   link instead of leaving them left-aligned under itself reads as
   intentional there, matching Prev's flex-start on the left. */
.more-to-see__nav-item--prev {
  align-items: flex-start;
}

.more-to-see__nav-item--next {
  align-items: flex-end;
}

/* align-items:flex-end above only positions the label/link as whole
   blocks within the item — it says nothing about how text wraps INSIDE
   them. A longer project name ("President's Club") can wrap the link's
   .more-to-see__nav-name to two lines at narrower widths, and text
   defaults to start-aligned, so a shorter second line ("Club") would
   sit flush-left under the first line instead of matching the page's
   right margin the way the un-wrapped case does. text-align:right
   fixes that for whichever line wraps; harmless on a name that stays on
   one line, since a single line already fills its own box either way. */
.more-to-see__nav-item--next .more-to-see__nav-name {
  text-align: right;
}

/* Same type size AND weight as .more-to-see__nav-link below it — the two
   now read as one consistent scale rather than a small caption over a
   bolder, larger name. 1.875rem is the prior 1.5rem scaled up ~25%; line
   height is tightened well below the inherited body value (1.5) so the
   two stacked lines sit close together instead of reading like separate
   paragraphs. Still its own shark-gray, matching the link's own rest
   color, so the underline is what marks the link as the interactive part,
   not a color difference between the two. */
.more-to-see__nav-label {
  font-family: var(--font-heading);
  font-size: 2.0625rem;
  font-weight: 500;
  line-height: 1;
  color: var(--shark-gray-light);
}

/* shark-gray-light + underline at rest, periwinkle on hover/focus —
   underline signals "this part specifically is the link" now that the
   label above it is the same size, weight, and color otherwise. */
.more-to-see__nav-link {
  display: inline-flex;
  align-items: center;
  font-family: var(--font-heading);
  font-size: 2.0625rem;
  font-weight: 500;
  line-height: 1;
  color: var(--shark-gray-light);
  text-decoration: underline;
  /* Base rule's transition governs mouseLEAVE (reverting to this color) —
     slow, so fading back to rest reads as a deliberate fade rather than a
     snap. The :hover rule below overrides it with a fast one for the
     reverse direction, so engaging on hover feels immediate while
     releasing it feels gradual. ease-in-out here, not var(--ease): that
     shared curve is an ease-out (fast start, gentle finish) tuned for
     things entering/moving, which would front-load almost this whole
     transition into its first fraction regardless of the slower
     duration. */
  transition: color 0.25s ease-in-out;
}

.more-to-see__nav-link:hover,
.more-to-see__nav-link:focus-visible {
  color: var(--periwinkle);
  transition: color 0.15s ease-out;
}

/* Cursor-following preview thumbnail — same mechanic as
   .project-card__hover-label (see script.js's projectCardHover; this one's
   moreToSeeHoverPreview), gated behind the same hover:hover/pointer:fine
   media query so it never activates on touch. Off by default (matching
   .project-card__hover-label's own display:none/display:block split)
   rather than relying on opacity alone, so it can't affect layout or be
   tab/screen-reader reachable on a device that will never fire the
   mousemove listener that reveals it. Positioned via left/top set from the
   cursor's own coordinates in script.js, not centered on the link — a
   fixed-position thumbnail this size would overflow a small text link's
   own bounding box if it tried to stay pinned inside it the way the tiny
   arrow label does.

   Split into two elements on purpose: this outer one owns position:fixed
   and the transform (translate for cursor-following + scale for the
   reveal animation); the actual border-radius/border/background-image
   lives on the .more-to-see__nav-preview-media child, which carries NO
   transform of its own. Putting border-radius + a background-image on the
   SAME element that also has a transform is a known WebKit rendering bug
   — Safari can fail to clip the background to the rounded corners once
   that element gets promoted to its own compositing layer (which
   position:fixed + transform guarantees), leaving the image's square
   edges visible past the rounded border on some sides. Keeping the
   transformed element and the radius-clipped element separate sidesteps
   it entirely, regardless of browser. */
.more-to-see__nav-preview {
  display: none;
}

@media (hover: hover) and (pointer: fine) {
  .more-to-see__nav-preview {
    display: block;
    position: fixed;
    left: 0;
    top: 0;
    width: 11rem;
    aspect-ratio: 4 / 3;
    pointer-events: none;
    z-index: 20;
    opacity: 0;
    /* Anchors the image's bottom edge 16px above the cursor's own y —
       clear of the cursor itself rather than centered on top of it. */
    transform: translate(-50%, calc(-100% - 16px)) scale(0.96);
    transition: opacity 0.25s var(--ease), transform 0.25s var(--ease);
  }

  .more-to-see__nav-preview.is-visible {
    opacity: 1;
    transform: translate(-50%, calc(-100% - 16px)) scale(1);
  }

  /* No border — every version tried (translucent at two different alphas,
     opaque) either picked up a visible tint from whatever's behind this
     floating element or just looked bad on its own. Edge contrast against
     the page is the source image's job now: export preview images with a
     non-white edge instead of relying on a border to separate a white
     card edge from a white/off-white page. */
  .more-to-see__nav-preview-media {
    display: block;
    width: 100%;
    height: 100%;
    border-radius: 0.3125rem;
    overflow: hidden;
    background-color: var(--off-white);
    background-size: cover;
    background-position: center;
  }
}

.project-card__link {
  display: block;
  position: relative;
  color: inherit;
  text-decoration: none;
}

.project-card__hover-label {
  display: none;
}

@media (hover: hover) and (pointer: fine) {
  .project-card__link:hover {
    cursor: none;
  }

  .project-card__hover-label {
    display: block;
    position: absolute;
    left: 0;
    top: 0;
    transform: translate(-50%, -50%);
    color: var(--green-pale);
    font-family: var(--font-heading);
    font-size: clamp(1.25rem, 2vw, 1.75rem);
    white-space: nowrap;
    pointer-events: none;
    opacity: 0;
    z-index: 2;
    transition: opacity 0.2s var(--ease);
  }

  .project-card__hover-label.is-visible {
    opacity: 1;
  }

  .project-card__hover-label svg {
    display: block;
    width: 1.61em;
    height: 1.57em;
    fill: currentColor;
  }
}

.project-card__link:hover,
.project-card__link:focus-visible {
  text-decoration: none;
}

.project-card__media {
  position: relative;
  aspect-ratio: 4 / 3;
  overflow: hidden;
  margin-bottom: 1.25rem;
  transition: transform 0.4s var(--ease);
}

.project-card__link:hover .project-card__media,
.project-card__link:focus-visible .project-card__media {
  transform: scale(1.015);
}

/* Subtle darkening on hover — a plain ::before over the media, not a
   filter/opacity change on the media itself, so it can't also dim the
   green cursor-following arrow (.project-card__hover-label) that sits
   above it — that's the whole point: a slightly darker image makes that
   green stand out a bit more, not the other way round. z-index:1 is
   still safely below the label's own z-index:2 for the same reason. */
.project-card__media::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  background: var(--black-cat);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.25s ease-in-out;
}

.project-card__link:hover .project-card__media::before,
.project-card__link:focus-visible .project-card__media::before {
  opacity: 0.22;
  transition: opacity 0.15s ease-out;
}

.project-card__title {
  font-size: 1.375rem;
  margin-bottom: 0.35rem;
}

.project-card__tagline {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
}

.project-card__tag {
  background: var(--off-white);
  color: var(--black-cat);
  padding: 0.25rem 0.65rem;
  border: 1px solid rgba(79, 78, 86, 0.4);
  border-radius: 4px;
  font-size: 0.85rem;
  line-height: 1.3;
}

/* ==========================================================================
   Footer
   ========================================================================== */

.site-footer {
  background: var(--off-white);
  color: var(--midnight);
  /* Bottom padding must comfortably exceed .reveal's translateY(48px) offset —
     it's the last section on the page, and a reveal-animated element can
     never be scrolled into view if its pre-reveal transform pushes it past
     the document's actual scrollable height. */
  padding-block: 4rem 5rem;
  border-top: 1px solid var(--midnight);
}

.site-footer a {
  display: inline-flex;
  align-items: center;
  color: var(--midnight);
  /* Slow fade back to rest on mouseleave; .footer__col a:hover below
     overrides with a fast transition for engaging hover instead. */
  transition: color 0.25s ease-in-out;
}

.footer__arrow {
  flex-shrink: 0;
  width: 0.6rem;
  height: 0.5rem;
  margin-right: 0.5rem;
  transition: transform 0.2s var(--ease);
}

.site-footer a:hover .footer__arrow,
.site-footer a:focus-visible .footer__arrow {
  transform: translateX(3px);
}

.footer__top {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 2rem;
  flex-wrap: wrap;
  padding-bottom: 3rem;
  margin-bottom: 2rem;
  border-bottom: 1px solid var(--midnight);
}

.footer__logo {
  height: 3.75rem;
  width: auto;
}

.footer__link-columns {
  display: flex;
  align-items: flex-start;
  gap: 5.5rem;
}

.footer__col {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.footer__col a:hover,
.footer__col a:focus-visible {
  color: var(--periwinkle-link);
  text-decoration: none;
  transition: color 0.15s ease-out;
}

.footer__bottom {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  flex-wrap: wrap;
  font-size: 0.875rem;
  opacity: 0.85;
}

.footer__legal {
  display: flex;
  gap: 1.5rem;
}

.footer__back-to-top {
  display: inline-flex;
  flex-direction: column;
  align-items: center;
  gap: 0.75rem;
  color: var(--midnight);
  /* Slow fade back to rest on mouseleave; the :hover rule below overrides
     with a fast transition for engaging hover instead. */
  transition: color 0.25s ease-in-out;
}

.footer__back-to-top:hover,
.footer__back-to-top:focus-visible {
  color: var(--periwinkle-link);
  transition: color 0.15s ease-out;
}

.footer__back-to-top-icon {
  flex-shrink: 0;
  width: 2rem;
  height: 1.94rem;
  transform: rotate(-90deg);
}

.footer__back-to-top-icon path {
  stroke: currentColor;
  stroke-width: 0.3;
}

/* Two animations handing off at the same instant: a single rise, then a
   continuous gentle bounce that repeats for as long as it's hovered —
   without ever replaying that initial rise. Driven by a JS-toggled class
   (not :hover directly) so hover-off can ease back to rest smoothly instead
   of snapping — CSS animations, unlike transitions, don't animate their own
   removal, so :hover alone can't produce a graceful return. */
.footer__back-to-top-icon.is-bouncing {
  animation-name: footer-rise-once, footer-gentle-bounce;
  animation-duration: 0.9s, 1.4s;
  animation-delay: 0s, 0.9s;
  animation-iteration-count: 1, infinite;
  animation-fill-mode: forwards, none;
  animation-timing-function: linear, linear;
}

/* translateY comes before rotate() so the movement happens in true
   screen-space up/down — with rotate() first, the translate would happen in
   the icon's own (already-rotated) local axis and move sideways instead.
   Easing matches constant-acceleration ("gravity") physics — the same
   easeOutQuad/easeInQuad pair as footer-gentle-bounce below, for a
   continuous decelerate-then-accelerate motion rather than the earlier
   "expo" curves, which pinned both control points at the target value and
   produced a snap-then-hang look instead of smooth motion. */
@keyframes footer-rise-once {
  0% {
    transform: translateY(0) rotate(-90deg);
    animation-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94);
  }
  45% {
    transform: translateY(-60%) rotate(-90deg);
    animation-timing-function: cubic-bezier(0.55, 0.085, 0.68, 0.53);
  }
  100% {
    transform: translateY(0) rotate(-90deg);
  }
}

@keyframes footer-gentle-bounce {
  0%,
  100% {
    transform: translateY(0) rotate(-90deg);
    animation-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94);
  }
  50% {
    transform: translateY(-20%) rotate(-90deg);
    animation-timing-function: cubic-bezier(0.55, 0.085, 0.68, 0.53);
  }
}

/* ==========================================================================
   Work page (work.html)
   --------------------------------------------------------------------------
   .work-page reuses .work-grid (see Selected Work above) directly rather
   than inventing a parallel grid system — same 12-column track, same
   .project-card component, just a third row-shape added below
   (.project-card--third, span 4, three to a row) alongside the existing
   --large/--medium pair. Row 1 here is literally that same
   large+medium pair (spans 7+5=12), matching the homepage's own first
   row on purpose — see work.html's own comment on why.
   ========================================================================== */

.project-card--third {
  grid-column: span 4;
}

.work-page {
  background: var(--black-cat);
  border-top: 1px solid var(--black-cat);
  padding-block: clamp(2.5rem, 6vw, 4rem) clamp(3rem, 6vw, 6rem);
}

.work-page__heading {
  /* Matches .project__name (the project-page title) exactly, per the
     brief — this is that same "one level below the hero" heading
     weight, just repurposed as this page's own top-level title. */
  font-size: clamp(1.75rem, 3vw, 2.5rem);
  color: var(--off-white);
  margin-bottom: clamp(2rem, 5vw, 3rem);
}

/* .project-card__title has no color of its own elsewhere (it inherits
   body's black-cat, correct against every other section's light
   background) — needs an explicit override here specifically, since
   .work-page's own background just went dark. */
.work-page .project-card__title {
  color: var(--off-white);
}

/* Same reasoning as .project-card__title above — .project-card__tag's
   own off-white-fill/black-cat-text treatment is built for a light
   section and reads as a filled-in block here; transparent + off-white
   border/text lets it sit against .work-page's dark background instead. */
.work-page .project-card__tag {
  background: transparent;
  color: rgba(255, 254, 253, 0.8);
  border-color: rgba(255, 254, 253, 0.7);
}

/* --- Creative Partners --- */

.creative-partners {
  background: var(--off-white);
  padding-block: clamp(3rem, 7vw, 6rem);
}

.creative-partners__heading {
  font-size: clamp(2rem, 4vw, 3rem);
  color: var(--black-cat);
  text-align: center;
  margin-bottom: clamp(2.5rem, 6vw, 4rem);
}

/* Narrower than the section's own 1600px container on purpose — at the
   full width, four logos per row read as too spread out; the
   heading/quotes around it stay at the regular width. A percentage
   (not a fixed max-width alone) so the indent stays visible at every
   viewport: 1600px is this container's own ceiling, not its usual
   width — at a smaller laptop, the container itself is already well
   under 1400px, so a flat "max-width: 1400px" cap was never actually
   engaging there and this was reading as full-width. 1400px is now
   just a ceiling on how wide the 88% is allowed to get on very wide
   screens, not the thing doing the indenting. */
.creative-partners__logos {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  /* Smaller gap doubles as giving each logo more room — narrower gutters
     mean each of the 4 equal-fr columns claims more of the row's width. */
  gap: clamp(0.375rem, 0.75vw, 0.625rem);
  width: 88%;
  max-width: 1400px;
  margin-inline: auto;
  margin-bottom: clamp(3rem, 7vw, 6rem);
}

/* Plain slot for one logo image — no border/padding of its own; the
   aspect-ratio box plus the img's own object-fit (below) is all that's
   needed to size and center it. */
.partner-logo {
  aspect-ratio: 3 / 1.4;
  display: flex;
  align-items: center;
  justify-content: center;
}

/* object-fit: contain — every source file is the same 898x473 canvas
   (see work.html's own comment on why that matters for consistent
   scaling across all 16), but contain rather than cover regardless,
   so a differently-proportioned logo added later still fits cleanly
   instead of cropping. */
.partner-logo img {
  width: 100%;
  height: 100%;
  object-fit: contain;
}

/* Desktop/tablet default: Stephanie Fowler's own logo shows, The Mars
   Agency's mobile stand-in (see work.html's own comment on this slot)
   stays hidden — the ≤768px override below flips both. */
.partner-logo__mobile-swap {
  display: none;
}

/* 16 total in the markup — desktop (this breakpoint's default, no
   override needed) shows all of them, a full extra row over what
   tablet/mobile show; those two narrow it down further in the
   responsive overrides below. */

/* Gap trimmed down from its first pass — at 3 equal columns, that's
   what actually gives each column more real width to wrap into (the
   column width, not .partner-quote's own max-width below, is what was
   capping words-per-line once the text got big — see that rule's own
   comment). */
/* max-width here (not just a bigger gap) is what actually fixes "wide on
   a wide desktop, tight once scaled down": .partner-quote's own text
   stops growing once its font-size clamp hits its cap (~1650px
   viewport and up), but this grid's 1fr columns don't — past that
   point, the extra column width was becoming empty margin INSIDE each
   column (centered under .partner-quote's own margin-inline: auto),
   which no amount of shrinking the actual grid gap could touch, since
   that empty space was never part of the gap to begin with. Capping the
   whole grid's width keeps column growth roughly matched to what the
   (also capped) text can use, so the real gap value below stays the
   dominant, predictable factor at every width instead of only at
   narrower ones. */
.creative-partners__quotes {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: clamp(1rem, 2vw, 1.5rem);
  max-width: 1300px;
  margin-inline: auto;
}

/* Widened from 30ch — mattered less than the column width above for
   words-per-line, but was still the nearer constraint at smaller
   viewports before the grid gap became the tighter of the two. */
.partner-quote {
  max-width: 34ch;
  margin-inline: auto;
  text-align: center;
}

/* No font-style: italic here — Sora's @font-face (see top of this file)
   only registers a normal-style face, so an italic request would just
   trigger the browser's own synthesized/faked slant rather than a real
   italic, the same fake-style problem already fixed elsewhere on this
   site for font-weight. Satoshi does have a real italic file loaded, if
   italic ends up mattering more than the Sora switch.

   Sized down a step from the first pass at this (2.125–2.5rem) — still
   comfortably bigger than the original 1.0625rem body-italic treatment,
   just not quite as dominant, and the smaller glyphs alone fit
   noticeably more words per line at a given column width than either
   the wider max-width or the trimmed gap above do on their own. */
.partner-quote__text {
  font-family: var(--font-heading);
  font-weight: 400;
  font-size: clamp(1.375rem, 1.1rem + 1vw, 1.75rem);
  line-height: 1.25;
  color: var(--black-cat);
  margin-bottom: 1rem;
}

/* Matches .project__quote-cite (the project-template.html pull quote's
   own attribution line) exactly, per the brief. */
.partner-quote__attribution {
  font-size: 1.0625rem;
  font-weight: 600;
  color: var(--shark-gray);
  font-style: normal;
}

/* ==========================================================================
   About page (about.html)
   ========================================================================== */

.about-intro {
  border-top: 1px solid var(--black-cat);
  padding-block: clamp(2.5rem, 6vw, 4rem) clamp(2rem, 5vw, 3rem);
}

/* Same font-family/size/line-height as .statement__text (the homepage's
   own large sentence under the hero) verbatim, per the brief — a new
   class rather than reusing .statement__text directly, since that
   class also carries a grid-column: span 11 tied to .statement's own
   12-column grid, which this page has no equivalent of.

   Its line-by-line reveal is handled by splitAboutIntroLines in
   script.js, which wraps each wrapped line in .statement__line/
   .statement__line-mask — the exact same masked-slide-up treatment
   .statement__rest uses on the homepage, just triggered by a fixed
   delay after load rather than that one's IntersectionObserver, since
   this text sits above the fold with nothing to scroll: scroll-based
   triggering would fire the instant the observer attaches, giving a
   landing user no chance to actually see it animate.

   opacity:0 by default (unlike .statement__rest, which stays visible
   the whole time and only its child lines animate) — this text sits
   above the fold, so on a slow script.js fetch the plain paragraph
   was painting first, then visibly collapsing into hidden lines the
   moment the script ran, before animating back in. script.js adds
   .is-split synchronously in the same pass it builds the line spans,
   so the paragraph never shows its unwrapped state. */
.about-intro__text {
  max-width: 22ch;
  font-family: var(--font-heading);
  font-size: clamp(2.25rem, 5.1vw, 3.75rem);
  line-height: 1.15;
  opacity: 0;
}

.about-intro__text.is-split {
  opacity: 1;
}

/* Cycling periwinkle phrases beneath .about-intro__text, built by
   aboutIntroRotator in script.js using the same .statement__line/
   .statement__line-mask masked slide-up treatment. Same type scale/
   max-width as .about-intro__text so the two read as one statement. */
.about-intro__rotator {
  max-width: 22ch;
  font-family: var(--font-heading);
  font-size: clamp(2.25rem, 5.1vw, 3.75rem);
  line-height: 1.15;
  color: var(--periwinkle);
}

/* --- Quote cards --- */

.about-quotes {
  padding-block: clamp(2.5rem, 6vw, 4rem);
}

.about-quotes__grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  /* start, not the default stretch — a shared row height would erase
     the per-card stagger below, since every card would still end up
     the same height regardless of its own margin-top. */
  align-items: start;
  gap: clamp(1.5rem, 1.8vw, 2rem);
}

/* Staircase down from left to right — card 1 sits highest (no offset),
   each one after it a bit lower than the last. */
.about-quote-card:nth-child(2) {
  margin-top: clamp(1.5rem, 3vw, 2.5rem);
}

.about-quote-card:nth-child(3) {
  margin-top: clamp(3rem, 6vw, 5rem);
}

/* margin:0 resets the browser's own default blockquote margin (1em
   40px) — .about-quote-card is a <blockquote>, and that default was
   quietly padding every card out sideways underneath the intentional
   gap/stagger values below, most visible once the mobile layout
   became a horizontal row instead of a stacked column. */
.about-quote-card {
  display: flex;
  flex-direction: column;
  margin: 0;
  border: 1px solid var(--black-cat);
  border-radius: 10px;
  overflow: hidden;
}

.about-quote-card__image {
  aspect-ratio: 4 / 3;
}

/* Deliberately much smaller than .partner-quote__text (work.html's own
   quote treatment) — these are compact cards, not full display type.
   Same aspect-ratio as .about-quote-card__image above (rather than a
   fixed min-height) is what actually keeps this roughly matching the
   image's own height regardless of viewport width, since both then
   scale off the same card width; flex centering keeps a short
   placeholder line from looking stranded at the top of all that extra
   room. */
.about-quote-card__text {
  aspect-ratio: 4 / 3;
  display: flex;
  align-items: center;
  padding: 1.25rem;
  font-size: 0.9375rem;
  line-height: 1.5;
  color: var(--black-cat);
}

/* --- Capabilities --- */

.capabilities {
  /* z-index: 0 (not left at auto) is load-bearing: it's what actually
     makes this element establish its own stacking context. Without it,
     .capabilities__pattern's negative z-index (below) would escape this
     element entirely and stack against the nearest ancestor that *does*
     form one — likely painting behind the whole page instead of just
     behind this section's own content, which is what made the pattern
     invisible before this was added. */
  position: relative;
  z-index: 0;
  /* Matches .contact__intro's background exactly (--periwinkle-bright,
     not the sitewide --periwinkle) — same bright periwinkle used for
     both of the site's full-bleed periwinkle sections. */
  background: var(--periwinkle-bright);
  padding-block: clamp(3rem, 6vw, 5rem);
  /* Clears the sticky header when the nav/footer "Services" link jumps
     here (#services resolves to this section) — same pattern as .work's
     own scroll-margin-top for the same reason. */
  scroll-margin-top: calc(var(--header-height) + 1rem);
}

/* Same wave-pattern asset/technique as .contact__pattern (see its own
   comment for the background-size/repeat-y reasoning), flush against
   this section's own right edge. Unlike .contact__pattern, nothing here
   reserves clear space for it — z-index: -1 tucks it behind
   .capabilities__inner's content instead, so it's free to sit behind
   the shorter chips ("Web", "Social", etc.) and simply disappear behind
   the longer ones ("Creative Direction") rather than needing every chip
   row to individually clear its width. That also means it never eats
   into the text column's own available width, so it can't push a long
   label into wrapping at a width it previously fit. */
.capabilities__pattern {
  position: absolute;
  top: 0;
  bottom: 0;
  right: 0;
  width: clamp(10rem, 15vw, 19rem);
  z-index: -1;
  background-image: url("images/elements/waves.svg");
  background-repeat: repeat-y;
  background-position: top right;
  background-size: 100% auto;
  pointer-events: none;
}

/* .work__heading is reused bare (see the comment in about.html) and
   otherwise inherits the page's default text color, which reads fine
   on off-white but disappears on this section's periwinkle — scoped
   override rather than touching the shared class. */
.capabilities .work__heading {
  color: var(--off-white);
}

/* align-items:flex-start (not stretch) is what makes each item's own
   highlighter-chip background wrap tightly to its own text width
   instead of stretching full container width — a flex column's
   default cross-axis stretch would otherwise force every chip to the
   same width as the longest one. */
.capabilities__list {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.2em;
}

/* Highlighter-chip treatment (reference: antiaverage.studio/about's
   "Our services" list) — a light background tightly wrapping each
   line of bold text, stacked with a small gap, rather than the
   previous full-width rows with divider lines. Plain, unordered
   entries on purpose either way — the brief calls this "a start" or a
   list that will grow, so nothing here (no numbering, no
   nth-child-specific styling) needs to change shape when the next
   capability is added, just another <li><a>…</a></li> dropped in.

   The class lives on the <a> (each item links to its project page), with
   a bare <li> as the actual flex item — display:block below is what
   makes this <a> (inline by default) size and lay out exactly like the
   plain <li> this used to be. */
/* --highlight (0–1) is written every scroll frame by
   scrollLinkedCapabilitiesHighlight in script.js — it's each chip's
   distance from a trigger line in the viewport, so background-color
   below tracks scroll position directly rather than playing on a fixed
   timer once triggered. Living in the stylesheet (not set as an inline
   background-color) matters: it keeps this property in the normal
   cascade, so the :hover/:active rule further down — same specificity,
   later in source — still wins on its own terms instead of being
   permanently blocked by a higher-priority inline value. */
.capabilities__item {
  display: block;
  font-family: var(--font-heading);
  font-weight: 700;
  /* Matches .about-intro__text's size exactly (weight unchanged — this
     stays 700, the intro line is a plain 400) so the chips read at the
     same scale as the page's own opening line instead of the
     reference's larger, more banner-sized treatment. */
  font-size: clamp(2.25rem, 5.1vw, 3.75rem);
  line-height: 1.15;
  padding-block: 0.25rem;
  padding-inline: 0.3em;
  border-radius: 0.15em;
  --highlight: 0;
  background-color: color-mix(in srgb, var(--off-white), var(--green-pale) calc(var(--highlight) * 100%));
  color: var(--black-cat);
  text-decoration: none;
}

/* .reveal's own rule (below) sets `transition` too, on the same
   specificity (one class), and comes later in the cascade — so it would
   otherwise win outright and silently drop the background-color transition
   added here, rather than merge with it. The combined .capabilities__item
   .reveal selector out-specifies both, so this is the one true source for
   this element's full transition list. background-color's own duration is
   short — just enough to smooth over the discrete steps between scroll/
   rAF updates to --highlight above, not a deliberate fade of its own. */
.capabilities__item.reveal {
  transition: opacity 0.7s var(--ease), transform 0.7s var(--ease), background-color 0.1s linear;
}

/* text-decoration override: these are the global a:hover/a:focus-visible
   underline rule's default — an underline would fight the chip shape. */
.capabilities__item:hover,
.capabilities__item:active,
.capabilities__item:focus-visible {
  background-color: var(--green-pale);
  text-decoration: none;
}

/* --- Bio --- */

.about-bio {
  padding-block: clamp(2.5rem, 6vw, 5rem);
}

.about-bio__grid {
  display: grid;
  grid-template-columns: repeat(12, 1fr);
  align-items: start;
  column-gap: clamp(2rem, 5vw, 4rem);
}

/* 7 + 5 (not an even 6/6) is what actually lands the image at "a little
   less than half" the row, per the brief. */
.about-bio__content {
  grid-column: span 7;
}

.about-bio__text {
  max-width: 46ch;
  font-size: clamp(1.375rem, 1rem + 1.76vw, 2.15rem);
  font-weight: 600;
  line-height: 1.4;
  color: var(--shark-gray);
}

.about-bio__text + .about-bio__text {
  margin-top: 1.6rem;
}

.about-bio__image {
  grid-column: span 5;
  aspect-ratio: 1 / 1;
  border-radius: 4px;
}

/* ==========================================================================
   Scroll reveal
   ========================================================================== */

/* .project__media-item shares this treatment directly (see scrollReveal in
   script.js, which observes both selectors) rather than needing a .reveal
   class added in the markup on every <figure> — every image in every
   gallery pattern gets this for free, current ones and any added later,
   with nothing to remember to add per project page. */
.reveal,
.project__media-item {
  opacity: 0;
  transform: translateY(48px) scale(0.97);
  transition: opacity 0.7s var(--ease), transform 0.7s var(--ease);
}

.reveal.is-revealed,
.project__media-item.is-revealed {
  opacity: 1;
  transform: translateY(0) scale(1);
}

.work-grid .reveal:nth-child(2n) {
  transition-delay: 0.1s;
}

/* Left-to-right stagger within a row/pair of columns — items stacked
   top-to-bottom already reveal in that order for free (each one crosses
   the intersection threshold at its own scroll position), but items
   sitting side by side cross it at essentially the same moment, so THAT
   direction needs an explicit delay per column position to read as
   left-to-right rather than all popping in at once. Same
   :nth-child-based-delay convention as .work-grid above. */
project-media-row[cols="2"] > .project__media-item:nth-child(2),
project-media-row[cols="3"] > .project__media-item:nth-child(2) {
  transition-delay: 0.1s;
}

project-media-row[cols="3"] > .project__media-item:nth-child(3) {
  transition-delay: 0.2s;
}

project-media-columns > project-media-column:nth-child(2) > .project__media-item {
  transition-delay: 0.1s;
}

@media (prefers-reduced-motion: reduce) {
  .reveal,
  .project__media-item {
    opacity: 1;
    transform: none;
    transition: none;
  }
}

/* ==========================================================================
   Responsive
   ========================================================================== */

@media (max-width: 1024px) {
  .project-card--large,
  .project-card--medium,
  .project-card--third {
    grid-column: span 6;
  }

  /* .partner-quote__text's own clamp() bottoms out at 1.5rem — fine
     above this breakpoint, where each quote has real column width to
     work with, but .creative-partners__quotes still holds 3 columns
     all the way down to 768px (see its own mobile override below that),
     so at tablet widths that "minimum" was still too big for the
     shrunk-down column it actually had to wrap into. */
  .partner-quote__text {
    font-size: clamp(1.125rem, 1rem + 0.75vw, 1.375rem);
  }

  /* Tablet: 12 of the 16 (still 4 columns here, same as desktop — only
     the ≤768px mobile override below switches to 2) — one full row
     narrower than desktop's own 4. */
  .partner-logo:nth-child(n + 13) {
    display: none;
  }
}

@media (max-width: 768px) {
  /* Smaller than the 2.0625rem used everywhere else — long project names
     need room to actually fit on a narrow screen without wrapping badly
     or forcing every name to be abbreviated just to work here. */
  .more-to-see__nav-label,
  .more-to-see__nav-link {
    font-size: 1.5rem;
  }

  .statement__text,
  .statement__cta {
    grid-column: span 12;
  }

  .nav__links {
    display: none;
  }

  .nav__toggle {
    display: flex;
  }

  /* Logo is tuned down slightly for mobile. */
  .nav__logo-full {
    height: 39.9px;
  }

  /* Icon-only mark replaces the full lockup once the mobile pill engages
     (scrolled or menu open) — the narrow pill doesn't have room for the
     wordmark. Same height as .nav__logo-full above and the same 173-tall
     viewBox in both SVGs, so the swap is a pure width change: the mark's
     vertical position stays pixel-identical, nothing jumps when the pill
     kicks in. The eye hover shift is inert on real touch devices (no
     hover), but the markup keeps the same .icon-eye class either way so
     it still works from a mouse/trackpad on a narrow viewport. */
  .nav__logo-icon {
    display: none;
    height: 39.9px;
    width: auto;
  }

  .site-header.is-scrolled .nav__logo-full,
  .site-header.menu-open .nav__logo-full {
    display: none;
  }

  .site-header.is-scrolled .nav__logo-icon,
  .site-header.menu-open .nav__logo-icon {
    display: block;
  }

  .hero__content {
    padding-right: var(--site-padding);
    padding-bottom: 4.5rem;
  }

  .hero__eyebrow {
    display: none;
  }

  .hero__title {
    font-size: 1.1rem;
  }

  .hero__meta {
    left: var(--site-padding);
    right: var(--site-padding);
    bottom: 1.5rem;
    flex-direction: row;
    align-items: center;
    justify-content: space-between;
  }

  .work {
    padding-block: 3.5rem;
  }

  .work-grid {
    row-gap: 2.5rem;
  }

  .project-card--large,
  .project-card--medium,
  .project-card--third {
    grid-column: span 12;
  }

  .creative-partners__logos {
    grid-template-columns: repeat(2, 1fr);
  }

  /* Mobile: 8 of the 16 (2 columns x 4 rows) — narrower than tablet's
     own nth-child(n+13) cutoff below, so this also re-hides 9-12,
     which that rule alone would still leave visible at this width. */
  .partner-logo:nth-child(n + 9) {
    display: none;
  }

  /* Position 4's swap (see work.html's own comment): The Mars Agency
     instead of Stephanie Fowler on mobile specifically. */
  .partner-logo__default {
    display: none;
  }

  .partner-logo__mobile-swap {
    display: block;
  }

  .creative-partners__quotes {
    grid-template-columns: 1fr;
    gap: 3rem;
  }

  /* Slightly smaller than the clamp()'s 2.25rem floor — at these widths
     the clamp is already pinned to its min, so this is the only way to
     shave it down further. Narrower wrapped lines here reduce the odds
     of the rotator phrase below landing on a different line count than
     this lead line, which is what reads as the page "jumping." */
  .about-intro__text {
    font-size: 2rem;
  }

  /* One scrollable row instead of a stacked column — each card is
     wide enough to read comfortably but deliberately doesn't reach
     the container's edge, so the next card's left edge peeks in as a
     swipe affordance rather than looking like the row simply ends.

     margin-right cancels the section's own right-side .container
     padding on this element specifically, so the row's scrollable box
     reaches the true viewport edge instead of stopping short at the
     container's inner padding — without this, the peeking card was
     clipped a site-padding's-width early, leaving a blank strip of
     page background between the peek and the screen edge that read as
     the row simply ending, not continuing off-screen.

     Card width is deliberately NOT a percentage of this grid's own
     content box (e.g. flex-basis:82%) — that box shrinks by whatever
     padding-right reserves below, so a percentage card width and the
     padding meant to compensate for it both move together and can
     never resolve to "the last card can scroll fully flush," leaving
     a stray gap on the left after swiping all the way through. Anchor
     both the card width and the trailing reserve to 100vw instead,
     which doesn't shift, so the two stay in exact proportion and the
     last card's snap point is always fully reachable. */
  .about-quotes__grid {
    display: flex;
    grid-template-columns: none;
    --carousel-gap: 0.75rem;
    --carousel-peek: 4.5rem;
    gap: var(--carousel-gap);
    overflow-x: auto;
    scroll-snap-type: x mandatory;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
    margin-right: calc(-1 * var(--site-padding));
    padding-right: calc(var(--carousel-peek) + var(--carousel-gap));
  }

  .about-quotes__grid::-webkit-scrollbar {
    display: none;
  }

  .about-quote-card {
    flex: 0 0
      calc(100vw - var(--site-padding) - var(--carousel-peek) - var(--carousel-gap));
    scroll-snap-align: start;
  }

  /* The generic .reveal scroll-fade fights this carousel: a peeking
     card sitting mostly outside the viewport can end up below the
     IntersectionObserver's visibility threshold on load, so it never
     reveals until the user scrolls it further into view — but a user
     has no reason to swipe toward a card they can't see yet in the
     first place. The swipe itself is the reveal here, so these cards
     just show immediately instead of waiting on scroll-based intent. */
  .about-quote-card.reveal {
    opacity: 1;
    transform: none;
    transition: none;
  }

  /* Single row here — the desktop stagger's margin-top would just
     read as odd vertical jitter between cards sitting side by side. */
  .about-quote-card:nth-child(2),
  .about-quote-card:nth-child(3) {
    margin-top: 0;
  }

  /* Content first, image below — matches this section's own DOM order
     (see about.html), so no explicit reordering is needed here, just
     collapsing the 7/5 split back to a single column. */
  .about-bio__grid {
    grid-template-columns: 1fr;
    row-gap: 2rem;
  }

  .about-bio__content,
  .about-bio__image {
    grid-column: span 1;
  }

  /* Matches .about-intro__text's own mobile size (2rem flat) — see the
     desktop rule's comment for why. */
  .capabilities__item {
    font-size: 2rem;
  }

  /* Same z-index: -1 "sits behind the chips" rule as desktop/tablet
     (see the base rule's own comment) — narrowed rather than hidden,
     since that same behind-the-content trick means it's never actually
     competing with the (now full-bleed-ish) chip text for space here,
     just scaled down so it doesn't read as oversized on a narrow phone
     screen. */
  .capabilities__pattern {
    width: clamp(6rem, 24vw, 9rem);
  }

  /* Footer's own bottom padding is sized for desktop (see .site-footer's
     comment on why it must comfortably exceed .reveal's 48px offset) — 5rem
     read as an oversized gap once the mobile layout below is tightened up,
     so it's pulled in here. 4rem/3rem still clears the 48px reveal offset
     with room to spare. */
  .site-footer {
    padding-block: 3rem 3rem;
  }

  .footer__top {
    flex-direction: column;
    /* Desktop's flex-start (children only as wide as their own content)
       is what was leaving .footer__logo-link too narrow for its own
       width:100% svg to mean anything — stretch gives it the row's full
       width to actually fill. .footer__link-columns is unaffected: it
       already forces its own width:100% regardless. */
    align-items: stretch;
    padding-bottom: 2rem;
    margin-bottom: 1.25rem;
  }

  /* Full width instead of a fixed height on mobile — .footer__logo-link
     is a standalone row here (.footer__top goes flex-direction:column
     above), so unlike the header's logo there's no neighboring element
     it needs to leave room for. display:block on the link (an <a>,
     inline by default) gives the svg a proper block-level box to fill
     with width:100%, height:auto. */
  .footer__logo-link {
    display: block;
  }

  .footer__logo {
    width: 100%;
    height: auto;
  }

  /* Three columns — nav links, social links, back-to-top — spaced evenly
     across the full row instead of clustered to the left with a fixed gap. */
  .footer__link-columns {
    width: 100%;
    justify-content: space-between;
    gap: 1rem;
  }

  /* Copyright + legal links tightened into a stacked block instead of the
     wrapping row used on desktop, which left an uneven gap when "Privacy
     Policy" / "Terms & Conditions" wrapped onto their own line. */
  .footer__bottom {
    flex-direction: column;
    align-items: flex-start;
    gap: 0.5rem;
  }
}

/* ==========================================================================
   Legal pages (privacy.html, terms.html)
   --------------------------------------------------------------------------
   Long-form prose, which the base reset deliberately strips down: `p` has no
   margin and `ul` has no markers or indent site-wide, since almost every
   other block on the site lays those out itself. Everything here is scoped
   under .legal so restoring normal document flow for legal copy can't leak
   into the nav, footer, or work grid.

   Headings step down in real weight (700/600/500) alongside size for
   hierarchy — Sora's @font-face at the top of this file is a variable
   font covering that whole range, so these render as true weights
   rather than the browser's synthesized fake-bold.
   ========================================================================== */

.legal-hero {
  padding-top: calc(var(--header-height) + clamp(2rem, 6vw, 4.5rem));
  padding-bottom: clamp(2rem, 5vw, 3.5rem);
  background: linear-gradient(160deg, var(--off-white) 0%, var(--mint-pale) 100%);
  border-bottom: 1px solid rgba(5, 0, 20, 0.12);
}

.legal-hero__eyebrow {
  font-family: var(--font-heading);
  font-size: 0.75rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--blue-deep);
  margin-bottom: 1rem;
}

.legal-hero__title {
  font-size: clamp(2.25rem, 1.6rem + 3vw, 3.5rem);
  font-weight: 700;
  letter-spacing: -0.02em;
  margin-bottom: 1.25rem;
}

.legal-hero__dates {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem 1.5rem;
  font-size: 0.875rem;
  color: #4a4a5c;
}

.legal-hero__dates strong {
  font-weight: 500;
  color: var(--black-cat);
}

.legal {
  padding-top: clamp(2.5rem, 6vw, 4rem);
  padding-bottom: clamp(4rem, 8vw, 6rem);
}

/* Narrower than --site-max-width on purpose: the work grid wants the full
   1600px, a column of legal text does not. ~68ch keeps line length in the
   readable range at every viewport. */
.legal__body {
  max-width: 68ch;
  margin-inline: auto;
  line-height: 1.7;
}

.legal__body p {
  margin-bottom: 1rem;
}

.legal__body ul,
.legal__body ol {
  list-style: revert;
  padding-left: 1.25rem;
  margin-bottom: 1rem;
}

.legal__body li {
  margin-block: 0.35rem;
}

/* Nested list inside the service-provider bullet in section 6. */
.legal__body li > ul {
  margin-top: 0.5rem;
  margin-bottom: 0.5rem;
}

.legal__body h2 {
  font-size: clamp(1.25rem, 1.1rem + 0.6vw, 1.6rem);
  font-weight: 600;
  line-height: 1.25;
  letter-spacing: -0.01em;
  margin-bottom: 0.85rem;
}

.legal__body h3 {
  font-size: 1.0625rem;
  font-weight: 500;
  margin-top: 1.75rem;
  margin-bottom: 0.5rem;
}

.legal__body > section {
  margin-bottom: clamp(2.5rem, 5vw, 3.5rem);
  /* Clears the sticky header when a table-of-contents link jumps here. */
  scroll-margin-top: calc(var(--header-height) + 1rem);
}

.legal__divider {
  border: 0;
  border-top: 1px solid rgba(5, 0, 20, 0.12);
  margin-block: clamp(2rem, 5vw, 3rem);
}

/* --- Summary card --- */

.legal-summary {
  padding: 1.5rem clamp(1.25rem, 3vw, 2rem);
  background: var(--mint-pale);
  border-left: 3px solid var(--periwinkle);
  border-radius: 3px;
  margin-bottom: clamp(2rem, 5vw, 3rem);
}

.legal-summary h2 {
  font-size: 1rem;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

.legal-summary__note {
  font-size: 0.9375rem;
  margin-top: 1rem;
  margin-bottom: 0;
}

/* --- Table of contents --- */

.legal-toc h2 {
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: #4a4a5c;
  margin-bottom: 0.75rem;
}

.legal-toc ol {
  list-style: none;
  padding-left: 0;
  columns: 2;
  column-gap: 2rem;
  font-family: var(--font-heading);
  font-size: 0.9rem;
}

.legal-toc li {
  margin-bottom: 0.45rem;
  break-inside: avoid;
}

.legal-toc a {
  color: var(--black-cat);
  transition: color 0.25s ease-in-out;
}

.legal-toc a:hover,
.legal-toc a:focus-visible {
  color: var(--blue-deep);
  transition: color 0.15s ease-out;
}

/* --- Tables --- */

/* Wide tables scroll inside their own container rather than pushing the page
   sideways — html has overflow-x:hidden, so an unwrapped table would simply
   be clipped and unreachable on narrow screens. */
.legal-table {
  overflow-x: auto;
  margin-bottom: 1.25rem;
  -webkit-overflow-scrolling: touch;
}

.legal-table table {
  border-collapse: collapse;
  width: 100%;
  min-width: 34rem;
  font-size: 0.9375rem;
}

.legal-table caption {
  text-align: left;
  font-size: 0.8125rem;
  color: #4a4a5c;
  padding-bottom: 0.5rem;
}

.legal-table th,
.legal-table td {
  text-align: left;
  vertical-align: top;
  padding: 0.7rem 0.85rem;
  border-bottom: 1px solid rgba(5, 0, 20, 0.12);
}

.legal-table th {
  font-family: var(--font-heading);
  font-size: 0.75rem;
  /* Explicit rather than left to the browser's own UA-default bold for
     <th> — same real weight as this page's other small uppercase
     headings (.legal-summary h2, .legal-toc h2), stated on purpose
     instead of happening to land there implicitly. */
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  background: var(--mint-pale);
}

.legal-table tbody tr:last-child td {
  border-bottom: 0;
}

/* --- Pre-launch placeholders --- */

/* Highlights every value still to be confirmed before this page goes live.
   Delete this rule once the last .legal-fill span is gone, so a stray one
   can never ship looking like ordinary copy. */
.legal-fill {
  background: var(--green-pale);
  padding: 0.05em 0.4em;
  border-radius: 2px;
  font-family: var(--font-heading);
  font-size: 0.9em;
  box-decoration-break: clone;
  -webkit-box-decoration-break: clone;
}

/* --- Contact block --- */

.legal-contact {
  background: var(--black-cat);
  color: var(--white);
  border-radius: 3px;
  padding: clamp(1.5rem, 4vw, 2.25rem);
}

.legal-contact h2 {
  color: var(--white);
}

.legal-contact a {
  color: var(--green-pale);
  transition: color 0.25s ease-in-out;
}

.legal-contact a:hover,
.legal-contact a:focus-visible {
  color: var(--mint-pale);
  transition: color 0.15s ease-out;
}

.legal-contact address {
  font-style: normal;
  line-height: 1.8;
}

.legal-contact__terms {
  margin-top: 1rem;
  margin-bottom: 0;
}

.legal-contact .legal-fill {
  color: var(--black-cat);
}

@media (max-width: 700px) {
  /* Two columns of contents entries get too narrow to scan here. */
  .legal-toc ol {
    columns: 1;
  }
}

/* Long all-caps disclaimer blocks in terms.html. The caps are conventional in
   contract drafting (they mark the clause as conspicuous), but caps destroy
   word-shape cues and get hard to read at body size, so these are set a notch
   smaller with looser tracking and line height to keep them scannable rather
   than shouted. */
.legal-caps {
  font-size: 0.875rem;
  letter-spacing: 0.015em;
  line-height: 1.75;
}

/* ==========================================================================
   Project template (project-template.html)
   --------------------------------------------------------------------------
   Title, then the toggle in its own full-width row (so it sits above the
   gallery before any scrolling — not overlapping the first image), sticky
   so it stays reachable as the page scrolls. Below that, .project__row is a
   flex row: .project__rail (holds just the expanding panel) beside
   .project__gallery (flex: 1 1 auto). Closed, the rail is empty (the panel
   is `hidden`) and claims zero flex-basis, so the gallery is genuinely full
   width. Opening it gives the rail real flex-basis, leaving the gallery
   less space — it reflows into that on its own, which is the "push content
   right" effect, no positioning math needed. */

.project {
  padding-block: clamp(1.5rem, 4vw, 3rem) clamp(3rem, 6vw, 6rem);
}

.project__header {
  margin-bottom: 1.5rem;
}

.project__name {
  color: var(--periwinkle);
  font-size: clamp(1.75rem, 3vw, 2.5rem);
}

.project__description {
  margin-top: 0.75rem;
  font-size: 1.0625rem;
  color: var(--shark-gray);
  max-width: 40rem;
}

.project__toggle-row {
  position: sticky;
  top: calc(var(--header-height) + 1rem);
  z-index: 20;
  margin-bottom: clamp(2rem, 5vw, 3.5rem);
}

.project__row {
  display: flex;
  align-items: flex-start;
}

/* Sticky too, not just the toggle above it — otherwise, once scrolled, the
   open panel (ordinary in-flow content) scrolls away on its own while the
   pinned button stays put, so it looks like the panel vanished instead of
   staying open. The extra 6rem in `top` clears the toggle row's own
   rendered height + margin-bottom (~95px at rest) so the stuck panel starts
   right below the stuck button instead of underneath/behind it. */
/* z-index explicit, not left at auto: .project__feature later in this file
   promotes itself to its own stacking context (z-index:0, for its
   background pseudo-element) — without this, that puts it in the same
   stacking step as this sticky rail, and since it comes later in the DOM,
   ties there resolve to it painting ON TOP of the panel wherever they
   overlap while scrolling, regardless of any background opacity on the
   panel. Confirmed directly with elementFromPoint at an overlap point.
   Comfortably above .project__feature's 0 restores the panel as the
   topmost layer, which is what every other part of this flyout already
   assumes (e.g. the toggle button's own z-index for the same reason). */
/* Deliberately NOT var(--ease): that curve is an ease-out (fast start,
   gentle settle at the end) used everywhere else on the site, but this
   push specifically wanted the opposite feel — a slow start that
   accelerates into a quick finish — so it gets its own ease-in curve
   in-line rather than repurposing the shared token for something it
   wasn't designed for. Lengthened from 0.4s to 0.6s too: an ease-in curve
   needs enough duration for the slow opening portion to actually read as
   deliberate rather than just looking like a slightly late start. */
.project__rail {
  flex: 0 0 0;
  min-width: 0;
  position: sticky;
  top: calc(var(--header-height) + 1rem + 6rem);
  z-index: 10;
  transition: flex-basis 0.6s cubic-bezier(0.55, 0.055, 0.675, 0.19),
    margin-right 0.6s cubic-bezier(0.55, 0.055, 0.675, 0.19);
}

.project__rail.is-open {
  flex: 0 0 clamp(20rem, 30vw, 27rem);
  margin-right: clamp(1.5rem, 4vw, 3rem);
}

/* Translucent + blurred rather than .btn--pale-green's solid fill — once
   .project__toggle-row goes sticky it sits directly over gallery images, so
   a solid button would just look pasted on top; letting the image show
   through faintly (with the blur keeping the label readable over busy
   photos) reads as floating above the content instead. Overrides
   .btn--pale-green's background here and in the hover rule below via source
   order (equal selector specificity, declared later in the file).

   No visible border: a translucent border color over a blurred backdrop
   renders as a hard, bright-looking ring right at the blur's edge — much
   more solid than the same alpha reads as a fill — so the fill + blur alone
   define the shape instead. */
.project__toggle {
  gap: 0.5rem;
  padding: 0.55rem 1.1rem;
  font-size: 0.9375rem;
  background: rgba(209, 255, 194, 0.72);
  border-color: transparent;
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  /* .btn's own transition (color/border-color only) doesn't cover
     background, so this swap was snapping instantly regardless of hover
     state. Same fast-in/slow-out pattern as the site's text links: this
     base rule's transition governs reverting to green (slow, a deliberate
     fade), the :hover rule below overrides it with a fast one for
     engaging hover instead. */
  transition: background 0.25s ease-in-out;
}

.project__toggle:hover,
.project__toggle:focus-visible {
  background: var(--black-cat);
  border-color: transparent;
  transition: background 0.15s ease-out;
}

/* Stays on the dark hover-state color while the panel is open, rather
   than reverting to green the instant the cursor leaves — "open" reads as
   its own persistent state, not just a hover effect. Same value as the
   :hover rule above, so hovering an already-open button is simply a
   no-op color-wise (nothing to transition between). */
.project__toggle[aria-expanded="true"] {
  background: var(--black-cat);
  /* .btn--pale-green's own :hover rule is what turns the text green
     against this dark background — without setting color here too, the
     text falls back to that same class's REST color (black-cat), which
     reads as nearly invisible against the dark background this state
     shares with hover. */
  color: var(--green-pale);
  /* Rest/hover states stay borderless (see the comment above
     .project__toggle on why — a border over the blurred translucent fill
     reads as a harsh ring). Open is solid black-cat, not translucent, so
     that concern doesn't apply, and the border here helps this state
     read as distinct/pinned rather than just another hover color. */
  border: 1px solid var(--shark-gray);
}

/* A plus that rotates into an × on open — both bars are drawn on one
   pseudo-element pair and rotated together as a unit, so a single
   transform:rotate(45deg) turns the whole glyph rather than needing to
   animate each bar separately. */
.project__toggle-icon {
  position: relative;
  width: 0.6rem;
  height: 0.6rem;
  flex-shrink: 0;
  transition: transform 0.35s var(--ease);
}

.project__toggle-icon::before,
.project__toggle-icon::after {
  content: "";
  position: absolute;
  inset: 0;
  margin: auto;
  background: currentColor;
}

.project__toggle-icon::before {
  width: 100%;
  height: 2px;
}

.project__toggle-icon::after {
  width: 2px;
  height: 100%;
}

.project__toggle[aria-expanded="true"] .project__toggle-icon {
  transform: rotate(45deg);
}

/* Bounded to the space actually left below the sticky rail's own `top`, so
   long field content scrolls inside the panel instead of running off the
   bottom of the screen unreachable. Once it's a genuinely overflowing
   element, hovering it and scrolling routes the wheel/trackpad to this pane
   instead of the page automatically — that's just how nested scrollable
   regions work, no JS needed to split "scroll the panel" from "scroll the
   gallery" by cursor position. */
/* Translucent, not solid: this panel sits over .project__feature's
   background (see that rule — it always bleeds behind the rail now, on
   purpose), and showing a bit of that color through the panel reads as
   intentional glass rather than a hard, flat overlap. Desktop only — the
   mobile bottom sheet below overrides this back to a solid fill, since it
   sits over a dimmed backdrop instead, where translucency doesn't apply.

   margin-inline/padding-inline together widen the visible white area on
   both sides WITHOUT moving the text: negative margin pulls the box
   outward, equal positive padding pushes the content back in by the same
   amount, so the field text ends up at the exact same horizontal position
   it was at before — only the background got wider around it. */
.project__about {
  max-height: calc(100vh - (var(--header-height) + 1rem + 6rem) - 2rem);
  overflow-y: auto;
  border-radius: 10px;
  /* Testing only, per the brief — not committed to keeping this. Same
     rgba(shark-gray, X) pattern as .btn's and .project-card__tag's own
     borders elsewhere in this file, since var(--shark-gray) itself is a
     flat hex with no alpha channel to dial down. */
  border: 1px solid rgba(79, 78, 86, 0.2);
  background: rgba(255, 254, 253, 0.9);
  /* Blurs whatever shows through from behind (mainly .project__feature's
     black background as it scrolls past) into a soft glow instead of a
     hard-edged color visible right through the panel's translucency.
     Matches .nav-bg's own blur(1rem) for consistency across the site's
     glass-panel surfaces. */
  backdrop-filter: blur(1rem);
  -webkit-backdrop-filter: blur(1rem);
  margin-inline: -1.5rem;
  padding-inline: 1.5rem;
  /* Matches the mobile sheet's own top padding (see the 768px block,
     which resets this via its own padding shorthand rather than
     inheriting it) — without it .project__overview sits flush against
     the panel's top edge, which reads as cramped now that that edge
     also has a border/radius of its own. */
  padding-top: 1.5rem;
  /* Breathing room below the last field before the scrollable area
     ends — without it the last line sits flush against max-height's
     cutoff, same issue the mobile sheet had before its own bottom
     padding. */
  padding-bottom: 2rem;
  scrollbar-width: none; /* Firefox */
  -ms-overflow-style: none; /* legacy Edge */
}

.project__about::-webkit-scrollbar {
  display: none; /* Chrome, Safari, Chromium Edge */
}

/* Scroll hint: a small down-chevron, sticky-pinned to the bottom of the
   visible panel (same zero-height-anchor trick used elsewhere in this
   file — height:0 here so it never adds to the panel's own scrollable
   height, with the actual svg positioned against it), that bounces once
   a few seconds after the panel opens (see projectAboutScrollHint in
   script.js for the timing/state) and fades out the moment the user
   actually scrolls — its job is done once they've found the scroll,
   not to sit there indefinitely. Hidden outright via [hidden] when the
   content doesn't overflow at all; script.js is what flips that. */
.project__about-scroll-hint {
  position: sticky;
  bottom: 0.75rem;
  height: 0;
  z-index: 1;
  pointer-events: none;
  color: var(--shark-gray);
  opacity: 1;
  transition: opacity 0.3s ease;
}

.project__about-scroll-hint[hidden] {
  display: none;
}

.project__about-scroll-hint.is-dismissed {
  opacity: 0;
}

/* box-shadow, not filter:drop-shadow — tried drop-shadow first since it
   follows the chevron's own path instead of its bounding box, but the
   path is just a thin 2px stroke with no fill: there's barely any source
   shape for a blur to work with, so the resulting shadow stayed
   imperceptible even at a large blur radius (blurring a thin line
   spreads what little "ink" it has even thinner, rather than building up
   a visible glow). box-shadow uses the element's actual box instead of
   its rendered content, so it has real area to blur into a genuinely
   visible halo regardless of how sparse the icon itself is.
   border-radius:50% keeps that halo circular rather than square —
   without it the blur alone still softens the corners at this radius,
   but not into a clean circle. The spread (the 4th value) pushes the
   shadow's edge out before the blur softens it, giving the glow real
   presence instead of just a faint fringe right at the icon's edge. */
.project__about-scroll-hint svg {
  position: absolute;
  left: 50%;
  bottom: 0;
  transform: translateX(-50%);
  border-radius: 50%;
  box-shadow: 0 0 14px 4px rgba(255, 255, 255, 0.85);
}

.project__about-scroll-hint.is-bouncing svg {
  animation: project-about-scroll-hint-bounce 0.6s ease-in-out 1;
}

@keyframes project-about-scroll-hint-bounce {
  0%,
  100% {
    transform: translateX(-50%) translateY(0);
  }
  50% {
    transform: translateX(-50%) translateY(6px);
  }
}

@media (prefers-reduced-motion: reduce) {
  .project__about-scroll-hint.is-bouncing svg {
    animation: none;
  }
}

/* Mobile bottom sheet only (see the 768px block) — inert on desktop
   regardless of its hidden attribute, so this can't leak in above that
   breakpoint. */
.project__about-backdrop {
  display: none;
}

/* Browsers apply a default margin to <dl> too (like <figure> above) — reset
   so the first field's top edge actually matches the rail's, which is what
   keeps it level with the gallery's first image before any scrolling. */
.project__fields {
  margin: 0;
  display: flex;
  flex-direction: column;
  gap: 2rem;
}

/* Sits between two .project__fields <dl>s (see markup) — margin instead of
   the dl's own gap keeps the same rhythm on both sides without it. */
.project__overview {
  margin: 0;
  font-size: 0.95rem;
  line-height: 1.6;
}

.project__divider {
  border: 0;
  border-top: 1px solid var(--midnight);
  margin-block: 2rem;
}

.project__field dt {
  font-family: var(--font-heading);
  font-size: 0.95rem;
  color: #050014;
  margin-bottom: 0.4rem;
  font-weight: 600;
}

.project__field dd {
  margin: 0;
  font-size: 0.95rem;
  line-height: 1.6;
}

/* A field slot (e.g. Solution) can carry more than one <p> when a single
   paragraph doesn't cover it — the base reset zeroes every <p>'s own
   margin sitewide, so consecutive paragraphs need this back or they'd run
   together with no break between them. */
.project__field dd p + p {
  margin-top: 0.85rem;
}

/* The base reset strips all <ul> markers/spacing site-wide (see Reset /
   base), so a multi-item field like Contributions or Team needs its own
   list treatment restored here rather than inheriting one. */
.project__list {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
}

/* Single stacked column, full width — not a masonry grid. Any side-by-side
   look on the reference site is baked into individual image files, not a
   layout the CSS needs to reproduce. flex:1 1 auto + min-width:0 is what
   actually makes it fill the row's remaining space next to the rail:
   without min-width:0, a flex item's default min-width:auto keeps it from
   shrinking below its content width, which can force the row wider than its
   container instead of the gallery settling into the space left for it. */
.project__gallery {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: var(--gallery-gap);
}

/* A little extra breathing room before the gallery's first image/video
   specifically — separate from --gallery-gap (which only ever spaces
   consecutive items apart, never the first one from whatever's above
   it: the toggle button/rail). Fixed 24px rather than the gap variable,
   which is a responsive clamp() tied to the gallery's own width — this
   is a flat one-time offset, not spacing that should shrink along with
   everything else on a narrower gallery. Applies to every project page
   generically since it targets the gallery's first child regardless of
   which pattern (a plain figure, a project-media-row, a .project__quote,
   etc.) happens to lead. */
.project__gallery > :first-child {
  margin-top: 24px;
}

/* container-type:inline-size is what makes --gallery-gap's cqw unit track
   THIS element's own resolved width — the actual thing the balance math
   cares about — rather than the viewport (see the token comment). That
   matters specifically because .project__gallery's width isn't only a
   function of viewport size: opening the About panel widens .project__rail
   and leaves the gallery, its flex sibling, with less space, with the
   browser window never having changed size at all. vw is blind to that;
   cqw isn't.

   Scoped to min-width:769px on purpose, not applied unconditionally: it
   breaks flex-stretch sizing specifically in the mobile flex-direction:
   column context (verified directly — with it active there, the gallery's
   own resolved width collapsed to 0, taking any .full-bleed descendant's
   edge-to-edge math down with it). Harmless to leave off on mobile: the
   768px breakpoint already pins --gallery-gap back to a flat 24px there,
   so nothing on that side ever reads a cqw value regardless. */
@media (min-width: 769px) {
  .project__gallery {
    container-type: inline-size;
  }
}

/* Browsers apply a default margin to <figure> (e.g. 1em 40px) — reset to 0
   so it can't inset images past the container edge the button aligns to. */
.project__media-item {
  margin: 0;
}

.project__media-item img,
.project__media-item video {
  display: block;
  width: 100%;
  height: auto;
}

/* A gallery video that plays on demand rather than autoplaying (unlike the
   homepage hero's own muted loop). Two entirely different UIs on the same
   markup, split by hover capability rather than width alone, but both
   built from the same glass-circle-plus-icon language:

   - Touch (tablet + mobile, max-width:1024px below): a plain fixed-center
     glass circle holding just the play icon — there's no cursor to turn
     into anything, so it has to be a real, always-visible tappable
     target. Vanishes completely once playing ("remove any overlays").

   - Real pointer (hover:hover further below): the control IS the cursor.
     .project__video:hover sets cursor:none and script.js's
     projectVideoToggle streams the real cursor's position into this
     element's left/top (same mechanic as .project-card__hover-label
     elsewhere in this file) — a larger glass circle with the play icon
     while paused, swapping to a smaller one with a stop square once
     playing. Pointer-events:none here so clicks always land on the
     <video> underneath rather than this control, which already has its
     own click-to-toggle handler. */
.project__video {
  position: relative;
}

/* The actual positioning context for .project__video-toggle and
   .project__video-mute — deliberately NOT .project__video itself, because
   a captioned instance's .project__caption sits below the video inside
   that same figure, adding height nothing else here should account for.
   With top/left percentages (mobile toggle) or a mousemove-fed cursor
   position (desktop toggle) measured against .project__video's full box,
   that extra caption height pushed both below the video's actual visual
   center — this wrapper scopes the math to just the video + its buttons,
   which is what was visibly wrong on mobile before this existed. */
.project__video-frame {
  position: relative;
}

/* A finished (non-looping) video jumping straight back to its poster
   frame — see the "ended" handler in script.js — reads as an abrupt cut.
   .is-resetting (added for the fade-out, removed a frame after video.load()
   swaps the poster back in to fade it back in) turns that into a soft
   crossfade instead. */
.project__video-el {
  transition: opacity 0.3s var(--ease);
}

.project__video.is-resetting .project__video-el {
  opacity: 0;
}

.project__video-toggle {
  position: absolute;
  border: none;
  cursor: pointer;
  color: var(--off-white);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  background: rgba(255, 254, 253, 0.2);
  backdrop-filter: blur(1rem);
  -webkit-backdrop-filter: blur(1rem);
}

.project__video-toggle-icon--stop {
  display: none;
}

@media (max-width: 1024px) {
  .project__video-toggle {
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    width: 5rem;
    height: 5rem;
    transition: opacity 0.3s var(--ease);
  }

  /* 100% larger than the icon's base size (1.5rem) — mobile/tablet only,
     per the brief; the hover-driven desktop sizing further below (the
     `hover:hover and pointer:fine` block) is untouched. */
  .project__video-toggle-icon--play {
    width: 3rem;
    height: 3rem;
  }

  .project__video.is-playing .project__video-toggle {
    opacity: 0;
    pointer-events: none;
  }
}

@media (hover: hover) and (pointer: fine) {
  /* Scoped to the frame, not the whole .project__video figure — a
     captioned instance's .project__caption sits below the frame in that
     same figure, and hovering plain caption text shouldn't blank the
     cursor or summon the play control. */
  .project__video-frame:hover {
    cursor: none;
  }

  /* Paused size — larger than the playing-state circle further down, so
     the swap on click reads as a deliberate shrink rather than the same
     badge just changing icon. */
  .project__video-toggle {
    top: 0;
    left: 0;
    transform: translate(-50%, -50%);
    width: 4.95rem;
    height: 4.95rem;
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.2s var(--ease), width 0.2s var(--ease), height 0.2s var(--ease);
  }

  .project__video-frame:hover .project__video-toggle {
    opacity: 1;
  }

  .project__video-toggle-icon--play {
    width: 2.45rem;
    height: 2.45rem;
  }

  .project__video.is-playing .project__video-toggle-icon--play {
    display: none;
  }

  .project__video.is-playing .project__video-toggle-icon--stop {
    display: block;
    width: 2rem;
    height: 2rem;
  }

  .project__video.is-playing .project__video-toggle {
    width: 2.75rem;
    height: 2.75rem;
  }
}

/* Persistent mute toggle, bottom-right of the frame — only present in the
   markup for videos that actually carry audio (the click-to-play reels;
   the two silent autoplay/loop clips get no button at all). Unlike
   .project__video-toggle, which is about starting/stopping playback and
   only reveals on hover (desktop) or fades out once playing (mobile),
   sound state isn't a "discover it on hover" control — it stays fixed in
   one corner and visible for as long as the video is actually playing, on
   both desktop and mobile alike. Same glass-circle language as the toggle,
   smaller and non-cursor-following since its position is fixed, not tied
   to pointer movement. */
.project__video-mute {
  position: absolute;
  right: 1rem;
  bottom: 1rem;
  width: 2.75rem;
  height: 2.75rem;
  border: none;
  cursor: pointer;
  color: var(--off-white);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  background: rgba(255, 254, 253, 0.2);
  backdrop-filter: blur(1rem);
  -webkit-backdrop-filter: blur(1rem);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.2s var(--ease);
}

.project__video.is-playing .project__video-mute {
  opacity: 1;
  pointer-events: auto;
}

.project__video-mute-icon {
  width: 1.35rem;
  height: 1.35rem;
}

/* Sound-on is the default (no muted attribute on any of these videos) —
   .is-muted (set by projectVideoToggle in script.js, mirroring the
   video's own .muted property) swaps which icon shows. */
.project__video-mute-icon--muted {
  display: none;
}

.project__video.is-muted .project__video-mute-icon--unmuted {
  display: none;
}

.project__video.is-muted .project__video-mute-icon--muted {
  display: block;
}

/* Mute button, shrunk 50% visually on mobile — but the button element
   itself (the actual tap target) stays at its original 2.75rem square, so
   shrinking the VISIBLE circle doesn't also shrink the tappable area
   below the ~44px minimum touch target size ("hit area" / "touch target"
   is the term for this — sometimes called "hit slop"). Placed here (after
   the base rules above) rather than in the earlier
   @media (max-width: 1024px) block near .project__video-toggle — same
   specificity as those base rules, so it has to come later in source
   order to actually win.

   border-radius:50% is deliberately left OFF the button itself here
   (overriding the base rule's circular button) — verified directly that
   Chromium clips a <button>'s actual hit-test region to its own
   border-radius, not just its painted appearance, so a rounded 44px
   button only ever accepted taps within the ~22px inscribed circle at its
   center, no matter how large the square box was. The visible glass
   circle moves to a ::before instead, which carries its own
   border-radius:50% purely for paint — it isn't the hit-tested element,
   so its rounding doesn't reintroduce the same clipping.

   The pseudo is taken out of flex flow (position:absolute, centered via
   top/left/transform against the button — still its positioned ancestor)
   so it doesn't consume any flex-layout space; the icon stays a normal
   flex item and keeps centering itself via the button's own
   align-items/justify-content, same as the base (non-mobile) rules. Both
   ending up centered on the same point is what actually overlaps them —
   an earlier version tried centering the pseudo with a negative
   margin-right instead, which cancelled its layout width but left it
   growing rightward from the icon's left edge rather than centered on
   it, visibly off-center. Icon gets position:relative so it and the
   (position:absolute) pseudo share the same "positioned" stacking
   bucket — ordered by tree order there, so the icon, later in the box
   tree, naturally paints above the pseudo with no z-index needed.

   right/bottom are pulled in from the base rule's 1rem to a small
   negative inset: the button's own 2.75rem box is centered on the
   visible circle, so keeping that box's edges flush with the frame
   corner (as the desktop circle-sized button does) left the small
   circle sitting a good ~1.4rem short of the actual corner. Letting the
   (invisible, background:none) button box hang slightly past the frame
   edge instead moves the circle itself close to the corner without
   shrinking the tap target. */
@media (max-width: 1024px) {
  .project__video-mute {
    right: -0.375rem;
    bottom: -0.375rem;
    border-radius: 0;
    background: none;
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }

  .project__video-mute::before {
    content: "";
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    width: 1.375rem;
    height: 1.375rem;
    border-radius: 50%;
    background: rgba(255, 254, 253, 0.2);
    backdrop-filter: blur(1rem);
    -webkit-backdrop-filter: blur(1rem);
  }

  .project__video-mute-icon {
    position: relative;
    width: 0.675rem;
    height: 0.675rem;
  }
}

/* Minimal scrubber along the bottom edge of a non-autoplay video — only
   present in the markup for the same click-to-play-with-sound reels that
   get a mute button (the two silent autoplay/loop clips have no need to
   be sought). A plain <input type="range"> rather than hand-built
   pointer/drag handling: dragging, clicking the bar, and keyboard seeking
   all come from the browser for free. No thumb — the played/unplayed
   split IS the only indicator, no separate handle graphic riding on top
   of it — and the input's own box is sized and positioned to exactly
   match the visible bar (height:10px, bottom:0), so the bar sits flush
   against the video frame's bottom edge rather than floating in a taller
   invisible hit box. --progress (0–100, written by the "input"/
   "timeupdate" handlers in script.js) drives a background gradient on the
   WebKit/Blink track pseudo to split played from unplayed — Firefox gets
   the same split for free via ::-moz-range-progress, a track-fill pseudo
   it supports natively. Unplayed is fully transparent (no bar at all
   where nothing's been watched yet) rather than a dim line — only the
   played portion actually renders, at a light, deliberately low opacity
   so it stays "very minimal" rather than reading as a solid progress bar. */
.project__video-scrubber {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  width: 100%;
  height: 10px;
  margin: 0;
  padding: 0;
  background: transparent;
  cursor: pointer;
  appearance: none;
  -webkit-appearance: none;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.2s var(--ease);
  --progress: 0%;
  /* See the base button rule's comment on touch-action — an <input> isn't
     covered by that reset, and this scrubber gets tapped/dragged same as
     any other repeat-interaction control. */
  touch-action: manipulation;
}

.project__video-scrubber::-webkit-slider-runnable-track {
  height: 10px;
  background: linear-gradient(
    to right,
    rgba(255, 254, 253, 0.35) var(--progress),
    rgba(255, 254, 253, 0) var(--progress)
  );
}

.project__video-scrubber::-moz-range-track {
  height: 10px;
  background: rgba(255, 254, 253, 0);
}

.project__video-scrubber::-moz-range-progress {
  height: 10px;
  background: rgba(255, 254, 253, 0.35);
}

.project__video-scrubber::-webkit-slider-thumb {
  appearance: none;
  -webkit-appearance: none;
  width: 0;
  height: 0;
  background: transparent;
  border: none;
}

.project__video-scrubber::-moz-range-thumb {
  width: 0;
  height: 0;
  border: none;
  background: transparent;
}

@media (max-width: 1024px) {
  .project__video-scrubber {
    height: 6px;
    opacity: 1;
    pointer-events: auto;
  }

  .project__video-scrubber::-webkit-slider-runnable-track {
    height: 6px;
  }

  .project__video-scrubber::-moz-range-track,
  .project__video-scrubber::-moz-range-progress {
    height: 6px;
  }
}

@media (hover: hover) and (pointer: fine) {
  .project__video-frame:hover .project__video-scrubber {
    opacity: 1;
    pointer-events: auto;
  }
}

/* ==========================================================================
   Export size reference — gallery media (project-template.html)
   --------------------------------------------------------------------------
   Source dimensions to export at for true 2x retina in each pattern below,
   derived from this site's actual gallery content width: 1504px (1600px
   --site-max-width minus its padding-inline — see .container). Each
   pattern's rendered column width comes directly from that number plus the
   24px gap used everywhere in the gallery; export size is just that
   rendered width x2. Rounded up slightly to clean numbers (never short of
   the true 2x minimum, just occasionally a little past it):

   - Full width (1 per row):        3008x1692 minimum -> use 3200x1800 (16:9)
   - Square, 2 or 3 per row:        1480x1480 minimum -> use 1500x1500 (1:1)
     (sized for the larger 2-across case — already covers 3-across too)
   - Two-column layout, left side (2 stacked):   1480x987 min -> 1500x1000 (3:2)
   - Two-column layout, right side (3 stacked):  1480x642 min -> 1500x650 (~2.3:1)
     (this pair is balanced against each other specifically — 2 stacked
     3:2 images and 3 stacked ~2.3:1 images land within 1px of the same
     total column height, so the two sides of project-media-columns read as
     intentionally even rather than one trailing off)
   - Flexible-ratio row (no forced crop): 1500px wide, height = whatever
     your source images' own consistent ratio requires — there's no fixed
     target here, this pattern exists specifically for content that isn't
     square or 3:2/2.3:1.

   Video: full retina master resolution isn't worth chasing once a file is
   already rendered/compressed — 1920x1080 is a fine, normal source for
   video in any of these slots. The softness from upscaling to a 3200px-
   wide slot is much less noticeable on video than on a still image, and
   re-exporting finished, already-compressed video for a marginal, mostly
   imperceptible sharpness gain usually isn't worth the redo cost. Save true
   retina-export effort for stills, or anything genuinely hero/flagship.
   ========================================================================== */

/* <project-media-row cols="2|3" [ratio="square"]> groups 2–3
   .project__media-item children into one row instead of each stacking full
   width — for anything narrower than the full 1920x1080 default (square
   thumbnails, or a set of same-ratio images meant to sit side by side).
   Pure CSS, no JS: an undefined custom tag still parses fine and is fully
   stylable, and since this component has no per-instance content to splice
   in (unlike <project-about>), there's no reason to reach for a
   connectedCallback/render() just to turn on `display: grid`.

   ratio="square" forces a 1:1 crop via aspect-ratio + object-fit — for
   source images that AREN'T already square. Omit `ratio` when the source
   images already share a consistent (non-square) ratio with each other;
   natural width:100%/height:auto (from .project__media-item img/video
   above) is enough to keep them even without cropping anything.

   More fixed ratios (e.g. a portrait set) are a one-line addition here —
   add `project-media-row[ratio="portrait"] … { aspect-ratio: 4/5; }` next
   to the square rule below — no restructuring needed.

   For two independent stacks side by side (item counts and heights don't
   need to match, e.g. 2 images left against 3 shorter ones right), see
   <project-media-columns>/<project-media-column> further down instead —
   different problem from a single aligned row. */
project-media-row {
  display: grid;
  gap: var(--gallery-gap);
}

/* Removes the gap between this row's own items specifically — e.g.
   Toggled's two matched "gro" SVGs (see brand-identity-toggled-
   lighting.html), meant to butt up against each other with no seam.
   Only ever acts as the horizontal gap in practice: a single-row
   project-media-row has no second row for a vertical gap to apply
   between, so this doesn't touch the spacing above/below the row itself
   — that's the parent project-media-column's own separate gap. */
project-media-row.project-media-row--flush {
  gap: 0;
}

/* Pulls the flush row's two items toward the center so they overlap
   instead of just touching, on genuinely wide desktop only — tablet and
   smaller desktop windows stay flush (0 gap, no overlap), same as
   mobile. Negative margin on plain <img> elements with transparent
   backgrounds, so no separate art needed — whichever item is later in
   the DOM paints on top where they overlap.

   1400px (not 769px, this breakpoint's own usual floor) and a smaller
   30px (not an earlier 50px) are both deliberately conservative: the
   author saw real text collision at "small desktop or tablet" widths
   that this session's own testing (Chromium and WebKit, a dense sweep
   from 769px up, the About panel both open and closed) never
   reproduced — something about their actual environment tips it that
   this testing didn't catch. Rather than keep guessing at the exact
   danger zone, this trades some of the effect's range for a wide safety
   margin on both axes: a much higher floor, and less overlap once it
   does apply. */
@media (min-width: 1400px) {
  project-media-row.project-media-row--flush > .project__media-item:first-child {
    margin-right: -30px;
  }

  project-media-row.project-media-row--flush > .project__media-item:last-child {
    margin-left: -30px;
  }
}

project-media-row[cols="2"] {
  grid-template-columns: repeat(2, 1fr);
}

project-media-row[cols="3"] {
  grid-template-columns: repeat(3, 1fr);
}

project-media-row[ratio="square"] .project__media-item img,
project-media-row[ratio="square"] .project__media-item video {
  aspect-ratio: 1 / 1;
  object-fit: cover;
  height: 100%;
}

/* emphasis="center" — widens the middle column of a cols="3" row so its
   square reads larger than its neighbors (e.g. the CAM Summit's social
   grid). align-items:start swaps out the row's default stretch: with
   unequal column widths each square needs to size off its OWN width via
   aspect-ratio rather than a row height shared with the other two — same
   "independent height per item" reasoning project-media-column already
   uses for its own unequal stacks. */
project-media-row[cols="3"][emphasis="center"] {
  grid-template-columns: 1fr 1.5fr 1fr;
  align-items: start;
}

/* fit="height" — the inverse of the default grid columns above: items
   share one row HEIGHT instead of an equal column WIDTH, for a set of
   images exported at the same height but different widths (e.g.
   Blackhat's F1 car/helmet/booth closeups, all 940px tall). Switches to
   flexbox because CSS grid columns can't size themselves off a shared
   cross-axis measurement the way flex-grow can.

   Two numbers have to be set inline per instance, since they depend on
   each image's actual export dimensions: the row's own aspect-ratio
   (combined width : shared height of all items side by side, e.g.
   "3300 / 940" for 1500+1000+800 wide at 940 tall), and each item's own
   flex-grow set to that image's export width — flex-basis:0 turns those
   grow values into the proportional widths that reproduce the images'
   real aspect ratios exactly. object-fit:cover on the image is just a
   sub-pixel rounding safety net, not a real crop, when those numbers are
   accurate. */
project-media-row[fit="height"] {
  display: flex;
}

project-media-row[fit="height"] > .project__media-item {
  flex: 1 1 0;
  min-width: 0;
  overflow: hidden;
}

project-media-row[fit="height"] > .project__media-item img,
project-media-row[fit="height"] > .project__media-item video {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* <project-media-columns> holds two <project-media-column>s side by side,
   each its own independent vertical stack — unlike <project-media-row>,
   the two sides don't need the same item count or matching heights (e.g.
   2 images stacked on the left against 3 shorter ones on the right).
   align-items:start is what actually keeps them independent: without it
   a grid's row children stretch to match each other's height by default,
   which would force the shorter column to grow and leave dead space at
   its bottom rather than just ending wherever its own content ends. */
project-media-columns {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--gallery-gap);
  align-items: start;
}

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

/* split="20-9" — a large single item on the left against a smaller stack
   on the right (e.g. a big video paired with a smaller video + an image
   underneath it), rather than the default even 1fr/1fr split. 20:9 is this
   site's specific balanced ratio for a 16:9 item on each side of the
   split plus a third item filling the remainder — see the export size
   reference notes for the math. More splits are a one-line addition here,
   same pattern as project-media-row's ratio values. */
project-media-columns[split="20-9"] {
  grid-template-columns: 20fr 9fr;
}

/* split="2-3" — a narrower item on the left against a wider one on the
   right, one image per side (not a stack), where the two sides need to
   land at the SAME height rather than "independent, whatever height each
   ends up at" (that's what the default 1fr/1fr split and
   align-items:start above are for). 2:3 matches this pair's actual native
   width ratio at equal native height (1200x1600 and 1800x1600) — giving
   each column that exact width proportion is what makes both images
   render at the same height purely from their own aspect ratio, with no
   cropping or stretching needed to force it. */
project-media-columns[split="2-3"] {
  grid-template-columns: 2fr 3fr;
}

/* split="12-13" — 2 stacked images left against 3 stacked images right
   (like the default 1fr/1fr two-stack pattern), but these two sets don't
   share a common aspect ratio the way split="2-3"'s pair does, so an even
   split doesn't land them at the same total height — the left set (1500x1125,
   4:3) is relatively taller per image than the right set (1500x677,
   ~2.22:1), and the right stack carries one more internal gap (2 vs 1)
   working against it too. 12:13 is the width ratio that offsets both of
   those at this gallery's real column-gap math, landing both stacks
   within a fraction of a pixel of the same height — same derivation
   method as split="2-3", just solved for unequal aspect ratios instead of
   equal ones. */
project-media-columns[split="12-13"] {
  grid-template-columns: 12fr 13fr;
}

/* split="61-50" — one large portrait image left against 2 stacked
   near-square images right. Unlike split="20-9" (also a large-left/
   stack-right shape, but for a pair of 16:9 items), this pair's ratios
   are portrait ~1814x2705 left against 1500x1337 right — solved with the
   same method as split="12-13"/"2-3" for these specific ratios plus the
   one internal gap in the right-hand stack. 61:50 (≈1.22) lands within a
   fraction of a pixel; the nearby clean-looking 11:9 (≈1.222) was ~2px
   off at this gallery's real column width, visible enough to be worth
   the less-round number here. */
project-media-columns[split="61-50"] {
  grid-template-columns: 61fr 50fr;
}

/* split="19-10" — one large near-square image left (Toggled's packaging
   mockup, ~1400x1404) against 3 stacked wide mockups right (~750x466,
   750x375, 750x531) — same large-left/stack-right shape as split="20-9",
   solved the same way as split="61-50"/"12-13" for this pair's own ratios
   and its two internal right-stack gaps. ~1.9:1, approximated at this
   gallery's typical column width rather than measured to the pixel like
   the older splits above — good enough for a stack this uneven to still
   land close. */
project-media-columns[split="19-10"] {
  grid-template-columns: 19fr 10fr;
}

/* split="10-5-9" — the only 3-column split so far: a banner/photo stack,
   a narrow tall diagram, and a renderings/apparel stack (Toggled's "Grow"
   product cluster). project-media-columns already places however many
   <project-media-column> children it's given in a row (nothing about it
   assumes exactly 2), so this is just a wider grid-template-columns, no
   structural change. Widened from an earlier 9-4-11 (weighted toward the
   source composite's own proportions) at the author's request — the
   banner/photo and diagram columns read as cramped against the wider
   renderings/apparel column, so this shifts share toward them instead. */
project-media-columns[split="10-5-9"] {
  grid-template-columns: 10fr 5fr 9fr;
}

@media (max-width: 768px) {
  /* Every row collapses to a single column on mobile, matching the rest of
     the gallery — a 3-across grid has no room to be 3-across on a phone.
     Matches [cols="…"] rather than the bare tag: an attribute selector
     outweighs a type selector regardless of source order, so a same-
     specificity override is what's actually needed to win here, not just
     a later media query. */
  project-media-row[cols="2"],
  project-media-row[cols="3"] {
    grid-template-columns: 1fr;
  }

  /* fit="height" is flex, not grid, so the rule above doesn't touch it —
     stacks to a single column the same way, but by switching the axis
     instead of the template, and dropping the inline aspect-ratio/
     flex-grow values back to each item's own natural size (matching
     .project__media-item img/video's plain width:100%/height:auto
     default) since there's no shared row height to size them against
     once they're stacked. !important on both is load-bearing, not
     decorative: each instance sets its own aspect-ratio/flex-grow
     inline (see the fit="height" comment above), and an inline
     declaration always wins over an external stylesheet rule
     regardless of specificity or cascade order — without !important
     this reset silently no-ops, and while it happens to self-heal once
     every image finishes loading (flex-basis:auto lets content resize
     the row), the images sit squeezed into slivers of the desktop
     aspect-ratio/flex-grow numbers for the whole time they're still
     loading, which reads as stretched/distorted — worst right after a
     reload with a cold image cache. */
  project-media-row[fit="height"] {
    flex-direction: column;
    aspect-ratio: auto !important;
  }

  project-media-row[fit="height"] > .project__media-item {
    flex: initial !important;
  }

  project-media-row[fit="height"] > .project__media-item img,
  project-media-row[fit="height"] > .project__media-item video {
    width: 100%;
    height: auto;
    object-fit: initial;
  }

  /* No [attr] involved for the default split, so the bare-tag desktop rule
     and this one are equal specificity — source order alone wins. The
     split="20-9"/"2-3"/"12-13"/"61-50" variants DO carry an attribute
     selector on the desktop side though, so they need the same
     match-the-specificity treatment as project-media-row[cols] above, or
     this override loses to them here too. */
  project-media-columns,
  project-media-columns[split="20-9"],
  project-media-columns[split="2-3"],
  project-media-columns[split="12-13"],
  project-media-columns[split="61-50"],
  project-media-columns[split="19-10"],
  project-media-columns[split="10-5-9"] {
    grid-template-columns: 1fr;
  }

  /* CAM Summit's "Looking back" retrospective (7th gallery item): on
     desktop the text sits beside the left video, above the looping reel,
     in its own column — fine there since split="20-9" puts the columns
     side by side. Stacked to a single column on mobile (the rule above),
     plain DOM order would put the text between the two videos instead of
     above both. display:contents unwraps each project-media-column's box
     so its children become direct items of this grid instead — order
     only reorders siblings sharing one parent, so this is what lets the
     text (order:-1) jump ahead of the left video, which lived in the
     OTHER column and is otherwise out of order's reach. Both videos keep
     the default order:0, so they stay in their original relative
     sequence after the text. */
  .project__feature-columns--mobile-text-first project-media-column {
    display: contents;
  }

  .project__feature-columns--mobile-text-first .project__feature-text {
    order: -1;
  }

  /* --gallery-gap's vw-based scaling exists to preserve the cross-column
     balance math between 768px and 1600px (see the token comment) — below
     768px everything's already a single column with nothing to balance
     against, so there's no reason for it to keep shrinking. Pinned back to
     the site's flat 24px here, matching every other gap on the page at
     this width instead of drifting down toward the clamp's 12px floor. */
  .project__gallery,
  project-media-row,
  project-media-columns,
  project-media-column {
    gap: 24px;
  }

  /* .project__feature-columns--center's inset padding is also desktop-only
     — flush with the section's edges on mobile, matching every other
     stacked gallery pattern there. flex-direction:column plus resetting
     both columns' flex-basis to full width stacks them the same way
     project-media-columns' own grid-template-columns:1fr does for every
     other split — needed here since this modifier replaced display:grid
     with display:flex above, so that generic grid-based mobile rule has
     nothing left to act on for this element. */
  .project__feature-columns--center {
    flex-direction: column;
  }

  /* This rule's own padding-inline:0 (and the flex-basis:100% override for
     both project-media-columns, a few rules down from here) actually live
     further down the file instead, right after their respective base
     rules — see the comments there for why a same-specificity override
     can't just go here. */

  /* --narrow-lg's own mobile width override lives further down the file,
     right after its base rule — see the comment there for why a
     same-specificity override can't just go here. */

  /* The iQ logo wants more room below it than --narrow-lg's shared 24px
     margin-bottom gives, on mobile specifically — this selector's two
     classes outrank that single-class rule, so 48px is a full
     replacement value here, not additional space stacked on top of the
     24px. Scoped to this one section so the "gro" logo's own mobile
     spacing (set the same way, by its own equally-specific selector) is
     untouched. */
  .project__feature--white .project__media-item--narrow-lg {
    margin-bottom: 48px;
  }
}

/* Sized up for the "gro" and "iQ" SVG lockups, both scaled to match
   each other. width:64% (not max-width alone) is a deliberate fix: this
   figure is a flex item (a direct child of project-media-column, which
   is flex-direction:column), and a flex item with only max-width set
   has no definite width of its own to stretch to — usually invisible
   since a raster image's real intrinsic size fills the gap, but an SVG
   <img> with no width/height attributes on its root (only a viewBox)
   has no intrinsic size at all, just an aspect ratio, so it fell back
   to the browser's generic 300px replaced-element default instead of
   actually filling the column. width:64% gives the figure a definite
   size to stretch to regardless of the source format, and max-width
   still caps it the same as before.

   64% width (down from an original 100% in two passes — 80%, then
   another -20% of that — per the author's own sizing pass) plus
   margin-inline:auto
   centers it with the freed-up space split evenly on both sides rather
   than the image just shrinking flush-left. max-width is still well past
   the gallery's own realistic width at any viewport (so in practice 64%
   of the column is what actually binds), kept as an explicit cap rather
   than dropped, in case a future redesign widens the gallery enough for
   it to matter. margin-bottom gives both logos a baseline gap before
   whatever follows them — this is what "iQ" actually uses on every
   screen size (nothing more specific overrides it there except on
   mobile). "gro" uses a bigger value instead, from
   .project__feature--grow-gradient's own selector below — that rule's
   two classes outrank this one's single class, so it's a full
   replacement of this margin-bottom, not additional space stacked on
   top of it (the *gap* "gro" also gets from its project-media-column
   parent is separate and does stack with whichever margin wins). */
.project__media-item--narrow-lg {
  width: 64%;
  max-width: 1843px;
  margin-inline: auto;
  margin-bottom: 24px;
}

/* The "gro"/"iQ" logos read as too small at 64% once the column itself
   has already shrunk to a phone's width — sized up on mobile
   specifically. Placed here, right after the base rule above (rather
   than inside the site's main mobile block much earlier in the file),
   because this selector and that base rule are equal specificity: with
   nothing to break the tie, plain source order decides regardless of
   which one sits inside a media query, so the mobile override has to
   come after the rule it's meant to beat. */
@media (max-width: 768px) {
  .project__media-item--narrow-lg {
    width: 85%;
  }
}

/* Extra breathing room above/below a single item — e.g. Kodia Lin's logo
   lockup, which reads as cramped sitting flush against .project__gallery's
   own gap like every other full-bleed image. vw-based so it scales with
   viewport width like the rest of the gallery's own padding/gap clamps,
   with a 100px floor so desktop never dips below that regardless of
   viewport. Smaller on mobile (below), where the vw-scaled value would
   otherwise still be pinned to that same 100px floor. */
.project__media-item--padded {
  padding-block: clamp(100px, 8vw, 160px);
}

@media (max-width: 768px) {
  .project__media-item--padded {
    padding-block: clamp(48px, 10vw, 80px);
  }
}

/* Overrides project-media-columns' default grid entirely — for a text
   block and an image that should read as one tight, centered group
   (e.g. Toggled's iQ section) rather than two tracks stretched to fill
   the full row the way every other split does. flex + justify-
   content:center lets both columns size to their own content (a
   readable text measure, a deliberately compact image — see
   .project__media-item--compact below) and sit close together in the
   middle, with the leftover space landing evenly outside the pair as
   margin instead of as a gap stretched between them. Any split="…"
   attribute on the same element is inert here — grid-template-columns
   has nothing to act on once display is no longer grid. align-items and
   the inset padding-inline carry over from the previous grid-based
   version (reset to flush again on mobile below).

   No flex-wrap here deliberately — real mobile stacking is already
   handled explicitly below via flex-direction:column, not by wrapping.
   flex-wrap:wrap used to sit on this rule too, and at tablet widths
   (where flex-direction is still row) the text column's 440px flex-basis
   plus the image's fixed width narrowly exceeded the available row width
   by just a few px in places — the wrapping algorithm decides line
   breaks off each item's flex-basis, before flex-shrink gets a chance to
   compress anything, so the text column wrapped onto its own line
   instead of actually shrinking to fit. That dropped the pair into an
   unstyled, ad hoc version of the stacked layout — no full-width image,
   no deliberate gap — at every tablet width down to the real mobile
   breakpoint, reading as a stray gap between them. Leaving this nowrap
   (the flex default) lets the text column's own flex-shrink:1 actually
   do its job instead, compressing (and wrapping onto more text lines)
   as needed to stay beside the image all the way down to 768px. */
.project__feature-columns--center {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: clamp(1.5rem, 4vw, 3rem);
  padding-inline: clamp(1.5rem, 6vw, 4rem);
}

/* Flush with the section's edges on mobile — same same-specificity/
   source-order reasoning as --narrow-lg's own mobile override further up
   the file: this rule and the one above are both a single class with no
   media query weighing in on specificity, so a mobile override placed
   BEFORE this one (inside the site's main mobile block much earlier in
   the file) silently loses to it. Placing the override here instead,
   after the rule it's meant to beat, is what actually makes it win. */
@media (max-width: 768px) {
  .project__feature-columns--center {
    padding-inline: 0;
  }
}

.project__feature-columns--center project-media-column:first-child {
  flex: 0 1 440px;
}

.project__feature-columns--center project-media-column:last-child {
  flex: 0 0 auto;
}

/* Full width once the row stacks to a single column on mobile — same
   same-specificity/source-order fix as above: this used to sit inside
   the site's main mobile block much earlier in the file, tied with the
   two rules above on specificity, so the later (here) `flex` shorthand
   always won regardless of the media query. Without this, the text
   column kept 440px as an actual flex-basis HEIGHT once flex-direction
   flipped to column on mobile — its box stayed 440px tall even though
   the text itself only filled a fraction of that, reading as a large
   empty gap between the paragraph and the image below it. */
@media (max-width: 768px) {
  .project__feature-columns--center project-media-column:first-child,
  .project__feature-columns--center project-media-column:last-child {
    flex-basis: 100%;
  }
}

/* A deliberately small, fixed-size image sitting beside a text block as
   part of a centered group (see .project__feature-columns--center above)
   rather than an image meant to fill a column — fixed px instead of a
   percentage since there's no longer a wide column track for a
   percentage to be relative to. Reset back to full width on mobile
   above, where there's no "beside" to sit next to once the columns
   stack. */
.project__media-item--compact img,
.project__media-item--compact video {
  width: 340px;
  max-width: 100%;
}

/* Full column width back on mobile, same as every other
   .project__media-item — there's no "beside" a text block to stay
   deliberately smaller than once the columns stack. Same same-
   specificity/source-order reasoning as --narrow-lg's own mobile
   override above applies here too: this used to sit inside the site's
   main mobile block much earlier in the file and silently lost to the
   fixed-width rule above it, since neither selector outranks the other
   and only document order was left to decide. */
@media (max-width: 768px) {
  .project__media-item--compact img,
  .project__media-item--compact video {
    width: 100%;
  }
}

.project__caption {
  margin-top: 0.75rem;
  font-size: 0.9rem;
  color: var(--shark-gray);
}

/* Inside .project__feature's black background, .project__caption's default
   shark-gray is unreadable — lighten to the same dimmed off-white used for
   body-weight text on other dark sections (e.g. .work-page .project-card__tag). */
.project__feature .project__caption {
  color: rgba(255, 254, 253, 0.8);
}

/* Background always bleeds to the true viewport edge on both sides,
   About panel open or closed — including behind the panel itself. That's
   deliberate: the panel (.project__about, above) is translucent, so
   showing through it reads as intentional glass rather than a hard
   overlap. .project__rail sits above this in the stacking order (its own
   explicit z-index, set where the rail is defined) specifically so the
   panel stays the topmost layer wherever the two overlap — without that,
   this section's own stacking context (below) would paint over it instead
   of under it, opacity or not.

   .project__feature itself stays a completely plain block — content
   always just tracks wherever .project__gallery currently is, exactly
   like every other pattern in the gallery; only the background (a
   ::before, not a property on .project__feature directly) reaches
   further out to the true edges. position:relative + z-index:0 here
   gives that pseudo-element a stacking context to sit at -1 within (so it
   paints behind the actual content but no further back than that), and a
   containing block to size against. --feature-bg-left/--feature-bg-width
   are set by projectFeatureBleed (script.js), which measures this
   element's actual position against the true viewport — same "measure
   reality, don't assume centering" reasoning as the old .full-bleed
   attempt this replaced. */
.project__feature {
  position: relative;
  z-index: 0;
  padding-block: clamp(2.5rem, 6vw, 4.5rem);
}

/* Two (or more) full-bleed black sections meant to read as one continuous
   block — wrap them in .project__feature-group rather than leaving them
   as plain siblings in .project__gallery. A plain-sibling negative margin
   trying to cancel --gallery-gap looked right until the About panel
   opened: --gallery-gap's cqw is read once by .project__gallery's own
   `gap` (which, per the container-query spec, can't use itself as the
   query container and so falls back to a viewport-based width) and again
   by the child .project__feature's margin-top (which CAN use
   .project__gallery as its container and tracks its real, narrower,
   panel-open width) — two different resolved lengths from the same
   variable, leaving a sliver uncanceled exactly when the panel was open.
   A group with a real (deterministic, always-0) gap sidesteps that
   mismatch entirely: each .project__feature keeps its own padding-block
   for internal breathing room (the black band between them), and the
   group itself is what .project__gallery's real gap spaces from its
   neighbors. */
.project__feature-group {
  display: flex;
  flex-direction: column;
}

/* The facing edges inside a group — this section's bottom when another
   feature follows it in the group, the next one's top — use a shorter
   padding than .project__feature's own default: full padding-block on
   both sides of the pair reads as too wide a black band once they're
   already sitting flush against each other with no gap between. Halving
   it keeps the same responsive clamp() shape rather than a flat value. */
.project__feature-group .project__feature:not(:last-child) {
  padding-bottom: clamp(1.25rem, 3vw, 2.25rem);
}

.project__feature-group .project__feature:not(:first-child) {
  padding-top: clamp(1.25rem, 3vw, 2.25rem);
}

.project__feature::before {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: var(--feature-bg-left, 0);
  width: var(--feature-bg-width, 100%);
  background: black;
  z-index: -1;
}

/* Modifier for the Toggled "Grow" feature section — swaps the plain black
   ::before background for the animated diagonal gradient the old Toggled
   site used behind its own Grow line (originally a fixed #toggled-gro id
   rule; ported here as a class so any project page can reuse it). Only
   `background`/`background-size`/`animation` are overridden — position,
   sizing, and the --feature-bg-left/--feature-bg-width bleed math above
   stay exactly as .project__feature already sets them. */
.project__feature--grow-gradient::before {
  background: linear-gradient(45deg, #0086cd, #4abda2, #76bc21);
  background-size: 150% 150%;
  animation: project-feature-grow-gradient 10s ease infinite;
}

@keyframes project-feature-grow-gradient {
  0% {
    background-position: 0% 50%;
  }
  50% {
    background-position: 100% 50%;
  }
  100% {
    background-position: 0% 50%;
  }
}

/* A .project__feature whose full-bleed background is an image rather than
   a flat color — e.g. LunaXi's concrete-texture pattern behind its
   centered logo mark. --feature-bg-image is set inline per section
   (same convention as --feature-bg-left/--feature-bg-width above), so
   this stays reusable rather than hardcoding one project's asset here.
   cover + center crops the image's top/bottom edges to fill whatever
   height the section's own padding/content ends up needing. */
.project__feature--pattern::before {
  background-image: var(--feature-bg-image);
  background-size: cover;
  background-position: center;
}

/* Shrinks LunaXi's centered logo mark without shrinking the rust band
   behind it — a transform is purely visual and doesn't affect the
   image's layout box, so .project__feature--pattern's height (padding
   plus this figure's box) stays exactly what it was at the figure's
   full, untransformed size. Scoped to this specific mark (not a general
   .project__media-item--narrow-lg override) so Toggled's gro/iQ logos,
   which share that base class, are untouched.

   Stephanie's wordmark gets its own, larger scale just below rather
   than reusing this one: at LunaXi's 0.3 it read as too small to notice
   against the green band — LunaXi's logo carries a bold icon+wordmark
   lockup that holds visual weight even tiny, while Stephanie's thinner
   script wordmark needs more size to read as present at all. */
.project__feature--pattern-mark .project__media-item--narrow-lg img {
  transform: scale(0.3);
}

.project__feature--pattern-wordmark .project__media-item--narrow-lg img {
  transform: scale(0.6);
}

/* Keeps the "gro" section's 3-across photo row 3-up on mobile too,
   instead of the site's usual full-width single-column stack — these
   photos read fine small, and stacking them full-width just meant a lot
   more scrolling for no real benefit. project-media-row[cols="3"] and
   this class selector are equal specificity (an attribute selector and
   a class selector are the same specificity tier), so this has to be
   declared after the site's own [cols="3"] mobile reset (in the site's
   main mobile block, much earlier in the file) to actually win — same
   same-specificity/source-order reasoning as the other mobile overrides
   nearby. Only this one row carries the class, so every other
   project-media-row[cols="3"] on the site keeps the normal single-
   column mobile behavior. */
@media (max-width: 768px) {
  project-media-row.project-media-row--mobile-3up {
    grid-template-columns: repeat(3, 1fr);
  }
}

/* The "gro" logo specifically (not "iQ") wants extra room below it before
   the 3-across photo row starts. This selector's two classes outrank
   --narrow-lg's own single-class margin-bottom above, so it's a full
   replacement value here, not a number added on top of that one — 30px
   (rather than --narrow-lg's shared 24px) is this section's own total.
   Combined with the extra structural gap its project-media-column parent
   already puts between every child (24px, separate from margin), gro's
   actual on-screen space below the logo is 30+24=54px. Scoped to this
   one section/class pairing so it doesn't touch the iQ logo's own
   spacing. */
.project__feature--grow-gradient .project__media-item--narrow-lg {
  margin-bottom: 30px;
}

/* More room below the "gro" logo specifically on mobile — same selector
   as the 30px rule above, so equal specificity; declaring it after that
   rule (inside its own @media block, right here rather than in the
   site's main mobile block much earlier in the file) is what makes it
   win, not extra specificity — a same-specificity override declared
   earlier in the file loses regardless of being inside a media query.
   60px is this section's own mobile total, replacing the 30px rather
   than stacking on top of it. Combined with the project-media-column
   gap (24px, pinned flat on mobile — see the site's main mobile block),
   gro's actual mobile space below the logo is 60+24=84px. */
@media (max-width: 768px) {
  .project__feature--grow-gradient .project__media-item--narrow-lg {
    margin-bottom: 60px;
  }
}

/* More room below the "gro" logo specifically on desktop/tablet too —
   same equal-specificity/source-order reasoning as the mobile override
   just above (and every other mobile override nearby): declared after
   the 30px rule near the top of this comment chain is what makes this
   win, not extra specificity. 54px replaces that 30px here rather than
   adding to it. Combined with the project-media-column gap (its own
   responsive clamp, not pinned flat above 768px the way mobile's is),
   gro's actual desktop/tablet space below the logo is 54px + that gap. */
@media (min-width: 769px) {
  .project__feature--grow-gradient .project__media-item--narrow-lg {
    margin-bottom: 54px;
  }
}

/* Both logo-led feature sections (Toggled's "gro" and "iQ" lockups) want
   less air above the logo specifically than .project__feature's shared
   padding-block gives every other section — padding-bottom is left
   alone, this only narrows the top edge, scoped to these two rather than
   .project__feature generally so every other section's spacing (CAM's
   "Looking back", the storyboard split, etc.) stays untouched. calc()
   over the clamp() keeps the same responsive floor/ceiling shape, just
   scaled down (0.85, not the full 1 — still a bit tighter than every
   other section, just not as tight as an earlier 0.7 pass landed; bumped
   up per the author's own "a little more room above the logo" pass). */
.project__feature--grow-gradient,
.project__feature--white {
  padding-top: calc(clamp(2.5rem, 6vw, 4.5rem) * 0.85);
}

/* More room above the "gro" logo specifically on mobile. The shared rule
   above is a comma-separated list of two single-class selectors, each
   evaluated on its own — so .project__feature--grow-gradient here is
   equal specificity to its half of that list, same as the other mobile
   overrides nearby, and wins the same way: by being declared after it,
   not by outranking it. Scoped to "gro" only, not .project__feature--
   white, so iQ's own mobile top spacing (still just the shared 0.85
   value) stays untouched. */
@media (max-width: 768px) {
  .project__feature--grow-gradient {
    padding-top: 64px;
  }
}

/* More room above the "gro" logo specifically on desktop/tablet too —
   same reasoning as the mobile override just above: equal specificity
   to its half of the shared comma-separated rule, winning by being
   declared after it rather than by outranking it. Adds a flat 24px on
   top of the shared 0.85 formula (rather than a flat replacement value,
   the way the mobile override above is) so the responsive clamp()
   shape underneath is preserved — this still shrinks toward the
   clamp's own floor on a narrower desktop/tablet window, just always
   24px above wherever iQ's own (unmodified) padding-top lands. Scoped
   to "gro" only, so iQ's desktop/tablet top spacing is untouched. */
@media (min-width: 769px) {
  .project__feature--grow-gradient {
    padding-top: calc(clamp(2.5rem, 6vw, 4.5rem) * 0.85 + 24px);
  }
}

/* A .project__feature section on a white background instead of black —
   e.g. Toggled's iQ app section, which still wants the feature section's
   generous padding-block breathing room as a break between gallery items,
   just not the black bleed. .project__feature-heading/-body default to
   off-white/translucent-white text for the black case, so both get
   repointed to the site's normal ink colors here too. */
.project__feature--white::before {
  background: var(--off-white);
}

.project__feature--white .project__feature-heading {
  color: var(--black-cat);
}

.project__feature--white .project__feature-body {
  color: var(--shark-gray);
}

/* Video border — a shark-gray hairline separates video from the black
   background inside .project__feature specifically (elsewhere in the
   gallery, on white, media needs no edge to read distinctly). Targets
   <video> directly, so a real project page needs no class for this.
   The template has no actual video source to point a <video> tag at,
   so its two 16:9 stand-ins carry .project__media-item--video-frame to
   demonstrate the same border — delete that class once real video
   replaces the placeholder images. */
.project__feature video,
.project__feature .project__media-item--video-frame img {
  border: 1px solid var(--shark-gray);
}

/* A .project__feature section whose single image is unusually tall (e.g.
   Kodia Lin's brand style guide spread) — needs real room on the sides
   instead of running edge-to-edge with the gallery, plus a bit more
   padding-block than .project__feature's own default so the extra height
   doesn't crowd the black band above/below it. padding-inline is generous
   on purpose (desktop/tablet only — the mobile override below keeps its
   own, tighter values) since these spreads read as cramped at anything
   close to the gallery's usual full-bleed width. */
.project__feature--inset {
  padding-block: clamp(3rem, 7vw, 5.5rem);
  padding-inline: clamp(2.5rem, 14vw, 10rem);
}

@media (max-width: 768px) {
  .project__feature--inset {
    padding-inline: clamp(1.25rem, 6vw, 3rem);
  }
}

/* Kodia Lin's brand orange instead of .project__feature's default black
   bleed — the fallback here, since Kodia Lin's own markup doesn't set
   --feature-inset-bg. Another project reusing --inset for its own tall
   spread (e.g. LunaXi) sets --feature-inset-bg inline on the section to
   swap in its own brand color instead. */
.project__feature--inset::before {
  background: var(--feature-inset-bg, #ee5234);
}

/* Same hairline, opt-in for a video sitting on white outside
   .project__feature — most white-background video reads fine without an
   edge, but a project can ask for one on a specific clip via this
   modifier on the .project__video figure. Lightened well past a subtle
   tint (white mixed into --shark-gray) rather than the full-strength
   color above — this clip's own footage already reads dark/heavy enough
   that anything close to full-strength shark-gray was too strong a line
   against it. */
.project__video--bordered .project__video-el {
  border: 1px solid color-mix(in srgb, var(--shark-gray) 50%, white 50%);
}

/* A short write-up sitting directly in a .project__feature column next to
   media (e.g. the CAM Summit's "Looking back" retrospective) — sized like
   .project__quote's heading (h3/h4, above body copy but below the page's
   largest headline) rather than a plain h3. Explicit 400 rather than
   leaning on h1-h4's own site-wide 400 default, so this can't silently
   drift if that default ever changes. */
.project__feature-heading {
  font-family: var(--font-heading);
  font-weight: 400;
  font-size: clamp(1.125rem, 1rem + 0.5vw, 1.375rem);
  color: var(--off-white);
  margin-bottom: 0.75rem;
}

.project__feature-body {
  font-size: 1.0625rem;
  line-height: 1.6;
  color: rgba(255, 254, 253, 0.8);
}

/* A direct child of .project__feature (rather than nested inside a
   project-media-column's .project__feature-text, like "Looking back"'s
   own heading+body pair) is a standalone section title sitting above a
   full media split — e.g. Toggled's "Video Art Direction & Storyboarding".
   0.75rem is enough breathing room between a heading and the body copy
   directly under it, but reads cramped above a whole row of media, so
   this context gets the gallery's own section-break spacing instead. */
.project__feature > .project__feature-heading {
  margin-bottom: clamp(1.5rem, 4vw, 2.5rem);
}

/* A .project__media-item sitting directly in .project__feature right
   after a project-media-columns split (e.g. Toggled's second storyboard
   image, clearing both the video and the storyboard grid above it to run
   full width) needs the same gap the columns' own internal rows already
   get — nothing about plain block flow supplies that on its own. */
.project__feature > project-media-columns + .project__media-item {
  margin-top: var(--gallery-gap);
}

/* Callout quote — sits directly in .project__gallery's flow (not wrapped
   in a .project__feature or any component), right-aligned with the image
   content's right edge rather than centered, so it reads as part of the
   same column the media sits in. 50ch is what actually targets the ~50
   characters-per-line goal; clamp(1.375rem, …, 1.75rem) keeps it above
   body copy (1rem) but below the largest headline (2.5rem at most) at
   every viewport width. No border accent (unlike .legal-summary's
   callout box) — the right alignment itself is what reads as
   deliberately set apart here, a rule would just compete with it. */
.project__quote {
  /* 38ch, not 50ch: measured the actual rendered line length at 50ch (this
     is set in --font-heading/Sora, whose glyphs run wider than the `ch`
     unit's "0"-width assumption) and it came out to ~66 real characters
     per line, not 50 — 38ch is what actually lands at ~50 in practice for
     this font at this size. */
  max-width: 38ch;
  margin: 0 0 0 auto;
  padding: clamp(1.5rem, 4vw, 2.5rem) 0;
  text-align: right;
  font-family: var(--font-heading);
  font-size: clamp(1.375rem, 1.1rem + 1vw, 1.75rem);
  line-height: 1.45;
  color: var(--black-cat);
}

/* Attribution line — <footer> inside the <blockquote>, same right
   alignment as the quote itself so it reads as one unit. font-size/weight
   match .partner-quote__attribution (the work page's own quote
   attributions) exactly, per the brief — that class's own comment already
   pointed back at this one before this rule had actually caught up to it. */
.project__quote-cite {
  margin-top: 0.75rem;
  font-family: var(--font-body, inherit);
  font-size: 1.0625rem;
  font-weight: 600;
  font-style: normal;
  color: var(--shark-gray);
}

/* Callout — a short section header sitting directly in .project__gallery's
   flow (not wrapped in a .project__feature), for a white/light background
   the way .project__feature-heading/.project__feature-body are for a black
   one. Introduced on the Toggled page (e.g. "Brand System" ahead of the
   rebrand images, "Digital + Web" ahead of the site redesign shot) —
   reusable for any project page that needs a labeled break between gallery
   sections. Heading/body font sizing matches .project__feature-heading/
   .project__feature-body exactly, just recolored for light backgrounds;
   the border-top (instead of that pair's dark-section framing) is what
   reads as a section break here. Body paragraph is optional — a heading
   with no .project__callout-body (e.g. a plain "Packaging + Print" divider)
   is just as valid. */
.project__callout {
  padding-top: clamp(1.5rem, 4vw, 2.5rem);
  border-top: 1px solid var(--black-cat);
}

.project__callout-heading {
  font-family: var(--font-heading);
  font-weight: 400;
  font-size: clamp(1.125rem, 1rem + 0.5vw, 1.375rem);
  color: var(--black-cat);
  margin-bottom: 0.75rem;
}

.project__callout-heading:last-child {
  margin-bottom: 0;
}

.project__callout-body {
  font-size: 1.0625rem;
  line-height: 1.6;
  color: var(--shark-gray);
}

/* "More to See" — a plain sibling of .project (see project-template.html),
   not nested inside it, specifically so it sits completely outside
   .project__row's flex/sticky context: .project__rail can only stay stuck
   while .project__row is still in view, so a section placed after that
   container closes is guaranteed to clear the About panel on scroll-past
   regardless of whether it's open or closed — no extra clearing rule
   needed here for that. Its actual rules (.more-to-see, .more-to-see__nav*)
   live up near the top of the stylesheet, not here — they need to come
   before this file's max-width:1024px block used to, back when there was
   a separate desktop grid layout to override; kept there since nothing
   about that ordering has changed. */

@media (max-width: 768px) {
  .project__row {
    flex-direction: column;
  }

  /* Stacked instead of side-by-side, so there's no width to push — the
     zero-width collapse trick and the sticky pin are desktop-only
     machinery. */
  .project__rail,
  .project__rail.is-open {
    flex: 0 0 auto;
    min-width: 0;
    width: 100%;
    margin-right: 0;
    position: static;
    /* Reset to auto: .project__rail's desktop z-index:10 (set to beat
       .project__feature's own stacking context while scrolling past it)
       has a side effect here — as a flex item, any non-auto z-index
       makes it establish a stacking context of its own, which traps
       .project__about's z-index:100 inside it instead of letting it
       compete at the page root. That left the fixed-position backdrop
       (z-index:90, genuinely root-level) painting on top of the white
       sheet instead of under it. Auto here removes that trap — mobile's
       rail is never sticky, so it never needs to out-rank .project__feature
       in the first place. */
    z-index: auto;
  }

  /* App-like bottom sheet: slides up from the bottom as a fixed overlay
     with a dimmed backdrop, rather than pushing the gallery down like the
     desktop rail does — there's no room beside anything to push into once
     the layout is a single stacked column.

     `top` here is a fallback only — the button stays sticky rather than
     being forced into a fixed spot (that used to make it visibly snap on
     open), so its actual position varies with scroll/content and can't be
     known in CSS alone. The real value is set inline by JS at open time,
     measured against wherever the button actually is — see
     projectAboutToggle in script.js. */
  .project__about {
    position: fixed;
    top: calc(var(--header-height) + 1rem + 4.5rem);
    /* Overrides the desktop rule's max-height (tuned against that
       layout's own top offset, via 100vh), which otherwise survives
       into mobile untouched since nothing here reset it. On a short
       enough window, that leftover cap came out smaller than the actual
       top-to-bottom span below, capping the sheet's height short of the
       screen's true bottom and leaving a gap — bottom:0 only pins where
       the box WOULD end if nothing else constrained its height first.
       none here lets top+bottom (both set) drive the height directly,
       same as the mobile design intends. */
    max-height: none;
    inset-inline: 0;
    bottom: 0;
    margin: 0;
    background: var(--off-white);
    border-radius: 1rem 1rem 0 0;
    box-shadow: 0 -0.5rem 2rem rgba(5, 0, 20, 0.18);
    /* 4rem base bottom padding — deliberately generous breathing room
       below the last field, since this box scrolls internally and the
       last line would otherwise sit flush against the screen's bottom
       edge. env(safe-area-inset-bottom) stacks on top of that (rather
       than being the only source of space) so the home indicator strip
       on notched iPhones adds further clearance instead of eating into
       it. Needs viewport-fit=cover on the page's own <meta
       name="viewport"> for the env() value to be non-zero at all. */
    padding: 1.5rem var(--site-padding) calc(4rem + env(safe-area-inset-bottom, 0px));
    z-index: 100;
    overflow-y: auto;
  }

  .project__about-backdrop {
    display: block;
    position: fixed;
    inset: 0;
    background: rgba(5, 0, 20, 0.4);
    z-index: 90;
  }

  .project__about-backdrop[hidden] {
    display: none;
  }

  /* Above both the backdrop and the sheet itself (still just sticky, not
     repositioned) so the button — now showing × — stays fully visible and
     tappable as an obvious way to close, instead of getting dimmed
     underneath the backdrop like the rest of the page behind it. Tapping
     the backdrop still closes it too. */
  .project__toggle-row {
    z-index: 110;
  }

  .project__toggle-row.is-open {
    position: fixed;
    top: calc(var(--header-height) + 1rem);
    inset-inline: 0;
    margin-bottom: 0;
  }

  /* Below .project__quote's own clamp() floor (1.375rem) — that floor is
     tuned for tablet/desktop widths where the right-aligned column still
     has room to breathe, but on phone widths the same size reads too
     large against the narrower 38ch measure. */
  .project__quote {
    font-size: 1.1rem;
  }

  .project__quote-cite {
    font-size: 0.875rem;
  }
}

/* ==========================================================================
   Digital contact card (card.html)
   --------------------------------------------------------------------------
   Mobile-first on purpose — the only entry point is a QR code scanned on a
   phone, so unlike every other page this one is designed at phone width
   first and just kept from looking stretched on a desktop browser, rather
   than the reverse. .vcard__body's own max-width is what keeps the name/
   title/button readable as one narrow column instead of sprawling edge to
   edge on a wide viewport.
   ========================================================================== */

.vcard {
  padding-bottom: clamp(3rem, 8vw, 5rem);
}

/* .full-bleed (utility class, in the markup) handles the 100vw escape from
   .container; this just sizes/crops the image itself. clamp rather than a
   vh-based height (contrast .hero) — a contact card has no reason to fill
   the viewport, just to read as a confident portrait at any width. */
.vcard__photo {
  height: clamp(20rem, 65vw, 32rem);
  overflow: hidden;
}

.vcard__photo img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center 20%;
}

.vcard__body {
  max-width: 28rem;
  margin-inline: auto;
  padding-top: clamp(2rem, 6vw, 3rem);
  text-align: center;
}

.vcard__name {
  font-family: var(--font-heading);
  font-size: clamp(2.5rem, 8vw, 3.5rem);
  font-weight: 700;
  line-height: 1.05;
  margin-bottom: 0.5rem;
}

.vcard__title {
  font-size: clamp(1.125rem, 3vw, 1.375rem);
  color: var(--shark-gray);
  margin-bottom: clamp(2rem, 6vw, 2.5rem);
}

/* Inverse of .btn--on-light's own resting/hover states: filled by default
   rather than on hover, so the button reads as a button at a glance on a
   phone screen. The outline (.btn--on-light's normal resting look) shows
   on :active instead — a phone has no :hover, so that base class would
   otherwise never show its own transparent/filled distinction at all on
   mobile; here tapping is what reveals it, giving real press feedback.
   Overrides .btn--on-light's slide-in ::before entirely (background/color
   transition directly, same technique as .btn--pale-green above) since
   that fill is keyed to :hover, not :active. */
.vcard__save {
  width: 100%;
  max-width: 20rem;
  background: var(--periwinkle);
  color: var(--white);
  border-color: var(--periwinkle);
  transition: background-color 0.35s var(--ease), color 0.35s var(--ease),
    border-color 0.35s var(--ease);
}

.vcard__save::before {
  content: none;
}

.vcard__save:hover,
.vcard__save:focus-visible,
.vcard__save:active {
  background: transparent;
  color: var(--black-cat);
  border-color: var(--black-cat);
}

/* ==========================================================================
   Contact
   --------------------------------------------------------------------------
   Two real sections, not a gradient trick: .contact__intro and
   .contact__panel each paint their own full-height, full-bleed color via
   their own ::before (see below) rather than .contact carrying a single
   split background behind both — a gradient's split point is just a
   viewport-relative percentage with no way to know where the grid
   columns it's supposed to be dividing actually land, which is exactly
   why an earlier version of this needed increasingly generous fudge-
   factor tuning to keep .contact__pattern's backing color from showing
   the wrong side of the split. Tying each section's own bleed to the
   same container math .contact__inner is laid out with (below) removes
   the guesswork entirely.

   .contact__inner stays a .container (max-width + centered) purely for
   *content* alignment (heading/form inset at the site's normal width);
   .contact__intro/.contact__panel stretching to fill its full height
   (align-items: stretch, here and on .contact__inner) is what lets each
   one act as a real full-height column for its own ::before to bleed
   from — no more measuring .contact's real height in JS and handing a
   top/height back in, the way .contact__pattern used to need.
   ========================================================================== */

.contact {
  border-top: 1px solid var(--black-cat);
  min-height: calc(100vh - var(--header-height));
  display: flex;
  align-items: stretch;
}

.contact__inner {
  display: grid;
  grid-template-columns: 1fr 1fr;
  align-items: stretch;
  width: 100%;
}

/* No grid gap between the columns — .contact__pattern (below) sits
   flush against .contact__intro's own right edge, and .contact__panel
   supplies its own left breathing room via padding-inline-start instead
   (see that rule), so the wave divider isn't fighting a gap for space on
   one side and gap-plus-padding on the other. */
.contact__intro,
.contact__panel {
  min-width: 0;
  position: relative;
  /* flex-direction: column + justify-content (not align-items) for the
     centering — align-items would act on the cross axis, which in a
     default row-direction flex container is horizontal: it would shrink
     the heading/form to their own content width and center *that*,
     losing the left-aligned, full-width text wrapping block layout gave
     them. justify-content in column mode centers on the main (vertical)
     axis instead, while the child still stretches to this element's
     full width by cross-axis default. */
  display: flex;
  flex-direction: column;
  justify-content: center;
  /* Same top/bottom padding on both columns (rather than a single
     padding-block on .contact, as an earlier version had it) is what
     keeps the heading and the form vertically aligned with each other
     now that each column centers its own content independently within
     its own (equal-height, stretched) box. */
  padding-block: calc(var(--header-height) + clamp(2rem, 6vw, 4.5rem)) clamp(3rem, 6vw, 6rem);
}

/* Each column's own color, bled past its own box to the true viewport
   edge — left for intro, right for panel — using the same "gutter width
   once the window exceeds --site-max-width, plus the container's own
   padding" formula .contact__pattern (below) and the old single-gradient
   .contact background both relied on already. The columns' *shared*
   inner edge is left exactly where the grid puts it (no bleed there),
   which is what makes this a hard, exact split rather than an
   approximation. z-index: -1 keeps this under both columns' real
   content and under .contact__pattern. Turned off at the mobile
   breakpoint below, where .contact__intro/.contact__panel paint their
   own background directly instead (they're full-bleed stacked blocks
   there, not side-by-side columns, so there's no shared edge to split
   at). */
.contact__intro::before,
.contact__panel::before {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  z-index: -1;
}

.contact__intro::before {
  left: calc(-1 * (max(0px, (100vw - var(--site-max-width)) / 2) + var(--site-padding)));
  right: 0;
  background: var(--periwinkle-bright);
}

.contact__panel::before {
  left: 0;
  right: calc(-1 * (max(0px, (100vw - var(--site-max-width)) / 2) + var(--site-padding)));
  background: var(--off-white);
}

/* Breathing room between .contact__pattern (flush against intro's right
   edge) and the form fields, now that there's no grid gap supplying it. */
.contact__panel {
  padding-inline-start: clamp(2rem, 5vw, 4.5rem);
}

/* The wave divider: right-aligned flush with .contact__intro's own right
   edge (right: 0 — intro is this element's positioned ancestor) rather
   than bleeding off the viewport's left edge the way an earlier version
   of this did, and sitting entirely within intro's own (periwinkle)
   column rather than in a gap between columns, so it never needs its
   own backing color to cover for whatever's on the other side of a
   split.

   A background-image, not an <img src>, and specifically so: object-fit
   on an <img> can only ever crop to a box's *given* aspect ratio, and
   this box (a narrow, fixed width against a full-section height) is
   nothing like the source SVG's own ~1:5.6 ratio — cover was forced to
   scale by height to fill the box at all, which meant scaling past the
   box's width too and cropping the excess off one side, cutting into
   the wave shapes themselves. background-size: 100% auto instead fixes
   the rendered width to exactly this element's own width (so nothing is
   ever cropped horizontally) and lets height fall out of that at the
   image's true aspect ratio; background-repeat: repeat-y then tiles that
   downward to cover any section height at all, cropping only whatever
   partial repeat is left over at the bottom — height, not width, is the
   dimension this pattern is actually meant to give up.

   Unmodified at the mobile breakpoint below: .contact__intro is already
   its own full-bleed periwinkle block there too (see that breakpoint),
   so "flush against intro's right edge, full height" lands in the same
   place — the true right edge of the periwinkle section — without
   needing a mobile-specific override. */
.contact__pattern {
  position: absolute;
  top: 0;
  bottom: 0;
  right: 0;
  width: var(--contact-pattern-width);
  background-image: url("images/elements/waves.svg");
  background-repeat: repeat-y;
  background-position: top right;
  background-size: 100% auto;
  pointer-events: none;
}

/* Gap between .contact__heading and .contact__tagline specifically
   (rather than on the shared .contact__intro/.contact__panel rule
   above) — .contact__panel never has more than one visible in-flow
   child at a time (the form/success swap), so a gap there would be
   inert anyway, but scoping it to intro keeps that obvious.

   --contact-pattern-width is defined here (rather than inline on
   .contact__pattern itself, where it's actually used) so
   padding-inline-end below can size off the exact same value: intro is
   .contact__pattern's ancestor, so the custom property inherits down to
   it. Keeping both in one place means the heading/tagline's clear space
   can't quietly drift out of sync with how wide the pattern actually
   is. */
.contact__intro {
  /* 40% wider than the previous clamp(8rem, 11vw, 14rem) — each bound
     scaled by 1.4. */
  --contact-pattern-width: clamp(11.2rem, 15.4vw, 19.6rem);
  gap: clamp(1rem, 2vw, 1.5rem);
  /* Reserves the pattern's own width plus a breathing gap so the
     heading/tagline's own text-wrap width never runs into it, at any
     viewport size — they wrap inside this narrower box rather than
     wrapping wide and then visually overlapping the pattern. */
  padding-inline-end: calc(var(--contact-pattern-width) + 1.5rem);
}

/* Solid white fill, not stroked/outlined. 800 renders as Sora's own real
   800 weight, not a synthesized fake-bold — see the @font-face at the
   top of this file, a variable font covering the full 100–800 range. */
.contact__heading {
  font-family: var(--font-heading);
  font-size: clamp(4rem, 9vw, 8rem);
  font-weight: 800;
  line-height: 0.95;
  color: var(--off-white);
}

/* Matches the screenshot: the site's body font (not the heading's) at a
   regular weight, in --black-cat rather than white — the one piece of
   dark text against .contact__intro's periwinkle background. */
.contact__tagline {
  font-family: var(--font-body);
  font-size: clamp(1.5rem, 3vw, 2.25rem);
  font-weight: 400;
  color: var(--black-cat);
  /* Tighter than the sitewide 1.5 body line-height this would otherwise
     inherit — at this font-size, wrapped to two lines via
     .contact__tagline-break below, 1.5 reads as loosely stacked rather
     than a cohesive two-line block. */
  line-height: 1;
}

.contact__form {
  display: flex;
  flex-direction: column;
  gap: 1.5rem;
}

/* Same gotcha as .project__about-backdrop elsewhere in this file: an
   author rule setting display (the one above) beats the browser's own
   default [hidden]{display:none} regardless of specificity, since author
   styles outrank UA styles in the cascade — without this, script.js
   setting form.hidden = true on a successful submit wouldn't actually
   hide it. */
.contact__form[hidden] {
  display: none;
}

.contact__field {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.contact__field label {
  font-size: 0.8125rem;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--black-cat);
}

/* text-transform/letter-spacing/font-weight reset to plain text — without
   this it'd inherit the label's own uppercase treatment and read as a
   second, equally-loud all-caps word butting up against "NAME" instead of
   a quiet secondary note. rgba (not a lighter color token) so it's a true
   50% tint of shark-gray specifically against this field's own
   off-white-page backdrop, not a separately-chosen lighter gray. */
.contact__required {
  font-size: 0.7rem;
  font-weight: 400;
  letter-spacing: normal;
  text-transform: none;
  color: rgba(79, 78, 86, 0.7);
}

/* Fields sit on the off-white right half of .contact's split background —
   black-cat is what actually reads against that. No fill at any state,
   inactive or active: just a 1px outline, black-cat at rest and
   periwinkle on focus — the color swap alone is what marks the active
   field, rather than a background change. */
.contact__field input,
.contact__field textarea {
  border: 1px solid var(--black-cat);
  border-radius: 4px;
  background: transparent;
  padding: 0.85rem 1rem;
  font-family: var(--font-body);
  font-size: 1rem;
  color: var(--black-cat);
  box-shadow: 0 0 0 0 transparent;
  transition: background-color 0.25s ease-in-out, border-color 0.25s ease-in-out, box-shadow 0.25s ease-in-out,
    color 0.25s ease-in-out;
}

/* box-shadow, not border-width:2px — border participates in the box's own
   layout size, and these fields have no explicit height (it's set purely
   by padding+border+line-height), so thickening the real border pushed
   everything below the field down by the difference on focus. A
   1px-spread shadow reads as the same "2px periwinkle edge" but paints
   outside the box model entirely, so nothing else on the page moves. */
.contact__field input:focus,
.contact__field textarea:focus {
  background: transparent;
  border-color: var(--periwinkle);
  box-shadow: 0 0 0 1px var(--periwinkle);
  color: var(--black-cat);
  outline: none;
  transition: background-color 0.15s ease-out, border-color 0.15s ease-out, box-shadow 0.15s ease-out,
    color 0.15s ease-out;
}

.contact__field textarea {
  resize: vertical;
  min-height: 6rem;
}

/* Same fast-in/slow-out pattern as the site's text links (see
   .nav__links a's own comment for the full reasoning): the hover rule
   below carries its own fast transition, this base rule's transition is
   what actually plays when the mouse leaves, since that's the direction
   with no :hover rule of its own to override it. */
.contact__submit {
  align-self: flex-start;
  margin-top: 0.5rem;
  padding: 0.85rem 2rem;
  border: 1px solid var(--black-cat);
  border-radius: 4px;
  background: transparent;
  color: var(--black-cat);
  font-family: var(--font-body);
  font-size: 1rem;
  font-weight: 600;
  cursor: pointer;
  transition: background-color 0.25s ease-in-out, color 0.25s ease-in-out;
}

.contact__submit:hover,
.contact__submit:focus-visible {
  background: var(--black-cat);
  color: var(--off-white);
  transition: background-color 0.15s ease-out, color 0.15s ease-out;
}

.contact__submit:disabled {
  opacity: 0.6;
  cursor: default;
}

/* Off-screen, not display:none/visibility:hidden — a naive scraper bot
   often skips filling fields that are invisible by those two properties
   specifically (since that's the well-known honeypot tell), but still
   fills ones that are just positioned off-canvas. tabindex="-1" keeps a
   real keyboard user from ever tabbing into it regardless. */
.contact__honeypot {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

.contact__error {
  margin-top: 1rem;
  font-size: 0.9rem;
  color: var(--error-red);
}

.contact__error a {
  color: inherit;
  text-decoration: underline;
}

.contact__success-icon {
  display: block;
  margin-bottom: 1.5rem;
}

/* pathLength="1" on the circle/path in contact.html normalizes both
   shapes to a length of 1 regardless of their actual geometry, so the
   dash-draw trick below (dasharray covering the whole path, dashoffset
   animated from 1 to 0) works without hand-calculating a circumference. */
.contact__success-icon-circle,
.contact__success-icon-check {
  stroke-dasharray: 1;
  stroke-dashoffset: 1;
  animation: contact-success-draw 0.5s var(--ease) forwards;
}

.contact__success-icon-check {
  animation-duration: 0.35s;
  animation-delay: 0.45s;
}

@keyframes contact-success-draw {
  to {
    stroke-dashoffset: 0;
  }
}

.contact__success-heading,
.contact__success-text {
  opacity: 0;
  transform: translateY(0.5rem);
  animation: contact-success-fade-up 0.5s var(--ease) 0.7s forwards;
}

.contact__success-text {
  animation-delay: 0.8s;
}

@keyframes contact-success-fade-up {
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

@media (prefers-reduced-motion: reduce) {
  .contact__success-icon-circle,
  .contact__success-icon-check,
  .contact__success-heading,
  .contact__success-text {
    animation: none;
    stroke-dashoffset: 0;
    opacity: 1;
    transform: none;
  }
}

/* balance picks line breaks that even out each line's length instead of
   the default greedy wrap, which was leaving "me" stranded alone on a
   third line at several column widths. */
.contact__success-heading {
  font-family: var(--font-heading);
  font-size: clamp(2.25rem, 1.6rem + 3vw, 3.5rem);
  font-weight: 700;
  color: var(--black-cat);
  margin-bottom: 1rem;
  text-wrap: balance;
}

.contact__success-text {
  font-size: 1.125rem;
  color: var(--shark-gray);
}

@media (max-width: 1024px) {
  /* Below the two-column breakpoint, .contact__intro and .contact__panel
     stack instead of sitting side by side — so the left/right split
     that works on desktop would leave the (periwinkle) intro block
     partly sitting on off-white and the (off-white) panel partly
     sitting on periwinkle, cutting the split at an arbitrary point
     that has nothing to do with where the content actually breaks. The
     fix: give up on .contact's own background entirely here and let
     each block carry its own full-bleed color instead, so the split
     always lands exactly at the intro/panel boundary regardless of
     how tall either one is. */
  .contact {
    background: none;
    border-top: none;
    padding-block: 0;
    min-height: 0;
    display: block;
  }

  .contact__inner {
    grid-template-columns: 1fr;
    gap: 0;
  }

  /* width:100vw + this negative margin is the same "escape a centered,
     padded container" trick as the .full-bleed utility class elsewhere
     in this file — duplicated inline (rather than adding that class in
     the markup) since it's paired here with background/padding that's
     specific to this component. Each block paints its own background
     directly here rather than through the ::before elements the desktop
     columns use above — those bleed relative to a shared inner edge
     between side-by-side columns, which doesn't exist once intro/panel
     stack, so they're switched off below instead. */
  .contact__intro,
  .contact__panel {
    width: 100vw;
    margin-inline: calc(50% - 50vw);
  }

  .contact__intro::before,
  .contact__panel::before {
    content: none;
  }

  .contact__intro {
    /* 2x the original flat 7rem this used to render at here: at every
       width in this range (down to the 768px phone breakpoint below),
       the desktop clamp's 10vw component never actually reached its own
       7rem floor, so tablet was rendering the pattern at a flat 7rem
       regardless of the fancier desktop formula. Explicit and flat here
       for the same reason — a vw-based clamp would just re-flatten to
       whichever bound wins across this narrow a range anyway. */
    --contact-pattern-width: 14rem;
    /* padding-inline-end reserves --contact-pattern-width (plus a
       breathing gap) so the heading/tagline wrap clear of the pattern
       here too — same reasoning as the desktop rule this value also
       feeds (see that rule's own comment), just against this element's
       own width instead of a grid column's. */
    padding-inline: var(--site-padding) calc(var(--contact-pattern-width) + 1rem);
    background: var(--periwinkle-bright);
    border-top: 1px solid var(--black-cat);
    /* Equal top/bottom here (unlike the desktop rule above, which adds
       --header-height to its top value) — desktop's .contact stretches
       to fill the full viewport below the header, so justify-content:
       center there centers the heading/tagline within that whole
       stretched height, and the extra --header-height in its padding-top
       is just a minimum clearance that gets absorbed into the free space
       flex is already centering across. Here .contact is display: block
       with no stretched height (see the ≤1024px rule above), so this
       box is sized by its own padding, and .contact__intro already
       starts below the fixed header via body's own padding-top (see that
       rule) — it never sits under the header the way padding-top: header-
       height would suggest. Adding it here would just be dead space at
       the top, pushing the text down off-center within the periwinkle
       area instead of centering it. */
    padding-block: clamp(2.5rem, 6vw, 3.5rem);
  }

  /* No .contact__pattern override here — the base rule above (right: 0,
     top/bottom: 0, all relative to .contact__intro) already does exactly
     what's wanted at this breakpoint too: .contact__intro is itself the
     full periwinkle block here (its own background, full-bleed to
     100vw), so the pattern just sits flush against its right edge and
     fills its height, same as the desktop column. Nothing here needs to
     account for the heading's own width the way an earlier, more
     complicated version of this rule did. */

  .contact__panel {
    background: var(--off-white);
    /* .contact__intro's own rule handles its (asymmetric) inline
       padding directly above — this is a plain, symmetric site-padding
       reset for .contact__panel, since it isn't next to the pattern and
       the desktop-only padding-inline-start from the shared rule above
       (the wave divider's breathing room there) doesn't apply once
       intro/panel stack instead of sitting side by side. */
    padding-inline: var(--site-padding);
    padding-block: clamp(2.5rem, 6vw, 3.5rem) clamp(3rem, 6vw, 4.5rem);
  }

  .contact__heading {
    font-size: clamp(3rem, 12vw, 5rem);
  }
}

@media (max-width: 768px) {
  /* 2x the original flat 4.5rem this rendered at here (same situation as
     the tablet override above: 13vw never actually reached the old 4.5rem
     floor across real phone widths, so this was rendering flat regardless
     of the vw term — explicit and flat here for the same reason). Still
     narrower than the tablet breakpoint's own flat 14rem, since this
     column is narrower still. */
  .contact__intro {
    --contact-pattern-width: 9rem;
  }

  /* Smaller than the desktop/tablet clamp(1.5rem, 3vw, 2.25rem) — that
     clamp's 1.5rem floor was still rendering flat-out too large next to
     the two-line break below at phone widths (3vw doesn't reach 1.5rem
     until ~800px, well above this breakpoint). */
  .contact__tagline {
    font-size: clamp(1rem, 4vw, 1.25rem);
  }
}

/* Tablet only (769–1024px): both breaks are suppressed here so the
   heading and tagline each render as a single line, unlike desktop
   (both break) and phone (both keep the break — see the ≤768px rule
   above for why the tagline needs it there too, just smaller).
   .contact__heading's own font-size clamps to a flat 5rem across this
   whole range (see the ≤1024px rule above — 12vw already exceeds it by
   768px), and at that size "Get in touch" fits .contact__intro's
   available width — narrowed by padding-inline-end's reserved space for
   the pattern, same as everywhere else — on one line; the tagline fits
   the same way at its own (unreduced here) size. */
@media (min-width: 769px) and (max-width: 1024px) {
  .contact__heading-break,
  .contact__tagline-break {
    display: none;
  }
}
