﻿/* ==========================================================================
   Lincor Holdings: homepage-only stylesheet
   Loaded after css/style.css, on index.html only. Everything here is scoped
   to the 14 Aug 2026 homepage rebuild; nothing in this file is referenced by
   any other page, which is why it is a separate file rather than another
   thousand lines on the shared stylesheet.

   SCROLL MODEL, and why it matters:
   The brief asked for "each section appearing in view, not the viewer
   scrolling through each section". That is done here with `position: sticky`
   on the hero and an ordinary opaque sibling scrolling up over it - NOT with
   a GSAP pin. A pin has to be handed a scroll budget, and any mismatch
   between that budget and the animation scheduled inside it produces exactly
   the dead scroll that made this site feel stuck (four frozen stretches on
   the old homepage, fixed 13 Aug). Sticky has no budget to get wrong: the
   hero holds still for precisely as long as the element above it takes to
   pass, and every pixel of scroll moves something. The only pin left on this
   page is the division stack, which genuinely needs scroll-driven animation.
   ========================================================================== */

/* --------------------------------------------------------------------------
   1. Intro overlay
   -------------------------------------------------------------------------- */
.intro {
  position: fixed;
  inset: 0;
  z-index: 9999;
  /* No background of its own - the navy is painted by .intro__bg below. */
  overflow: hidden;
  /* Never eats clicks. If a script error ever left this layer in the DOM, a
     stuck opaque overlay that ALSO blocks the whole page is a far worse
     failure than one you can at least scroll and click past. */
  pointer-events: none;
}

/* The navy field, and the rectangle cut out of it. A box-shadow with a very
   large spread and no blur paints solid colour in every direction EXCEPT the
   element's own box - so this is a zero-size element that covers the screen
   in navy, and growing it opens a clean rectangular window onto whatever is
   behind (the real hero video). 100vmax guarantees the ring still reaches
   every corner at any aspect ratio. */
/* The navy ground. Was .intro__hole (20 Aug 2026): a zero-size box with a
   100vmax navy box-shadow, which painted navy everywhere EXCEPT its own rect,
   so growing it opened a rectangle onto the hero. That is what made the
   transition read as two objects - see the long note in index.html.
   Nothing is knocked out of anything now. It is a plain navy sheet, and the
   window above simply grows until it has covered it. */
.intro__bg {
  position: absolute;
  inset: 0;
  background: var(--navy);
}
/* Not painted at all unless the intro is actually going to run. .intro-active
   is set on <html> by the inline head script in index.html, only for a first
   visit this session with motion allowed - so repeat visitors and
   reduced-motion visitors never render this layer even for one frame. */
html:not(.intro-active) .intro { display: none; }

/* The morphing window. Sized in px, not percentages, so the growth in
   js/home.js behaves identically at every viewport width.

   This is the whole morph, and it is worth explaining because it is doing
   something less obvious than it looks. The requirement (client, 20 Aug 2026)
   is that the mark becomes a rounded square as ONE uninterrupted object - no
   moment where a square can be read as a new element arriving on top of the
   logo. What follows is the third attempt; the first two are recorded because
   both failures are easy to walk back into.

   Everything happens inside a single element's mask. CSS unions multiple mask
   layers for free - mask-composite computes to `add` by default, checked in the
   browser rather than assumed - so no compositing keyword is needed and none of
   the patchily-supported subtract/exclude values are involved. The union can
   only ever GAIN area, which is what makes the silhouette continuous by
   construction: nothing fades, nothing is replaced.

   Two numbers drive it.

   --d closes the counters. It offsets nine copies of the mark - centre, four
   axes, four diagonals - by the same radius, and their union is the mark
   dilated by roughly that much: the strokes thicken and the gaps between them
   narrow from their edges inward. Crucially this is also what absorbs the
   mark's dot, which is a separate island in the artwork itself. At --d: 8px the
   dot has merged into the stroke beside it and the shape is fully connected,
   while the edges are still clean - dilating much further starts to show the
   octagon in the offsets as scalloping.

   --morph then trues the connected blob up into a rounded square, and where it
   grows FROM is the important part. Grown from the centre - the obvious choice,
   and the second failed attempt - it is born in open counter space, so for
   ~300ms there is visibly a small rounded lobe in the middle of the letterform:
   exactly the "square appearing inside the logo" that was being fixed. It is
   instead born at --sx/--sy, a point inside the J's own stroke, where it is
   hidden in already-lit area until it is big enough to read as the stroke
   swelling. js/home.js drifts that origin to centre as it grows.

   Past 100% the corners travel outside the element's box, so the visible edges
   straighten without anything switching from mask to border-radius. The final
   full-bleed beat is therefore the same object, with the same mask, that
   started as the logo - there is no static seam anywhere in the sequence.

   The mark sits at 70% rather than `contain` because its ink reaches all four
   edges of its own PNG; at `contain` the dot and the J's outer curve would sit
   on the box edge and cut into the square's rounded corners.

   preserveAspectRatio="none" is required on that SVG: the default
   (xMidYMid meet) letterboxes the rect inside a non-square mask box instead of
   stretching to fill it, and by the final beat the box is the whole viewport. */
.intro__window {
  /* px, not percentages, because the box below never changes size - see the
     layout note under it. All four are driven by js/home.js. */
  --mark-size: 91px;   /* the mark's own width/height */
  --d: 0px;            /* dilation radius: closes the counters, absorbs the dot */
  --morph: 0px;        /* the rounded square's size */
  --ox: 54px;          /* where that square grows FROM, as an offset from centre */
  --oy: 36px;

  --mark: url("../images/logo/lincor-mark.png");
  --square: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" preserveAspectRatio="none"><rect width="100" height="100" rx="24" fill="black"/></svg>');

  /* The nine offset copies, kept in named lists so the declarations below stay
     readable. Order matters only in that the square must come last. */
  --mark-x9: var(--mark), var(--mark), var(--mark), var(--mark), var(--mark),
             var(--mark), var(--mark), var(--mark), var(--mark);
  --size-x9: var(--mark-size) var(--mark-size), var(--mark-size) var(--mark-size),
             var(--mark-size) var(--mark-size), var(--mark-size) var(--mark-size),
             var(--mark-size) var(--mark-size), var(--mark-size) var(--mark-size),
             var(--mark-size) var(--mark-size), var(--mark-size) var(--mark-size),
             var(--mark-size) var(--mark-size);
  --pos-x9: center,
            calc(50% + var(--d)) 50%, calc(50% - var(--d)) 50%,
            50% calc(50% + var(--d)), 50% calc(50% - var(--d)),
            calc(50% + var(--d)) calc(50% + var(--d)), calc(50% - var(--d)) calc(50% + var(--d)),
            calc(50% + var(--d)) calc(50% - var(--d)), calc(50% - var(--d)) calc(50% - var(--d));

  /* The box is the whole viewport for the entire sequence and never resizes.
     That is a correctness requirement, not a style choice.

     It used to be a small box that grew - 130px to 300px, then out to the
     viewport - which is the obvious way to build this and measurably the wrong
     one. The <video> inside is clipped by this element's overflow, so growing
     the box grew the video's visible region, and Chrome's layout-instability
     API counts that as the video moving. Measured: CLS went from 0.069 to 0.261
     on the homepage, 0.194 of it attributed to .intro__video alone, purely from
     this box changing size. The video never actually moved a pixel - its rect
     was constant throughout - but the growing clip is scored as though it had.

     Sizing everything off the mask instead keeps the layout completely still:
     mask-size and mask-position are paint, not layout, so there is nothing for
     the layout-shift accounting to see. It is also cheaper - no per-frame
     layout on a full-screen element - which is why --mark-size and --morph are
     px rather than the percentages a resizing box needed. */
  position: absolute;
  inset: 0;
  overflow: hidden;
  will-change: mask-size, mask-position;

  -webkit-mask-image: var(--mark-x9), var(--square);
          mask-image: var(--mark-x9), var(--square);
  -webkit-mask-size: var(--size-x9), var(--morph) var(--morph);
          mask-size: var(--size-x9), var(--morph) var(--morph);
  -webkit-mask-position: var(--pos-x9), calc(50% + var(--ox)) calc(50% + var(--oy));
          mask-position: var(--pos-x9), calc(50% + var(--ox)) calc(50% + var(--oy));
  -webkit-mask-repeat: no-repeat;
          mask-repeat: no-repeat;
}

/* Final beat only. Once --morph has reached 100% the rounded square already
   contains the dilated mark completely (verified by pixel-diffing the frame
   either side of this switch - it is a visual no-op), so the nine mark layers
   can be dropped for the part of the sequence that is full-screen and therefore
   the most expensive to composite. Silhouette identical, one mask layer instead
   of ten. */
.intro__window.is-square {
  -webkit-mask-image: var(--square);
          mask-image: var(--square);
  -webkit-mask-size: var(--morph) var(--morph);
          mask-size: var(--morph) var(--morph);
  -webkit-mask-position: calc(50% + var(--ox)) calc(50% + var(--oy));
          mask-position: calc(50% + var(--ox)) calc(50% + var(--oy));
}

/* The video inside the window is locked to viewport size and centred, so
   growing the container reveals more of the same frame rather than rescaling
   the footage. That is the difference between a window opening and an image
   zooming - and it also means the frame lines up with the hero video behind,
   so the handoff in stage 2 is seamless. */
.intro__video {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 100vw;
  height: 100vh;
  object-fit: cover;
  pointer-events: none;
}


/* Header and hero copy stay out of sight until the reveal lands. Both are
   suppressed only while .intro-active is on <html>, which js/home.js removes
   at the end of the timeline - and also immediately if the intro is skipped,
   or if GSAP failed to load. A broken script can never leave the page
   permanently headless. */
html.intro-active .site-header { opacity: 0; pointer-events: none; }
html.intro-active .hero-lock__content { opacity: 0; }
/* The navigation entrance (@keyframes nav-enter in style.css) is held at its
   first frame for the same span, and released by the same class removal. Its
   delays are only 160/260ms, so without this both halves of the bar would play
   out during the intro behind that opacity: 0 header - meaning the entrance
   would never actually be seen on a first visit, which is the one visit it
   exists for. Pausing rather than re-triggering keeps it a CSS animation:
   nothing has to add a class at the right moment, so there is no version of
   this where a script failure leaves the bar stuck off its resting position. */
html.intro-active .site-header .logo,
html.intro-active .menu-btn { animation-play-state: paused; }
/* Added by js/home.js when it takes the navigation entrance over (intro branch
   only). From that point the logo and the Menu button are animated by GSAP
   alone - two systems writing transform on one element is what this prevents,
   and it has to be a class rather than an inline style because GSAP's
   clearProps: 'all' would wipe an inline one at the end of the tween. */
html.nav-enter-js .site-header .logo,
html.nav-enter-js .menu-btn { animation: none; }

/* --------------------------------------------------------------------------
   1a. Masked line reveal
   -------------------------------------------------------------------------- */
/* Built at runtime by maskLines() in js/home.js: one clipping block per line,
   each holding a block that slides up out of it. Only ever added when the
   motion actually runs, so reduced-motion and no-GSAP visitors keep the plain
   heading markup exactly as authored.

   The padding/negative-margin pair is doing real work, not spacing: overflow
   hidden clips at the box edge, and a box sized to the text alone cuts the
   tails off descenders (every "g", "y" and "p" in the hero headline). The
   padding gives the clip somewhere to happen, and the equal negative margin
   takes that space back out of the layout, so the heading occupies exactly the
   height it did before it was split - no reflow, no shift (brief 23). */
.line-mask {
  display: block;
  overflow: hidden;
  padding-bottom: 0.14em;
  margin-bottom: -0.14em;
}
.line-inner { display: block; }

/* The aperture the two hero states flip through (20 Aug 2026, client: the
   change has to move instantly, smoothly, and flip up).

   Two earlier attempts are recorded because each failure points at the next
   thing to get right.

   FIRST: a cross-fade of the two states. Both lines sat in the same place at
   partial opacity for the length of the transition, so what you saw was two
   headlines dissolving through each other. Shortening it only produced a short
   dissolve - still soft, still reading as lag, because nothing in it ever looks
   like a single decisive movement.

   SECOND: a clipping band around each individual LINE, so each one could pivot
   out of its own box with no fade at all. Right idea, wrong geometry: the two
   states are stacked on one centre, so their lines sit at DIFFERENT heights -
   the incoming statement rose straight through the outgoing wordmark, and
   mid-flight there were three bands of type crossing each other. Caught by
   freezing the transition and looking at it, not by reasoning about it.

   THIS: one aperture, both states inside it, exactly as a flipboard works. Each
   state travels one full aperture height, so at every instant the outgoing one
   is leaving through the top while the incoming one arrives from the bottom and
   the two meet edge to edge without ever sharing a pixel. Nothing fades: the
   whole change is carried by geometry, which is what makes it read as one
   object turning over rather than as two images mixing.

   The height is not set here - it comes from the grid. Both states share grid
   area 1/1, so the row is as tall as the taller of them and the aperture is
   exactly the size of the type it holds. That is also why js/home.js measures
   the travel distance at the moment the flip starts rather than hard-coding it:
   the type is fluid (clamp() at every size) so the aperture is a different
   height at every viewport width.

   perspective is what makes the pivot a flip rather than a slide - the rotateX
   in js/home.js hinges each state at the edge it leaves or arrives through, so
   it turns away from the viewer as it goes. 1200px is a shallow field: enough
   to be felt, not enough to distort display-size letterforms into a gimmick.

   The padding/negative-margin pair does the same job as on .line-mask above -
   it gives the clip somewhere to happen so glyph overshoot is not shaved at the
   aperture edge, and takes the space back out of the layout again. */
.hero-flipbox {
  display: grid;
  overflow: hidden;
  perspective: 1200px;
  padding-block: 0.08em;
  margin-block: -0.08em;
}
/* Each state is a card in the aperture: same grid cell, its own transform. */
.hero-flipbox > .hero-state {
  grid-area: 1 / 1;
  transform-style: preserve-3d;
  will-change: transform;
}

/* --------------------------------------------------------------------------
   2. Hero (sticky)
   -------------------------------------------------------------------------- */
/* The header is fixed on this page rather than sticky, so it occupies no
   space in the flow at all. Sticky still reserves its 88px at the top of the
   document, which pushed the hero down and left a white band above the video
   (the old homepage papered over this with margin-top: -88px on the hero,
   which then had to be added back as padding - two hacks cancelling out).
   Fixed removes the cause: the hero starts at true y=0 and the header simply
   floats over it. Everything else about the header - the hide-on-scroll
   transform, the scrim, .is-over-hero - is untouched and keeps working. */
body:has(.hero-lock) .site-header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
}
.hero-lock {
  position: sticky;
  top: 0;
  height: 100svh;
  z-index: 0;               /* under the white surface that scrolls over it */
  display: flex;
  align-items: flex-end;
  overflow: hidden;
  background: var(--navy);
  /* Overrides the sitewide `section { padding-block: var(--section-pad) }` -
     this one is full-bleed and positions its own content. */
  padding-block: 0;
}
.hero-lock__media { position: absolute; inset: 0; z-index: 0; }
.hero-lock__video {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
  /* Chrome shows a picture-in-picture affordance on hover for any <video>,
     controls or not. This is purely a background. */
  pointer-events: none;
}
/* No scrim over the hero video (client, 20 Aug 2026). There was one - a weak
   neutral black, a centre vignette for the type plus a top-and-bottom linear
   pass for the header, its strength driven per-frame from the clip's own clock
   in js/home.js so it eased off as the sky darkened. All of it is gone at the
   client's request: it read as washing the footage out, and the footage is the
   subject.

   Kept as a note because it is a trade, not a free win. That layer was what
   held the white type's contrast over the brightest frame - measured 2.55:1
   mean and 1.54:1 across the worst 5% of pixels, i.e. under AA for large text.
   If legibility needs recovering, the type is the place to do it: a per-glyph
   text-shadow, which is what the header's own nav uses over this same video,
   costs nothing in tone across the rest of the frame. Do not reinstate a full
   layer over the picture. */

/* Centred, both axes (client brief, 20 Aug 2026). It was bottom-left aligned in
   a .container before; the states now sit in the middle of the frame.

   Both states are stacked in the same place rather than laid out in flow - they
   are alternatives, not a sequence of blocks, so only one occupies the space at
   a time and swapping them must not move anything. Sharing one grid area is
   what keeps them registered on the same centre regardless of how many lines
   each one runs to. */
.hero-lock__content {
  position: relative;
  z-index: 1;
  display: grid;
  place-items: center;
  width: 100%;
  height: 100%;
  padding-inline: var(--gutter);
  text-align: center;
}

.hero-state {
  grid-area: 1 / 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--sp-4);
  /* Only the active state takes pointer input - the other one is still in the
     box, just invisible, and would otherwise swallow clicks meant for the CTAs
     or for the video behind. */
  pointer-events: none;
}

.hero-state.is-active { pointer-events: auto; }

/* First state: the wordmark. LINCOR in the heaviest weight available, HOLDINGS in
   book - standing in for Gotham Black / Gotham Book, see the note on the font
   link in index.html. Both are set in caps in the markup's own case so the
   words remain readable as words to a screen reader and to search. */
/* A per-glyph text-shadow was added here on 7 Sep 2026 and REMOVED the same
   day at the client's request - the drop shadow behind the type was not
   wanted. Recorded rather than deleted, because the note on .hero-lock__video
   above proposes exactly this as the way to recover the white type's contrast,
   and it should not be proposed again as though it were untried.

   What it was: a tight 1px edge plus a wide soft pool, both pure black at low
   alpha. It did work on the measurement - 1.42:1 to 4.9:1 on the brightest
   frame at 390px, where white type sits on open sky - and it was invisible over
   the dark half of the day-night loop. The client still did not want it.

   So the contrast trade stands where it stood: no scrim over the footage (their
   earlier call) and now no shadow on the type either. On the bright end of the
   loop the hero type is genuinely low-contrast, and that is a deliberate,
   informed choice rather than an oversight. If it ever has to be recovered,
   the remaining levers are the footage itself (a darker grade, or a clip whose
   centre is not open sky) or the type's colour - not a layer and not a shadow.

   The separate fault reported alongside it - a 31px band of the next state
   leaking into the flip aperture on mobile - was a real bug and is fixed in
   js/home.js. That one had nothing to do with contrast. */
.hero-wordmark {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: clamp(2px, 0.6vw, 10px);
  margin: 0;
  color: var(--white);
  font-family: var(--f-hero);
  text-transform: uppercase;
}

.hero-wordmark__name {
  font-weight: 900;
  font-size: clamp(3rem, 13vw, 11rem);
  line-height: 0.9;
  /* Tightened as it gets bigger - at display size Montserrat's default spacing
     reads loose, and the mark it stands in for is tighter still. */
  letter-spacing: -0.02em;
}

.hero-wordmark__sub {
  font-weight: 400;
  font-size: clamp(0.85rem, 3.2vw, 2.4rem);
  line-height: 1;
  /* Letter-spaced out to the width of the name above it. Tracking is what makes
     a light word sit under a black one as a lockup rather than as a caption. */
  letter-spacing: clamp(0.18em, 0.6vw, 0.42em);
  /* The tracking is applied to the right of the last letter too, which pushes
     the word visually left of centre; this takes that back. */
  text-indent: clamp(0.18em, 0.6vw, 0.42em);
}

/* States 2 and 3: one statement per state, same weight as the wordmark's name so
   the three states read as one voice. */
.hero-line {
  margin: 0;
  max-width: 22ch;
  color: var(--white);
  font-family: var(--f-hero);
  font-weight: 900;
  font-size: clamp(2rem, 7.2vw, 5.5rem);
  line-height: 1.02;
  letter-spacing: -0.02em;
  text-transform: uppercase;
  text-wrap: balance;
}

.hero-state__cta {
  justify-content: center;
}

.hero-lock .btn-primary { background: var(--white); color: var(--navy); }
.hero-lock .btn-primary:hover,
.hero-lock .btn-primary:focus-visible { background: var(--bg-tint); }
.hero-lock .btn-outline { color: var(--white); border-color: var(--white); }
.hero-lock .btn-outline:hover,
.hero-lock .btn-outline:focus-visible { background: var(--white); color: var(--navy); }

/* The 200vh .hero-scroll spacer and its html.hero-scrub height lived here.
   Both are gone with the scroll-driven type (client, 20 Aug 2026): the two
   states swap on a timer, so there is nothing for a scroll runway to pace and
   two empty screen-heights would only be two empty screen-heights. The hero is
   one screen tall and .surface--over-hero climbs over it directly. */

/* No JS, or reduced motion: the states cannot be swapped, so they are simply
   both shown, stacked, centred and still. The words are the point and they are
   both present - nothing moves, nothing is hidden, and the hero is one screen
   tall as it always was. */
.hero-lock__content:not(.is-sequenced) .hero-state {
  grid-area: auto;
  pointer-events: auto;
}

.hero-lock__content:not(.is-sequenced) {
  align-content: center;
  gap: clamp(var(--sp-3), 4vh, var(--sp-5));
}

.hero-lock__content:not(.is-sequenced) .hero-wordmark__name {
  font-size: clamp(2.4rem, 9vw, 6rem);
}

.hero-lock__content:not(.is-sequenced) .hero-line {
  font-size: clamp(1.4rem, 4.4vw, 2.6rem);
}

/* --------------------------------------------------------------------------
   3. White surface: statement + figures
   -------------------------------------------------------------------------- */
.surface {
  position: relative;
  z-index: 1;
  background: var(--shell);
}

/* Paper grain on the eggshell surfaces. --grain is an inline SVG feTurbulence
   tile (defined in style.css, no network request) drawn at a fixed 128px so
   the browser rasterises one small tile once and repeats it, rather than
   computing a noise field the size of the whole section on every paint.

   Opacity is the whole point here: the client's reference was a heavy plaster
   wall and asked for "a lot more subtle". At 0.02 the texture is not legible
   as a pattern at all - it just stops the flat fill reading as screen-white,
   the way an uncoated stock differs from a gloss one. Anything past ~0.04 and
   it starts to look like noise sitting on top of the design.

   On a pseudo-element rather than the section's own background so it can be
   layered under the content without tinting photographs or video, and
   pointer-events: none so it can never intercept a click. */
.surface::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  background-image: var(--grain);
  background-size: 128px 128px;
  background-repeat: repeat;
  opacity: 0.02;
}
.surface > * { position: relative; z-index: 1; }

/* This is the element that scrolls up over the sticky hero. The shadow on its
   top edge is what sells the overlap: without it the white simply appears,
   with it the surface reads as a sheet physically sliding over the video. */
.surface--over-hero {
  box-shadow: 0 -32px 60px rgba(1, 35, 64, 0.3);
  /* Bottom space is PADDING, not the figures' margin. A bottom margin on the
     last child collapses straight out through .container and this section
     when the section has no bottom padding of its own - so the gap ended up
     OUTSIDE the eggshell box, and the sticky hero showed through it as a
     strip of skyline between the numbers and the division stack. Padding
     cannot collapse, so the space stays inside the surface. */
  padding-block: clamp(var(--sp-7), 14vh, 180px) clamp(160px, 24vh, 320px);
}

/* Two-column About row: image left, statement right. Single column below
   900px, where a side-by-side split leaves neither half enough room. */
.about-row {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--sp-6);
  align-items: center;
}
/* Two columns, centred as a pair with equal air either side, rather than a
   full-bleed grid pinned to both gutters (17 Aug 2026, client request).

   The image is sized FROM the text: js/home.js sets --about-visual-h to the
   statement block's measured height, and the 3/4 aspect-ratio here turns that
   into the width, so the photo starts level with the first line of the heading
   and finishes level with the last line of the paragraph.

   Why the height comes from JS and not from CSS: deriving a box's width from a
   sibling's height is circular. A grid sizes its columns before its rows, so an
   aspect-ratio box asked to be 100% tall has no height to work from yet and
   falls back to the image's intrinsic width - measured here at 1600px, which
   threw the row to 2133px tall.

   The feedback loop that would make that measurement unstable is cut on the
   next line: .statement has a fixed width, so its height can never depend on
   how wide the image is. Image follows text; text follows nothing. Without
   that, a shrinking text column would make the text taller, which would make
   the image taller, which would make it wider, which would shrink the text
   column again. */
/* 1100px, not the 900px this row used to switch at (18 Aug 2026). The text
   column is a fixed width and cannot shrink - that is what keeps the image
   sizing stable - so below a certain viewport the image is the only thing left
   to give, and flex takes its width, which takes its 3/4 proportion with it.
   Measured: 0.63 at 1000px, 0.74 at 1050px, 0.75 from 1100px up. Below that
   the single-column layout is the better read anyway.
   Kept in step with the same threshold in js/home.js. */
@media (min-width: 1100px) {
  .about-row {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: clamp(var(--sp-5), 4vw, var(--sp-7));
  }
  /* 68ch, not the paragraph's own 58ch (18 Aug 2026). "exporting worldwide."
     measures 613px on one line at the heading's largest size, and a 58ch
     column is 540px - so the last word could not physically fit beside the one
     before it and was left stranded on a line of its own. No wrapping strategy
     fixes that; text-wrap: balance and pretty can only choose between breaks
     that fit, and this one did not. The column is widened instead.

     The paragraph is unaffected: it keeps its own max-width: 58ch from the
     rule below, so its measure is unchanged and only the heading uses the
     extra room. flex-shrink 0 is what keeps the sizing loop cut - see above. */
  .about-row .statement {
    flex: 0 0 auto;
    width: min(68ch, 100%);
  }
  .about-visual {
    flex: 0 1 auto;
    aspect-ratio: 3 / 4;
    /* The fallback is only ever on screen if JS has not run yet, or at all -
       close enough that correcting it is not a visible jump. */
    height: var(--about-visual-h, clamp(340px, 32vw, 480px));
  }
}

/* The image starts as a smaller rectangle and opens out to the full column.
   The reveal is a clip-path inset on this wrapper, not a width/height or
   scale change on the image: clipping never moves the photo or its
   neighbours, so the row cannot reflow and nothing below it shifts while the
   animation runs. js/home.js animates --reveal from 14% to 0; the value is
   declared here so the un-animated state (no GSAP, reduced motion) is the
   finished one rather than a permanently shrunken image. */
.about-visual {
  --reveal: 0%;
  position: relative;
  overflow: hidden;
  border-radius: var(--radius);
  clip-path: inset(var(--reveal) round var(--radius));
}
/* img and video share this: the child is a still on some pages and the
   walking loop on the homepage, and either one fills the wrapper the same
   way. pointer-events: none is for the video - Chrome floats a
   picture-in-picture affordance over any <video> on hover, controls or not,
   and this one is a background. Same reason .hero-lock__video carries it. */
.about-visual img,
.about-visual video {
  display: block;
  width: 100%;
  height: 100%;
  aspect-ratio: 4 / 5;
  object-fit: cover;
  pointer-events: none;
}
/* Above 1100px the wrapper carries the ratio (see .about-visual), so the child
   simply fills it and crops. Leaving 3/4 here as well would fight the wrapper's
   JS-set height. */
@media (min-width: 1100px) {
  .about-visual img,
  .about-visual video { aspect-ratio: auto; }
}

/* .type-word / .type-caret moved to css/style.css 8 Sep 2026: property.html's
   new statement section types in the same way and does not load this
   stylesheet, so the shared, page-agnostic half of the typewriter moved to
   the sitewide sheet. Only .statement's own homepage layout stays here. */

.statement h2 {
  font-size: clamp(2rem, 5.2vw, 4rem);
  line-height: 1.04;
  letter-spacing: -0.025em;
  color: var(--navy);
  max-width: 18ch;
  margin-bottom: var(--sp-4);
  text-wrap: balance;
}
.statement p {
  font-size: clamp(1rem, 1.35vw, 1.2rem);
  line-height: 1.6;
  max-width: 58ch;
}

/* Three figures across the full width of the surface: big and blue, per the
   brief. minmax(0, 1fr) rather than 1fr so a long label can never push its
   column wider than a third. */
/* Generous air on both sides of the figures (14 Aug 2026, client request).
   The bottom margin is the larger of the two on purpose: it is the gap
   between the numbers and the division stack, and the stack arrives as a
   full-bleed pinned panel with no padding of its own, so this margin is the
   only thing separating them. */
.figures {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--sp-5);
  margin-top: clamp(var(--sp-7), 16vh, 200px);
}
@media (min-width: 760px) {
  .figures {
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: var(--sp-4);
  }
}
.figure {
  display: flex;
  flex-direction: column;
  gap: var(--sp-1);
  /* Number and label centred over each other in the middle of their column,
     so the three read as an evenly spaced row rather than three left edges. */
  text-align: center;
}
.figure__num {
  font-family: var(--f-display);
  font-weight: 700;
  font-size: clamp(3.5rem, 10vw, 8.5rem);
  line-height: 0.9;
  letter-spacing: -0.045em;
  color: var(--navy);
  /* Keeps the three columns optically steady while the count-up runs -
     without tabular figures the numbers visibly jitter as digit widths
     change from frame to frame. */
  font-variant-numeric: tabular-nums;
}
.figure__label {
  font-family: var(--f-display);
  font-weight: 600;
  font-size: clamp(1rem, 1.6vw, 1.35rem);
  letter-spacing: -0.01em;
  color: var(--navy);
}

/* --------------------------------------------------------------------------
   4. Division stack
   -------------------------------------------------------------------------- */
/* MOBILE-FIRST BASE = THE FALLBACK. Every panel is an ordinary full-width
   block in document order, fully readable with no JS at all. Touch devices,
   narrow viewports, reduced-motion and a failed GSAP load all get exactly
   this. The horizontal bar layout below is added on top, never depended on. */
.dstack {
  padding-block: 0;
  position: relative;
  z-index: 1;
  background: var(--shell);
}
.dstack__rail { display: flex; flex-direction: column; }
.dstack__panel {
  position: relative;
  min-height: 78svh;
  display: flex;
  align-items: flex-end;
  overflow: hidden;
  background: var(--navy);
}
.dstack__media {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
/* Navy cast removed 14 Aug 2026 at client request - the photography shows in
   its own colour now instead of being tinted blue across the whole panel.
   Legibility is handled locally instead, in two pieces:

   1. This scrim, which is now neutral black rather than navy, weak, and
      confined to the bottom third. It grounds the copy and the spine label,
      both of which sit low, without recolouring the image.
   2. A soft vignette behind the text block itself (.dstack__body::before),
      which does the real contrast work exactly where it is needed and fades
      to nothing well before the edges of the photo.

   Splitting it this way is what lets the image stay clean. A single
   full-panel gradient strong enough for panel 01 - a near-white newspaper
   mockup carrying white headings - would have to be heavy enough to flatten
   every other panel too. */
.dstack__scrim {
  position: absolute;
  inset: 0;
  background: linear-gradient(to top, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0.18) 22%, rgba(0, 0, 0, 0) 42%);
}
.dstack__body {
  position: relative;
  z-index: 2;
  padding: var(--gutter);
  padding-block: clamp(24px, 6vh, 64px);
  max-width: 60ch;
}
/* The local vignette that replaced the old full-panel navy wash. An ellipse
   anchored at the bottom-left of the copy, bleeding well past the text block
   on every side so it has no visible edge, and gone entirely by 70% out. This
   is what makes white text survive on panel 01's near-white newspaper image
   while leaving the rest of that photo untouched.
   z-index: -1 keeps it behind the text but still above the image: .dstack__body
   sets z-index 2, which makes it a stacking context, so a negative child
   cannot escape below the media it sits on. */
.dstack__body::before {
  content: "";
  position: absolute;
  left: -12%;
  right: -35%;
  top: -18%;
  bottom: -30%;
  z-index: -1;
  pointer-events: none;
  background: radial-gradient(ellipse at 28% 82%,
              rgba(0, 0, 0, 0.82) 0%,
              rgba(0, 0, 0, 0.55) 38%,
              rgba(0, 0, 0, 0) 72%);
}
/* Per-glyph insurance for the rare frame where a bright highlight in the
   photograph lands directly under a letter and the vignette alone is not
   quite enough. Cheap, and invisible everywhere it is not needed. */
.dstack__body h3,
.dstack__body p {
  text-shadow: 0 1px 12px rgba(0, 0, 0, 0.55);
}
/* The division name, at the hero's scale (client, 20 Aug 2026 - "big like the
   heading in the header section"). It was 3rem at most and sat under a small
   mono label, with the brand names taking the heading slot; now the division
   IS the heading and there is nothing above it, so it carries the panel on its
   own and has to be sized for that.

   Sized in vw with the ceiling well out of the way, on purpose. A rem ceiling
   near the common desktop sizes is what made this look small on a big monitor
   (client, 20 Aug 2026): the panel is always the full width of the window, so
   once the type stops growing it does not stay the same size, it becomes a
   smaller and smaller part of the picture. Measured, 5rem held the heading at
   5.80% of the window width up to about 1380px and then let it fall away to
   2.33% by 3440px - the same type, less and less of the frame. At 5.6vw it is
   5.6% of the width at every size, which is what "the same proportions" means
   here. The ceiling is a backstop for a projector, not a design decision.

   line-height comes down as the type goes up: 1.4 was right for a 28px
   heading and leaves a visible gap between two lines of 80px. */
.dstack__body h3 {
  color: var(--white);
  font-size: clamp(2.25rem, 5.6vw, 12rem);
  line-height: 0.98;
  letter-spacing: -0.03em;
  margin-block: 0 var(--sp-3);
  text-wrap: balance;
}
/* The paragraph grows too, or a 190px heading would sit over 16px body copy
   on a wide screen and the two would stop reading as one block. It keeps its
   46ch measure rather than a width in vw, so the line length stays readable
   while the type scales - ch is relative to the font, so the column widens
   with it automatically. */
.dstack__body p {
  color: rgba(255, 255, 255, 0.85);
  font-size: clamp(1rem, 1.15vw, 2.25rem);
  line-height: 1.5;
  max-width: 46ch;
  margin-bottom: var(--sp-4);
}
.dstack__body .btn-outline { color: var(--white); border-color: var(--white); }
.dstack__body .btn-outline:hover,
.dstack__body .btn-outline:focus-visible { background: var(--white); color: var(--navy); }

/* The vertical spine label only exists in the collapsed bar state, which only
   exists on desktop - hidden entirely in the stacked fallback. */
.dstack__spine { display: none; }

/* DESKTOP ENHANCEMENT: scroll-driven stacked overlay.
   Rebuilt 14 Aug 2026. The first version laid the panels out as flex slots
   sitting SIDE BY SIDE, which is why the sequence read as panels arriving
   next to each other rather than over each other - each one occupied its own
   column of the rail, so nothing was ever on top of anything.

   They are now absolutely positioned and fully overlapping: every panel is
   inset: 0, so all five share an identical box - same width, same height,
   same top edge, same border radius - and the ONLY thing that ever changes is
   translateX. Nothing can reflow, resize or shift vertically during the
   sequence because nothing about the box is animated.

   Panel 1 rests at x = 0. Panel n starts at translateX(100%) (exactly one
   container width to the right, so fully outside) and scrubs left to rest at
   n * --dstack-strip. Because each panel stops a strip short of the one
   beneath it, and z-index climbs with document order, every previous panel
   keeps a narrow vertical sliver visible down the left - and stays in the
   DOM underneath rather than being removed.

   Gated on the same condition js/home.js tests before building the timeline,
   so CSS and JS can never disagree about which layout is live. */
@media (hover: hover) and (pointer: fine) and (min-width: 1000px) {
  .dstack {
    /* --dstack-strip is the width of the sliver each covered panel keeps.
       It is read by js/home.js for the resting offsets, so the two are driven
       from one number rather than a magic constant in each file. */
    --dstack-strip: 84px;
  }
  .dstack__pin {
    height: 100svh;
    overflow: hidden;
    position: relative;
    /* Navy behind the stack. Panel 1 covers this completely from the first
       frame, so it only ever shows through the radiused corners - but it
       means the section can never flash page-white mid-sequence. */
    background: var(--navy);
  }
  .dstack__rail {
    position: absolute;
    inset: 0;
    display: block;   /* not flex - panels are positioned, not laid out */
  }

  .dstack__panel {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    min-height: 0;
    /* A hard left edge so each panel reads as a card laid over the last.
       Constant - never animated, per the brief. */
    box-shadow: -18px 0 40px rgba(0, 0, 0, 0.45);
    will-change: transform;
  }
  /* Panel 1 sits flat on the stage and needs no edge shadow of its own. */
  .dstack__panel:first-child { box-shadow: none; }

  /* Content sits clear of this panel own spine, which occupies the leftmost
     strip. Without this pad the heading would start underneath its own
     vertical label. */
  .dstack__body {
    position: absolute;
    left: 0;
    bottom: 0;
    /* Widened with the heading (20 Aug 2026): at 40vw a division name at this
       size broke onto three lines.

       Written as the text column plus the padding around it, rather than as
       one number for the whole box: the padding here is large and asymmetric
       - a gutter each side plus the spine strip the heading has to clear - so
       a plain width says almost nothing about how much room the words get.

       The text column is a fraction of the window with no ch cap, for the
       same reason the heading is: a 70ch cap held it at 652px, which is 45%
       of a 1440px window and 19% of a 3440px one, so the copy drifted into
       the corner of the panel as the screen grew. 46vw keeps the block the
       same part of the panel at every size, and keeps the heading's wrapping
       the same with it - four of the five names on one line, "App
       Development" on two. */
    width: calc(46vw + var(--gutter) * 2 + var(--dstack-strip));
    /* The base rule caps this block at 60ch for the stacked fallback, where it
       is a normal-width column of prose. That cap is narrower than the width
       above and was silently winning it - measured, the text column stayed at
       347px at both 1440 and 1920 no matter what the width said. */
    max-width: none;
    padding-left: calc(var(--gutter) + var(--dstack-strip));
    opacity: 1;
    transform: none;
    pointer-events: auto;
  }

  /* The spine lives in the leftmost strip of its own panel - which is exactly
     the sliver left showing once the next panel covers it. That is why it
     needs no show/hide logic: it is always in the one place that stays
     visible. */
  .dstack__spine {
    display: flex;
    position: absolute;
    left: 0;
    top: 0;
    bottom: 0;
    width: var(--dstack-strip);
    padding: var(--sp-4) 0;
    flex-direction: column;
    align-items: center;
    /* One child now that the number has gone - the label alone - so there is
       nothing for a gap to sit between. */
    justify-content: flex-end;
    z-index: 3;
    transition: background-color var(--ease-standard);
  }
  .dstack__panel:not(:first-child) .dstack__spine:hover {
    background: rgba(255, 255, 255, 0.08);
  }
  .dstack__spine-label {
    font-family: var(--f-display);
    font-weight: 600;
    font-size: clamp(1rem, 1.25vw, 1.25rem);
    color: var(--white);
    white-space: nowrap;
    writing-mode: vertical-rl;
    transform: rotate(180deg);
    letter-spacing: -0.01em;
  }
}

/* --------------------------------------------------------------------------
   5. Brands conveyor
   -------------------------------------------------------------------------- */
/* The strip is the whole section now - the "Our Brands" heading was dropped
   at client request (20 Aug 2026), so the rule that sized it has gone with it.

   That makes the padding the only thing setting this band's rhythm, and it is
   symmetric on purpose: with no heading to anchor the top, the row of marks
   should read as a band running across one continuous eggshell sheet, between
   the division stack above and the closing block below. Both neighbours carry
   their own generous padding, so this stays deliberately modest - stacking
   three lots of 140px would open a hole in the middle of the page. */
/* .surface--brands is gone (24 Aug 2026): the conveyor it wrapped now lives
   in the dark run at the foot of the page, which sets its own spacing. */
.conveyor {
  /* The row's own three values, kept together because they are read against
     each other rather than individually.

     --mark-ink is a neutral dark grey, not --navy (client reference, 20 Aug
     2026). Against the eggshell it reads as ink on paper rather than as brand
     colour, which is what lets fifteen unrelated marks sit together as one set.

     --mark-rule-w is the width every hairline gets, and it is the MOST COMMON
     width in the row rather than an arbitrary one: measured, 8 of the 15 marks
     land exactly on the item's max-width because they are wide wordmarks that
     hit the cap before they hit the row height. So the same clamp() as
     .bcard's max-width below - one number, two uses, and it cannot drift out
     of step. Marks narrower than that get a rule wider than themselves, which
     is why neighbouring rules meet and overlap; per the brief, that is the
     intended look rather than a side effect. */
  --mark-ink: #404040;
  --mark-gap: clamp(40px, 4.6vw, 76px);
  --mark-rule-w: clamp(200px, 21vw, 320px);
  /* The width a rule is actually drawn at: the common mark width PLUS a little
     over one gap. The gap is the reason for the addition. At exactly the common
     width, the eight marks that sit at the cap get a rule precisely their own
     width, so the gap either side of them is left unruled and the row reads as
     a line of dashes with breaks in it - accidental-looking, rather than the
     overlap the brief asks for. Spanning one gap and a bit guarantees every
     rule meets or crosses its neighbours, so the run is continuous and the
     narrower marks overlap heavily, which is the intended look. */
  --mark-rule-span: calc(var(--mark-rule-w) + var(--mark-gap) * 1.15);
  --mark-rule: rgba(0, 0, 0, 0.12);
  overflow: hidden;
  /* Fades the track out at both edges rather than hard-cutting a card in
     half at the viewport boundary. */
  -webkit-mask-image: linear-gradient(to right, transparent 0%, black 4%, black 96%, transparent 100%);
  mask-image: linear-gradient(to right, transparent 0%, black 4%, black 96%, transparent 100%);
}
.conveyor__track {
  display: flex;
  flex-wrap: nowrap;
  /* One fixed gap, and never justify-content: space-between - the spacing
     between the last item of one copy and the first item of the next has to be
     identical to the spacing inside a copy, or the seam is visible every time
     it comes round.

     Widened from var(--sp-3) (24px) when the cards became bare logos: a card
     carries its own edges, so 24px between two of them is 24px between two
     visible boundaries. A mark has no edges, so the same gap let neighbouring
     logos read as one long graphic. This is roughly the width of a capital at
     the row's own size, which is what separates them as marks.

     Read from --mark-gap on .conveyor rather than written here, because the
     hairline span above is derived from it - two copies of this number would
     put breaks back into the rules the moment one of them changed. */
  gap: var(--mark-gap);
  width: max-content;
  /* FALLBACK ONLY (20 Aug 2026). The belt is driven by js/home.js now - see
     section 5b there - and this keyframe animation is what runs when it cannot
     be: no JS, or GSAP blocked. It is the old engine: two hand-written copies
     of the list translating exactly -50%, so the moment it resets the copies
     line up pixel for pixel. Seamless, but mechanically linear at a constant
     90s - which is precisely the "website marquee" feel the JS replaces.
     js/home.js adds .is-live, which switches this off before it can be seen;
     both start at translateX(0), so the handover has nothing to jump. */
  animation: conveyor-scroll 90s linear infinite;
}
/* Pausing on hover lets someone actually stop and read a brand rather than
   chasing a moving target. Under .is-live the JS does this instead: it eases
   the belt to a stop rather than freezing it mid-stride, and grows the card
   under the cursor into the focus. */
.conveyor:hover .conveyor__track { animation-play-state: paused; }
@keyframes conveyor-scroll {
  from { transform: translateX(0); }
  to { transform: translateX(-50%); }
}

/* The JS belt has taken over. will-change lives here, on the one element that
   is actually transformed every frame, and only once the thing driving it
   exists - not on the cards, and not on the section. */
.conveyor.is-live .conveyor__track {
  animation: none;
  will-change: transform;
}
/* Room for the hovered card to grow into, without moving anything.
   
   Three approaches were measured, because two of them look right and are not.
   
   `overflow: visible` with `clip-path: inset(-100% 0)` reads as the precise
   tool for "clip one axis only", and it is not: clip-path clips PAINT, not
   scrollable overflow, so the 10920px track became 9287px of horizontal
   overflow on the document and the page could be dragged sideways.
   
   `overflow: clip` with `overflow-clip-margin: 20px` is layout-neutral and
   creates no scroll container - but the clip margin applies to every side, and
   the 20px it allows horizontally still registered as 20px of page overflow.
   
   Padding with an equal negative margin on both sides - the pair .line-mask
   uses - is not neutral HERE: the negative top margin collapses against the
   heading's bottom margin rather than simply pulling the strip up, and measured
   it left the section 8.95px taller with everything below pushed down to match.
   
   What is left is the version below, measured at 0.00px of change to the
   section height, the track's position, the next section's position and the
   document height, with no horizontal overflow: pad the box out by 18px top and
   bottom so `overflow: hidden` has something to clip against, take the whole
   36px back off the bottom margin so the flow is unchanged, then shift the
   painted box up 18px so the strip lands exactly where it did. relative/top
   rather than a second negative margin precisely because top does not collapse
   against anything. 18px covers the largest growth on the page: a 280px card at
   1.14 gains 14.7px per edge, a 200px mobile card 10.5px. */
.conveyor.is-live {
  /* 18px was enough for a 46px mark at 1.28; a 72px one grows by 10px per edge
     and the focus ring wants a little air beyond that. Kept in step with the
     numbers below it - the -2x on the margin and the -1x on top are what make
     this add nothing to the layout. */
  padding-block: 26px;
  margin-bottom: -52px;
  position: relative;
  top: -26px;
}
/* Cursor over the belt: the card that grows is the subject, so the pointer
   should say so. No transition or transform declared here - the scale, the
   dim and the z-index are all GSAP's, and a CSS transition on the same
   properties would fight it for ownership every frame. */
.conveyor.is-live .bcard { cursor: default; }
/* Continuous autonomous motion, so it gets the same reduced-motion floor as
   every other self-running animation on this site: stopped, wrapped into a
   static grid, with the duplicate copy hidden so nothing appears twice. */
@media (prefers-reduced-motion: reduce) {
  .conveyor__track {
    animation: none;
    flex-wrap: wrap;
    width: auto;
    justify-content: center;
    /* Row gap as well as column gap now: wrapped rows of bare marks need
       vertical separation that boxes used to give themselves. */
    row-gap: clamp(28px, 4vh, 48px);
  }
  .conveyor {
    -webkit-mask-image: none;
    mask-image: none;
    /* The rules sit 14px outside each item, and without the belt's clip box
       there is nothing holding that space open - the first and last rows would
       have theirs shaved off by `overflow: hidden`. */
    padding-block: 16px;
  }
  .bcard[aria-hidden="true"] { display: none; }
}
/* Each item is now just its logo (client, 20 Aug 2026). The card it used to
   sit in - tinted panel, hairline border, 16px radius, 4:3 at 200-280px wide -
   is gone, along with the empty .bcard__img slot inside it that was holding
   space for brand photography.

   So the item has no box of its own: its height is fixed, its width comes from
   the logo's own proportions, and what used to be a wall of cards is a row of
   marks. Height rather than width is what is fixed, because these logos run
   from nearly square to about 6:1 - matching widths would set the square ones
   enormous next to the long ones, while a common height is what makes mixed
   marks read as one row. The width cap is the backstop for the longest of
   them. */
/* A hairline above and below every mark, all of them the same width and
   centred on the item - see --mark-rule-w on .conveyor for where that width
   comes from. One pseudo-element carries both, as two 1px background gradients
   pinned to its top and bottom edges, so the pair cannot drift apart and
   nothing extra is added to the DOM for 36 items.

   ::before because ::after is already the mark itself. Held 14px clear of the
   row so the rules frame the mark rather than touch it; the belt's clip box
   has 26px of room either side (see .conveyor.is-live), which is what stops
   them being shaved off. */
.bcard::before {
  content: "";
  position: absolute;
  top: -14px;
  bottom: -14px;
  left: 50%;
  width: var(--mark-rule-span);
  transform: translateX(-50%);
  background:
    linear-gradient(var(--mark-rule), var(--mark-rule)) top / 100% 1px no-repeat,
    linear-gradient(var(--mark-rule), var(--mark-rule)) bottom / 100% 1px no-repeat;
  /* Behind the mark, and never in the way of the pointer - the item's own
     hover is what the cursor is for. */
  z-index: 0;
  pointer-events: none;
}

.bcard {
  /* The focus state, as two numbers js/home.js tweens rather than as a
     transform on this element.

     It used to scale the whole item, which also scaled the hairlines inside it:
     the focused mark's rules grew ~18px past its neighbours' and put a visible
     jog in what is meant to be one continuous line. The rules have to stay
     exactly where they are while the mark grows INSIDE them, and dimming has
     to leave them alone too, or the row shows one bright segment of line around
     whatever the cursor is on.

     So the item itself never transforms. --focus scales the mark, --dim fades
     it, and both are read only by the mark - the ::after and .bcard__word
     below. Tweening a custom property costs a style recalc on the item rather
     than a layout, which for transform and opacity is the same class of work
     the direct tween was doing. */
  --focus: 1;
  --dim: 1;
  position: relative;
  flex: 0 0 auto;
  /* Grown at client request (20 Aug 2026): 46px -> 72px at desktop, 30 -> 44
     at mobile. The marks were sized to sit inside a card and kept that size
     when the card went away, which left them small on an open sheet - and
     several of these files carry a lot of their own internal padding, so the
     ink inside the box is smaller again than the number suggests. The width
     cap grows with it, in the same proportion, so the long marks are still the
     ones being held back and not suddenly the only ones. */
  height: clamp(44px, 4.9vw, 72px);
  max-width: clamp(200px, 21vw, 320px);
  display: flex;
  align-items: center;
  justify-content: center;
}
/* The photography slot. Kept in the markup - it is where brand imagery would
   go if the cards ever come back - but it paints nothing now. */
.bcard__img { display: none; }

/* Navy, exactly, via a mask rather than a filter.

   The marks are supplied as transparent PNGs in their own brand colours, and
   the old treatment flattened them with `filter: brightness(0) opacity(0.62)`
   - which can only ever produce black, or grey once faded. There is no filter
   chain that turns black into a specific colour without approximating it.

   So the paint and the shape are separated: this element keeps the logo's box
   and its alt text but stops painting its own pixels, and ::after fills that
   exact box with --navy masked to the logo's alpha. The colour is therefore the
   token itself, not a filter's best effort at it, and it stays right if the
   token changes.

   opacity, not visibility or display, is what hides the bitmap: all three hide
   the pixels, and only opacity leaves the image in the accessibility tree with
   its alt text intact. Its box is still what sizes the item, so the ::after's
   `inset: 0` lands exactly on the logo. --logo is set per item by js/home.js,
   which is the only place the src is known. */
.bcard__logo {
  position: relative;
  z-index: 1;
  height: 100%;
  width: auto;
  max-width: 100%;
  object-fit: contain;
}
/* Scoped to .has-logo, which js/home.js only adds once --logo is set and the
   ::after can actually paint the mark. Unscoped, the one case with no script at
   all - where nothing sets the class - hid every logo and painted nothing in
   its place. Without JS the marks simply show in their own colours, which is
   the right floor: visible and correct, just not tinted. */
.bcard.has-logo .bcard__logo { opacity: 0; }
.bcard.has-logo::after {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--mark-ink);
  -webkit-mask: var(--logo) center / contain no-repeat;
          mask: var(--logo) center / contain no-repeat;
  /* The mark is what grows and fades on hover - see the note on .bcard. */
  transform: scale(var(--focus));
  opacity: var(--dim);
  will-change: transform, opacity;
}

/* --------------------------------------------------------------------------
   6. Closing block
   -------------------------------------------------------------------------- */
/* Same eggshell surface as the brands wall directly above it, so the two read
   as one continuous sheet running into the footer rather than as two sections
   with a join. Motion tapers here by design (brief 15): the heading gets the
   same masked reveal as everything else, the rest is one short rise, and the
   footer below does not animate at all. */
.closing {
  padding-block: clamp(var(--sp-6), 12vh, 140px) clamp(var(--sp-7), 16vh, 200px);
  text-align: center;
}
.closing h2 {
  font-size: clamp(2rem, 5.2vw, 4rem);
  line-height: 1.04;
  letter-spacing: -0.025em;
  color: var(--navy);
  margin-block: var(--sp-3) var(--sp-5);
}
.closing .mono-label { display: block; }
.closing .cta-group { justify-content: center; }

/* --------------------------------------------------------------------------
   7. Microinteractions
   -------------------------------------------------------------------------- */
/* The arrow on a division CTA moves on its own rather than travelling with the
   label - a small independent part responding faster than the thing it sits
   in. Wrapped in its own span in the markup and marked aria-hidden, which also
   takes the bare glyph out of the link's accessible name.
   inline-block because transform does nothing to a plain inline box. */
.btn-arrow {
  display: inline-block;
  transition: transform var(--ease-standard);
}
.btn:hover .btn-arrow,
.btn:focus-visible .btn-arrow { transform: translateX(4px); }
@media (prefers-reduced-motion: reduce) {
  .btn-arrow { transition: none; }
  .btn:hover .btn-arrow,
  .btn:focus-visible .btn-arrow { transform: none; }
}

/* The three brands with no supplied artwork. Set as type rather than left as a
   hole, in the same navy as the masked marks so the row reads as one set. Not
   mono any more and not grey: it sat at 0.72rem in --slate to look deliberate
   inside a card, and with the cards gone that reads as a caption dropped
   between logos. Display weight at the row's own scale carries next to a
   wordmark. */
.bcard__word {
  position: relative;
  z-index: 1;
  font-family: var(--f-display);
  font-weight: 700;
  /* Sized against the MARKS beside it, not against body copy. At 1.25rem it
     measured right on paper and looked wrong in place: 20px of type between
     45px-tall logos reads as a caption someone dropped into the row. This is
     roughly the cap height of the artwork around it, which is what makes the
     three text-only brands sit as equals rather than as labels. */
  font-size: clamp(1.25rem, 2.6vw, 2.2rem);
  letter-spacing: -0.02em;
  /* Uppercase as it always was here - it is standing in for a wordmark, and
     every mark in the row is set in caps. */
  text-transform: uppercase;
  color: var(--mark-ink);
  text-align: center;
  white-space: nowrap;
  /* Same pair as the masked marks, so the three text-only brands behave
     identically under the cursor. */
  transform: scale(var(--focus));
  opacity: var(--dim);
}
