/* ==========================================================================
   Lincor Holdings: global stylesheet
   Plain CSS, no build step. Mobile-first; min-width media queries scale up.
   ========================================================================== */

/* --------------------------------------------------------------------------
   0a. Page transitions
   -------------------------------------------------------------------------- */
/* Moving between pages: the page being left is pushed back and dimmed while
   the page being opened rises up over it. Client reference, 20 Aug 2026.

   This is the browser's own cross-document view transition, not a script. The
   whole mechanism is the at-rule below plus two keyframes: on a same-origin
   navigation the browser snapshots the outgoing page, loads the incoming one,
   and animates between the two itself. No link interception, no overlay
   element, no fake page held in the DOM, and - the part a scripted version
   always gets wrong - the Back button, the Escape key, opening a link in a new
   tab and a mid-transition second click all keep working, because the browser
   is doing the navigating rather than being talked out of it.

   Both documents have to opt in, which is why this lives in the sitewide
   stylesheet rather than on any one page.

   Where it is not supported (Firefox, and Safari before 18.2) navigation is
   simply instant, exactly as it was before. Nothing is hidden waiting for a
   transition that will not happen, which is the property that makes this safe
   to ship: the transition is the enhancement, the navigation is the feature.

   The whole root is transitioned, header included, so the page moves as one
   object. Naming the header separately would hold it still, but its own
   styling differs from page to page - translucent navy over the homepage
   hero, solid white elsewhere - so holding it still would mean cross-fading a
   header in place mid-slide, which reads as a glitch rather than as chrome
   staying put. */
@view-transition {
  navigation: auto;
}

/* The outgoing page. Pushed back rather than faded out: it keeps most of its
   opacity to the end so the eye reads depth - one sheet behind another -
   rather than a dissolve. Same curve and a slightly shorter run than the
   incoming page, so the two read as one movement with the old sheet settling
   first rather than as two animations of different character. */
::view-transition-old(root) {
  animation: page-push-back 620ms cubic-bezier(0.45, 0, 0.25, 1) both;
}
/* The incoming page, rising from below over the old one.

   Eased at BOTH ends (client, 21 Aug 2026 - "so it slowly eases in when going
   up"). It was on a front-loaded "out" curve, which leaves at full speed from
   the very first frame: computed, that curve was 67% of the way up within the
   first fifth of its run, so the movement was already finishing before the eye
   had registered it starting - a snap followed by a long settle, not a rise.

   This curve is 8.9% up in that same first fifth, carries the distance through
   the middle, and spends its last fifth on the final 2.5% - sampled off the
   pseudo-element's own transform through a real navigation, not read off the
   curve: 18px of an 800px rise in the first 83ms. Deliberately not the
   slowest option tried: at cubic-bezier(0.62, 0, 0.24, 1) the first fifth
   covers under 5%, which at this duration is around 150ms where nothing
   appears to be happening after a click.

   Longer to pay for the two slow ends: keeping 620ms would compress the
   middle into a lurch. 780ms is where both ends are unhurried and the whole
   thing still reads as an answer to a click rather than a wait. */
::view-transition-new(root) {
  animation: page-rise 780ms cubic-bezier(0.45, 0, 0.25, 1) both;
}
@keyframes page-push-back {
  from { transform: none; opacity: 1; }
  to   { transform: scale(0.93) translateY(-1.5%); opacity: 0.6; }
}
@keyframes page-rise {
  from { transform: translateY(100%); }
  to   { transform: translateY(0); }
}
/* The new page has to be the one on top for the rise to cover anything. This
   is the default order, stated because the whole effect depends on it. */
::view-transition-image-pair(root) { isolation: auto; }
::view-transition-old(root),
::view-transition-new(root) { mix-blend-mode: normal; }

@media (prefers-reduced-motion: reduce) {
  /* No movement at all - not a faster slide. Navigation goes back to being
     instant, which is the correct answer here rather than a shortened version
     of a motion someone has asked not to see. */
  ::view-transition-old(root),
  ::view-transition-new(root) { animation: none; }
  @view-transition { navigation: none; }
}

/* --------------------------------------------------------------------------
   1. Tokens
   -------------------------------------------------------------------------- */
:root {
  /* Color */
  --white: #FFFFFF;
  --navy: #012340;
  --navy-soft: #0E3A5C;
  --slate: #4A535C;
  /* Was gold (#B9862B) sitewide - retired 24 Jul 2026 at client request in
     favor of a light grey from the site's existing --slate family. Kept
     the --gold name (touches ~15 rules across style.css; renaming would
     just be churn) but redefined its value. --gold is now solid, for
     anything that's actually read as text or is a button/hover background
     with text on top of it (needs real contrast); --gold-faint is the
     low-opacity version for purely decorative accents (thin underlines,
     border-left accents) where the client explicitly wanted the low-
     opacity look. (The Import & Export hero globe used to be a deliberate
     exception here, holding its own literal navy/gold country-highlight
     hexes rather than this token; it was removed 21 Aug 2026 and its CSS
     on 4 Sep 2026, so no exception remains.) */
  --gold: #9CA3AF;
  --gold-faint: rgba(156, 163, 175, 0.3);
  /* Sampled 23 Jul 2026 from the dot in MEZZOC_LOGO-01.png (PowerShell +
     System.Drawing) - distinct from --gold, used only for Mezzo's own CTA
     buttons so they match the wordmark's actual dot color exactly. */
  --mezzo-yellow: #FFC80C;
  --line: #DDE2E5;
  --bg-tint: #F4F6F5;
  /* Page ground, 14 Aug 2026 (client: Shoji White SW 7042). Replaces flat
     #FFFFFF as the colour the site sits on. --white is deliberately left
     alone at pure white - it is still what buttons, cards and text-on-dark
     use, and the small step between the two is what makes a white card or a
     white button read as sitting ON the page rather than being cut out of
     it. Change this token, not --white, to reshade the page. */
  --shell: #F5F4EE;

  /* Added 22 Jul 2026 - dark stat-panel component, see section 2.5a of build spec.
     --line-dark is the hairline-on-dark equivalent of --line; --grain is a
     subtle noise texture (inline SVG, no network request) used at low
     opacity so it reads as texture, not as a graphic in its own right.
     Fixed 23 Jul 2026: the svg had no width/height, so as a background-image
     it rasterized the feTurbulence filter at the full size of whatever
     section it was on (the tall "Our divisions" panel) - expensive to
     compute and, on many browsers, recomputed on scroll/compositing, which
     is what was making the homepage stutter over that section. Explicit
     128x128 intrinsic size + tiling (background-repeat, see .stat-panel
     ::before) makes the browser rasterize one small tile once and repeat
     it cheaply, instead of one giant noise field per paint. */
  --line-dark: rgba(255, 255, 255, 0.22);
  --grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='128' height='128'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");

  /* General Sans, sitewide (28 Jul 2026, client reference: benorth.studio,
     confirmed via their own @font-face rules to actually be "Aeonik" - a
     paid, self-hosted commercial font. General Sans (Fontshare, free/SIL)
     is a close, freely-usable visual match, loaded via <link> in every
     page's <head> - see index.html's own comment). Supersedes the old
     Neue Haas Grotesk TODO below: 'Neue Haas Grotesk Display' was never
     actually a loaded webfont anywhere on this site (no @font-face/link
     for it ever existed), so every h1-h3 had silently been rendering in
     plain Helvetica/Arial Bold via this same fallback stack the whole
     time - General Sans is now the real primary font, this stack is
     purely the fallback if its CDN fails to load. If a licensed Aeonik
     kit is purchased later, swap it in ahead of 'General Sans' here rather
     than replacing it outright, so the fallback chain still holds. */
  --f-display: 'General Sans', 'Neue Haas Grotesk Display', 'Neue Haas Grotesk Text', 'Helvetica Neue', Helvetica, Arial, sans-serif;
  --f-body: 'General Sans', 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
  --f-mono: 'IBM Plex Mono', 'Courier New', monospace;
  /* The hero's three-state type (20 Aug 2026). Stands in for Gotham Black /
     Gotham Book, which the brief asked for but which cannot be embedded - see
     the note on the font link in index.html. Only the homepage loads it. */
  --f-hero: 'Montserrat', 'General Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
  /* Added 22 Jul 2026 - editorial accent face for dark stat panels only; see section 2.3/2.5a */
  --f-editorial: 'Instrument Serif', Georgia, 'Times New Roman', serif;

  /* Spacing scale (8px base): use these values only */
  --sp-1: 8px;
  --sp-2: 16px;
  --sp-3: 24px;
  --sp-4: 32px;
  --sp-5: 48px;
  --sp-6: 64px;
  --sp-7: 96px;
  --sp-8: 128px;

  /* Layout */
  --gutter: clamp(20px, 5vw, 64px);
  /* Updated 22 Jul 2026: was 2px ("near-square, architectural precision").
     Client feedback: current site reads outdated; reference imagery calls
     for softer, rounded cards. Cascades to every button/card/input/image
     that already references this token. */
  --radius: 16px;
  --section-pad: var(--sp-6);

  /* Motion - updated 22 Jul 2026: plain "ease" reads generic/dated on every
     hover and reveal transition sitewide (everything already references
     these two tokens, so this one change cascades everywhere). Standard
     uses a quick material-style ease-out for hovers/borders/small state
     changes; reveal uses an ease-out-expo curve (snap-then-settle) for the
     slower scroll/load reveals - see the .reveal-on-load system below. */
  --ease-standard: 180ms cubic-bezier(0.4, 0, 0.2, 1);
  --ease-reveal: 700ms cubic-bezier(0.16, 1, 0.3, 1);
  /* Added 23 Jul 2026: the division-card hover expand (flex-grow + reveal
     max-height) was riding --ease-standard's 180ms, quick enough that the
     layout reflow it triggers reads as a jerky snap rather than a smooth
     grow. Slower duration + gentle ease-out settles it instead. */
  --ease-card: 550ms cubic-bezier(0.22, 1, 0.36, 1);
}

@media (min-width: 860px) {
  :root {
    --section-pad: var(--sp-7);
  }
}

/* --------------------------------------------------------------------------
   2. Reset / base
   -------------------------------------------------------------------------- */
*, *::before, *::after { box-sizing: border-box; }

/* scrollbar-gutter:stable (27 Jul 2026): reserves the vertical scrollbar's
   space permanently, whether or not a scrollbar is actually showing at any
   given moment. Traced a ~500ms "What we do" card layout glitch (cards
   transiently collapsing to label-only height, ~1.1s after load, gone under
   prefers-reduced-motion) to GSAP ScrollTrigger's own built-in behavior of
   auto-refreshing all trigger positions once, shortly after every page
   resource finishes loading - this page's pinned "Our Divisions" scroller
   (see .division-scroller in main.js) briefly recalculates its total
   document height during that refresh, and without a permanently-reserved
   gutter, a momentary scrollbar appear/disappear during that recalculation
   changes the viewport's available width by the scrollbar's own width for
   an instant - just enough, at these cards' narrow widths, to re-wrap their
   description text into fewer lines and back. Not exclusive to this one
   component: any narrow, text-wrap-sensitive layout elsewhere on the page
   is exposed to the same scrollbar-width flicker, so this is fixed at its
   root (the viewport itself) rather than by insulating every affected
   component individually. */
html {
  -webkit-text-size-adjust: 100%;
  scrollbar-gutter: stable;
}

body {
  margin: 0;
  background: var(--shell);
  color: var(--navy);
  font-family: var(--f-body);
  font-weight: 400;
  font-size: 1rem;
  line-height: 1.6;
  overflow-x: hidden;
}

/* height: auto is what makes the width/height attributes safe.

   Every <img> on the site now carries its intrinsic pixel size (20 Aug 2026),
   so the browser can reserve the right box before the file arrives instead of
   reflowing the page when it lands - 72 of the 122 images had no size at all,
   and the document height was visibly jumping as they loaded: on
   divisions/import-export.html it went 4574 -> 2974 -> 4331 during the first
   1.4 seconds.

   But an <img> with both attributes and `max-width: 100%` and no height rule
   uses the ATTRIBUTE height once max-width shrinks the width, which squashes
   the picture. height: auto turns the pair back into an aspect ratio, which is
   all they are wanted for here. Any component that genuinely sets its own
   height - .dstack__media, .footer-bg, .bcard__logo - is more specific than
   this and still wins. */
img { max-width: 100%; height: auto; display: block; }

a { color: inherit; }

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

h1, h2, h3, h4, p, figure { margin: 0; }

button {
  font-family: inherit;
  cursor: pointer;
  background: none;
  border: none;
  color: inherit;
}

input, textarea, select {
  font-family: inherit;
  font-size: 1rem;
  color: inherit;
}

/* Visible keyboard focus everywhere: do not rely on the browser default */
:focus-visible {
  outline: 2px solid var(--navy);
  outline-offset: 3px;
}

/* --------------------------------------------------------------------------
   3. Typography
   -------------------------------------------------------------------------- */
h1, h2, h3, .h-display {
  font-family: var(--f-display);
  font-weight: 600;
  color: var(--navy);
  line-height: 1.1;
}
h1, .h1 {
  font-size: clamp(2.2rem, 6vw, 4.2rem);
  line-height: 1.08;
  letter-spacing: -0.01em;
  font-weight: 700;
  max-width: 18ch;
}

h2, .h2 {
  font-size: clamp(1.6rem, 3.2vw, 2.3rem);
}

h3, .h3 {
  font-size: 1.4rem;
}

p { color: var(--slate); }

p.lede {
  font-size: clamp(1.05rem, 1.6vw, 1.25rem);
  color: var(--slate);
  max-width: 60ch;
}

.mono-label {
  font-family: var(--f-mono);
  font-size: 0.75rem;
  font-weight: 500;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--slate);
}


/* Index tag - numbered mono eyebrow ("/ 01 /"), see section 2.5a of build spec */
.index-tag {
  display: block;
  font-family: var(--f-mono);
  font-size: 0.75rem;
  font-weight: 500;
  letter-spacing: 0.1em;
  color: var(--gold);
  margin-bottom: var(--sp-1);
}

/* --------------------------------------------------------------------------
   4. Layout helpers
   -------------------------------------------------------------------------- */
/* Updated 23 Jul 2026: was centered (max-width: 1280px + margin-inline:
   auto), so on any screen wider than that every section's content sat
   well in from the true left edge of the window - inconsistent with the
   hero,
   which had already been pulled flush left, and with everything else once
   more sections got the same treatment (see request history). Hugging the
   left edge (just the gutter, no max-width/centering) now applies
   everywhere - header logo, hero, About Us, etc. - so the whole page reads
   as one consistent left edge instead of some sections centered and
   others not. Grid-based sections (division cards, footer columns, the
   logo marquee) just get more breathing room on very wide screens instead
   of stretching all the way - none of them break at that width. */
.container {
  padding-inline: var(--gutter);
}

section {
  padding-block: var(--section-pad);
}

.section-head {
  margin-bottom: var(--sp-5);
  max-width: 68ch;
}

.section-head h2 { margin-bottom: var(--sp-2); }

/* --------------------------------------------------------------------------
   5. Header
   -------------------------------------------------------------------------- */
.site-header {
  position: sticky;
  top: 0;
  z-index: 100;
  background: var(--white);
  transition: transform var(--ease-standard);
}
/* Hide-on-scroll-down / reveal-on-scroll-up header (28 Jul 2026, client
   request modeled on benorth.studio): visible on load and whenever
   scrolled back up toward the top, slides out of view the moment you
   scroll down past it, instead of staying pinned in view the whole
   time - see the scroll listener in main.js for the direction/threshold
   logic. Site-wide, every page - not scoped to the home hero. Plain
   translateY rather than touching `position` keeps .site-header's own
   sticky behavior (and the pin-spacer math other pinned sections rely on
   elsewhere on the page) untouched; this only ever slides the already-
   sticky box off-screen and back. */
.site-header.is-hidden {
  transform: translateY(-100%);
}
@media (prefers-reduced-motion: reduce) {
  .site-header {
    transition: none;
  }
}
/* Home hero video extends up behind the header (27 Jul 2026, client
   request: remove the solid white bar above the video, let it reach the
   very top of the page, keep every nav item visible). Scoped to the
   homepage only (:has(.hero--photo), the video hero) - every other page's
   header sits over a plain white section and still needs its normal
   background. Transparent for the entire scroll depth of the page, not
   just over the hero (client preference, confirmed 28 Jul 2026) - a plain
   flat `background: transparent` here made that genuinely broken further
   down though: division-slide headings visibly overlapped the logo, and
   the white nav text went illegible over Our Brands' light background.
   A navy scrim behind the header fixes the legibility without giving up
   the see-through look the client wants past the hero - translucent
   enough that white nav/logo/text stay readable against literally
   anything scrolling underneath. backdrop-filter: blur (client reference,
   28 Jul 2026: benorth.studio's frosted-glass nav) rather than a flat
   tinted background - blurs whatever's actually behind the header into an
   indistinct wash instead of just darkening it, which is what keeps a
   heading's own text from reading as a separate, competing layer of
   text through the header (the original overlap problem) even at a
   lighter, more translucent tint than the flat scrim needed. The
   hairline border-bottom is the same reference's thin underline, and
   doubles as a legibility floor of its own on the rare backdrop the blur
   alone doesn't fully flatten. Text-shadow on the nav/logo is a third,
   per-glyph layer of insurance for the same overlap case.

   That scrim is a visible dark stripe of its own when it's sitting over
   the hero image, though (client feedback, 28 Jul 2026: "not what I
   want") - the hero already carries its own dark scrim (see .hero--photo)
   that made the ORIGINAL flat `transparent` header blend in seamlessly,
   and stacking a second, differently-shaped scrim on top of that broke
   the blend. .is-over-hero (added/removed by the IntersectionObserver in
   main.js while .hero--photo is on screen) lets the header go back to
   plain transparent - pixel-identical to the original - for exactly the
   region where the hero's own scrim already has legibility covered, and
   only switches on the header's own scrim once scrolled past it, where
   nothing else is doing that job. */
body:has(.hero-lock) .site-header {
  background: rgba(1, 35, 64, 0.55);
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
  border-bottom: 1px solid rgba(255, 255, 255, 0.14);
}
body:has(.hero-lock) .site-header .logo img {
  filter: brightness(0) invert(1) drop-shadow(0 1px 3px rgba(0, 0, 0, 0.5));
}
/* Still over the hero: back to the original plain-transparent look, no
   blur, no border, no shadows - the hero's own scrim already handles
   legibility here, so adding ours on top only doubled up as a visible
   seam. */
body:has(.hero-lock) .site-header.is-over-hero {
  background: transparent;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  border-bottom-color: transparent;
}
body:has(.hero-lock) .site-header.is-over-hero .logo img {
  filter: brightness(0) invert(1);
}
/* Header Contact button, navy -> white (27 Jul 2026, client request) - was
   fine as solid navy (legible regardless of what's behind it), but reads
   better matching the white nav text/logo now that the header sits over
   the video instead of a white bar. Hover goes to --bg-tint, same pattern
   .hero--photo .btn-primary already uses for the same navy-on-white swap.
   Kept as a solid opaque button rather than joining the scrim/text-shadow
   treatment above - it never had the overlap problem the plain text/logo
   did, since its own background already fully covers anything behind it. */
body:has(.hero-lock) .site-header .btn-primary {
  background: var(--white);
  color: var(--navy);
}
body:has(.hero-lock) .site-header .btn-primary:hover,
body:has(.hero-lock) .site-header .btn-primary:focus-visible {
  background: var(--bg-tint);
}
/* Smooths the .is-over-hero on/off switch (scrim fading in/out, shadows
   appearing/disappearing) into a brief fade instead of an instant snap
   right as it happens mid-scroll. .site-header here needs `transform`
   carried over too (not just background) - this selector is more
   specific than the plain `.site-header { transition: transform ... }`
   base rule, so on the homepage it replaces that rule's `transition`
   outright rather than adding to it; leaving transform out here would
   silently kill the hide-on-scroll slide animation on the homepage only. */
body:has(.hero-lock) .site-header,
body:has(.hero-lock) .site-header .logo img {
  transition: background var(--ease-standard), backdrop-filter var(--ease-standard), border-color var(--ease-standard), text-shadow var(--ease-standard), filter var(--ease-standard), box-shadow var(--ease-standard), transform var(--ease-standard);
}

/* The Menu button gets the same list MINUS transform (20 Aug 2026). transform is
   in the list above because .site-header needs it for the hide-on-scroll slide;
   the button needs no transform transition at all, and having one meant its
   entrance was being animated by two systems at once - GSAP writing a transform
   every frame while a 180ms CSS transition chased each value. The result was a
   soft, laggy entrance that never quite matched its easing curve. One owner per
   property. */
body:has(.hero-lock) .menu-btn {
  transition: background var(--ease-standard), backdrop-filter var(--ease-standard), border-color var(--ease-standard), text-shadow var(--ease-standard), filter var(--ease-standard), box-shadow var(--ease-standard);
}

.site-header .container {
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: 88px;
}

.site-header .logo {
  display: flex;
  align-items: center;
}

.site-header .logo img {
  height: 32px;
  width: auto;
}

/* --------------------------------------------------------------------------
   5a. Menu button + nav page (19 Aug 2026)

   Replaces the horizontal .nav-links row, the .nav-toggle hamburger and the
   .nav-panel dropdown, all three of which are gone from the markup. The site's
   navigation is now one button that opens one full-screen page.

   Two custom properties do the light/dark work, so neither the button nor the
   expanding wave needs to know which page it is on:
     --menu-ink   the button's border and label colour
     --wave-ink   the colour of the ring that expands out of it
   They are set once here for the ordinary white header, and overridden in the
   .hero-lock block above for the homepage, where the header floats over dark
   video and everything has to invert.
   -------------------------------------------------------------------------- */
:root {
  --menu-ink: var(--navy);
  --wave-ink: rgba(1, 35, 64, 0.55);
}

.menu-btn {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: 40px;
  padding: 0 26px;
  /* Pinned so the control cannot change size when the webfont arrives. Measured
     during the audit (20 Aug 2026): the label is 94px wide in the fallback face
     and 95px in General Sans, and because the header is justify-content:
     space-between that 1px lands as a half-pixel shift of the button's left
     edge as the font swaps. Too small for the layout-shift API to score, but the
     brief asks for a bar that is completely stable once it has entered, and a
     control that quietly resizes on font load is not that. Comfortably clears
     "Menu" at 500 in either face. */
  min-width: 96px;
  border: 1px solid var(--menu-ink);
  border-radius: 999px;
  background: transparent;
  color: var(--menu-ink);
  font-family: var(--f-display);
  font-size: 0.95rem;
  font-weight: 500;
  letter-spacing: 0.01em;
  cursor: pointer;
  /* Clips the second label out of sight until it rolls up. */
  overflow: hidden;
  /* Entrance: see .site-header .logo, .menu-btn below - the logo and this
     button move together as one navigation system. */
  animation: nav-enter 620ms cubic-bezier(0.22, 1, 0.36, 1) var(--nav-enter-delay, 260ms) both;
}

/* The navigation entrance, and it is deliberately ONE rule covering both halves
   of the bar (20 Aug 2026, client audit).

   It replaces a keyframe that moved the Menu button alone, from
   translateY(-180%) - about 72px - while the logo did not move at all and the
   header carried its own separate 12px slide. Measured over a page load, the
   logo travelled 12px and the button 60-72px, on different curves, at different
   times. They read as two unrelated components loading, which is exactly what
   the audit was called to fix.

   Now both do the same small thing: fade up over 14px, on the same curve, with
   the logo 100ms ahead of the button so the bar assembles left-to-right rather
   than arriving as one slab. That stagger is the only difference between them.

   Only opacity and transform are touched, so nothing here can move the page.
   The header is position: fixed on the homepage and sticky elsewhere, and its
   box is at its final size and offset from the first frame - the entrance
   cannot change layout, and measurably does not: the header, logo and button
   account for 0.000 of the page's cumulative layout shift.

   A CSS animation rather than something JS adds, for one specific reason: if it
   were JS-driven these would have to start hidden, and a failed script would
   leave the site with no visible way into its own navigation. Declared this way
   the resting state IS visible - the animation only plays if animations run at
   all. `both` holds the offset frame through the delay so nothing flashes in
   place first. js/home.js takes over the timing on the homepage, where the
   entrance has to wait for the intro. */
.site-header .logo {
  animation: nav-enter 620ms cubic-bezier(0.22, 1, 0.36, 1) var(--nav-enter-delay, 160ms) both;
}

@keyframes nav-enter {
  from { opacity: 0; transform: translateY(14px); }
  to   { opacity: 1; transform: translateY(0); }
}

/* The label roll. .menu-btn__roll is one line tall with overflow hidden; the
   two identical words are stacked inside it, so only the first is ever in
   view. Moving the pair up by exactly one word-height rolls the first out of
   the top as the second arrives from below - the label reads as having moved
   rather than changed. Both words shift together as one block, which is why
   the transform goes on the wrapper and not on the words themselves. */
.menu-btn__roll {
  display: block;
  height: 1.25em;
  overflow: hidden;
}

.menu-btn__roll > .menu-btn__word {
  display: block;
  height: 1.25em;
  line-height: 1.25em;
  transition: transform 460ms cubic-bezier(0.16, 1, 0.3, 1);
}

.menu-btn:hover .menu-btn__word,
.menu-btn:focus-visible .menu-btn__word {
  transform: translateY(-1.25em);
}

.menu-btn:focus-visible {
  outline: 2px solid var(--menu-ink);
  outline-offset: 3px;
}

/* Homepage only: the header sits over dark video, so the pill inverts. Kept in
   step with the .logo img / nav treatment in section 5 above - same trigger,
   same reason. */
body:has(.hero-lock) {
  --menu-ink: var(--white);
  --wave-ink: rgba(255, 255, 255, 0.85);
}

/* ---- The nav page ---------------------------------------------------------

   Hidden by the .navpage-js class, which the inline script in every page's
   <head> adds before first paint. The hiding is deliberately conditional on
   JS: with no JS the class is never added, so this whole block does not
   apply, and the nav page stays what it is in the markup - an ordinary
   section at the foot of the document, with all six links usable. That is the
   fallback for the site's only navigation, so it cannot depend on a CDN.
   -------------------------------------------------------------------------- */
.navpage-js .navpage {
  position: fixed;
  inset: 0;
  z-index: 200;            /* over .site-header, which is 100 */
  visibility: hidden;
  pointer-events: none;
}

/* Open. The linen ground is handed over from the wave to the panel itself the
   moment the expansion finishes (see js/nav.js): the wave is a fixed-size
   ellipse and would stop covering the screen if the window were resized while
   the menu was open, so it is never what holds the background for longer than
   the animation. */
.navpage-js .navpage.is-open {
  visibility: visible;
  pointer-events: auto;
  overflow-y: auto;
}

.navpage-js .navpage.is-filled {
  background: var(--shell);
}

/* The header is NOT touched while the wave is travelling.

   It was, at first: opacity 0 the moment .is-open landed. That was the one
   discontinuity in the whole transition, and the worst possible place for one -
   the logo and the button the visitor had just clicked blinked out in a single
   frame, because .site-header's transition list carries transform and
   background but never opacity. What you felt was the page being taken away
   and then something else arriving, rather than one layer closing over
   another.

   Nothing needs to hide it. .navpage is z-index 200 and the header is 100, so
   the wave paints straight over it: the page the visitor was on - header, hero,
   all of it - genuinely stays on screen underneath the moving edge and is
   covered by it. Measured, the far corner of the old page is still visible for
   about 540ms after the click.

   It is only taken out once the wave has finished covering it, on .is-filled,
   and then with visibility rather than opacity - an opacity: 0 header is still
   in the tab order, so a keyboard visitor could tab into a logo and a Menu
   button sitting invisible behind the panel. visibility: hidden removes it from
   both the paint and the tab order.

   On the way out the class comes off early in the close timeline, so the header
   is back before the wave has finished contracting and is uncovered by it -
   the same continuity, in reverse. */
.navpage-js body:has(.navpage.is-filled) .site-header {
  visibility: hidden;
}

/* The expansion. A single bordered box grown from the Menu button: 1px border,
   border-radius 50%, so at any size it is a true ellipse with a crisp edge -
   the button's own outline, opening out. width/height are animated rather than
   transform: scale() because scaling would thicken that 1px border into a fat
   ring as it grew. It has no children and its parent is fixed, so there is
   nothing for the size change to reflow.

   left/top are set by JS to the button's centre and translate(-50%, -50%)
   keeps it centred there, so it opens from the corner the button sits in. */
.navpage__wave {
  position: fixed;
  left: 0;
  top: 0;
  width: 0;
  height: 0;
  transform: translate(-50%, -50%);
  border: 1px solid var(--wave-ink);
  border-radius: 50%;
  background: transparent;
  pointer-events: none;
  will-change: width, height;
}

.navpage__inner {
  position: relative;
  z-index: 1;
  min-height: 100%;
  display: flex;
  flex-direction: column;
  /* Proportional to the viewport's HEIGHT, not a fixed step. This column has
     to fit a back button, a photograph, six links and a footer inside one
     phone screen, and a 48px gap between them is a sixth of that screen on a
     small one. */
  gap: clamp(16px, 2.6vh, 40px);
  padding: var(--gutter);
  padding-block: clamp(20px, 4vh, 40px) clamp(24px, 5vh, 48px);
}

/* Back arrow, top left, small (client brief). */
.navpage__back {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  align-self: flex-start;
  padding: 6px 4px;
  border: 0;
  background: transparent;
  color: var(--navy);
  font-family: var(--f-mono);
  font-size: 0.7rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  cursor: pointer;
}

.navpage__back svg {
  width: 18px;
  height: 18px;
  transition: transform var(--ease-standard);
}

.navpage__back:hover svg,
.navpage__back:focus-visible svg {
  transform: translateX(-4px);
}

.navpage__back-label { line-height: 1; }

/* Image above list on a phone, image left of list from 900px up.

   Rebuilt 21 Aug 2026: on a phone this opened onto a blank screen. The block
   was a grid with align-content: center inside a column stretched to the
   panel's full height, so its rows were centred in whatever space was left -
   which put a screen's worth of nothing above the first link, dropped the
   photograph below the list, and left the email marooned a second screen
   further down. Measured on a 360x640 phone: the panel was 856px of scrolling
   for 640px of screen, and what you landed on was empty.

   It is a flex column here instead, with no centring and the photograph
   absorbing whatever room is left over (see .navpage__media below). Nothing
   can be centred in slack space, because there is no slack space - the image
   takes it. The order is the one the client asked for: image, pages, email. */
.navpage__grid {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  gap: clamp(16px, 2.6vh, 40px);
}

.navpage__media {
  order: 0;
  /* The slack-absorber. flex-basis 0 with grow means it does not get a say in
     how tall the column wants to be - it takes what the list and the footer
     leave, between a floor that keeps it a photograph and a ceiling that stops
     it crowding them out. */
  flex: 1 1 0;
  min-height: 120px;
  max-height: 38vh;
  position: relative;
  overflow: hidden;
  border-radius: var(--radius);
  /* The reveal, same technique as .about-visual on the homepage: a clip-path
     inset on the wrapper, so the photograph itself never moves and the grid
     cannot reflow while it plays. js/nav.js animates --reveal to 0; declaring
     the finished value here means no-GSAP and reduced-motion visitors get the
     open state rather than a permanently shrunken image. */
  --reveal: 0%;
  clip-path: inset(var(--reveal) round var(--radius));
}

/* The wrapper owns the height now, so the photograph simply fills it. The old
   max-height on the image was what made the wrapper follow the picture instead
   of the space available; object-fit: cover means a 3:4 portrait crops to
   whatever shape the wrapper ends up being rather than dictating it. */
.navpage__media img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* Beats the sitewide `img { height: auto }`, which would otherwise let the
     picture's own proportions set the wrapper's height again. */
  min-height: 0;
}
.navpage__nav { order: 1; flex: 0 0 auto; }

/* The list. Numbers are --f-mono and small in the grey from the site's own
   accent family; names are display type, uppercase, navy. Hairline rules
   between rows, and one above the first, so the list reads as a table of
   contents rather than a stack of buttons. */
.navlist {
  list-style: none;
  margin: 0;
  padding: 0;
  border-top: 1px solid var(--line);
}

.navlist__item { border-bottom: 1px solid var(--line); }

.navlist__link {
  display: flex;
  align-items: baseline;
  gap: clamp(14px, 2vw, 28px);
  padding-block: clamp(12px, 1.8vh, 20px);
  text-decoration: none;
  color: var(--navy);
}

.navlist__num {
  flex: 0 0 auto;
  font-family: var(--f-mono);
  font-size: 0.7rem;
  letter-spacing: 0.1em;
  color: var(--gold);
  transition: color var(--ease-standard);
}

.navlist__name {
  font-family: var(--f-display);
  font-weight: 600;
  font-size: clamp(1.6rem, 4.2vw, 3rem);
  line-height: 1.02;
  letter-spacing: -0.02em;
  text-transform: uppercase;
  transition: transform 320ms cubic-bezier(0.16, 1, 0.3, 1);
}

/* Hover nudges the name toward its destination and brings the number up to
   full strength. No colour change on the name itself: it is already navy on
   linen, and darkening it further only makes the row look pressed. */
.navlist__link:hover .navlist__name,
.navlist__link:focus-visible .navlist__name {
  transform: translateX(clamp(6px, 1vw, 14px));
}

.navlist__link:hover .navlist__num,
.navlist__link:focus-visible .navlist__num {
  color: var(--navy);
}

/* The page you are already on. Marked by the number going solid rather than by
   dimming the name - a disabled-looking row in a six-item list reads as
   broken. */
.navlist__link[aria-current="page"] .navlist__num { color: var(--navy); }

/* Email at the foot of the page, logo bottom right (client brief; the
   reference puts both in the same two places). */
.navpage__foot {
  /* Last in the column and never given any of the leftover space - the
     photograph above takes all of it, which is what keeps the email sitting
     directly under the list rather than a screen below it. */
  flex: 0 0 auto;
  display: flex;
  /* Stacked and left-aligned on a narrow screen (client, 21 Aug 2026): the
     email and the lockup are one block in the bottom-left corner, not two
     things pushed to opposite edges. The email is monospace and cannot shrink,
     so a row would have wrapped here anyway - and wrapping left the lockup
     alone on a second line still pinned to the right, which read as a stray
     rather than as a pair. Stating the column makes that deliberate. */
  flex-direction: column;
  align-items: flex-start;
  gap: var(--sp-2);
}

.navpage__email {
  font-family: var(--f-mono);
  font-size: clamp(0.75rem, 1.4vw, 0.9rem);
  /* One step up from the 400 it inherited (20 Aug 2026, client request). 500 is
     the heaviest IBM Plex Mono weight the page actually loads - see the Google
     Fonts link in every <head>, which requests wght@400;500 - so this is a real
     weight rather than a browser-synthesised fake bold. Anything heavier would
     need the font request widened first. */
  font-weight: 500;
  letter-spacing: 0.02em;
  color: var(--navy);
  text-decoration: none;
  border-bottom: 1px solid var(--gold-faint);
  padding-bottom: 2px;
  transition: border-color var(--ease-standard);
}

.navpage__email:hover,
.navpage__email:focus-visible { border-bottom-color: var(--navy); }

.navpage__logo {
  height: 28px;
  width: auto;
}

/* Short viewports - a landscape phone, or a small one - lose the photograph.

   Six links at a usable tap size plus a footer is already more than 390px of
   height, and the picture is decorative: it carries nothing the list beside it
   does not already say, which is why its alt is empty. Shrinking the links to
   fit it in would take the rows under the ~44px a thumb needs, so the
   photograph goes and the navigation keeps its size. Above this height it is
   back, and it is the element that absorbs the spare room. */
@media (max-height: 620px) and (max-width: 899px) {
  .navpage__media { display: none; }
}

@media (min-width: 900px) {
  .navpage__grid {
    /* Image gets the smaller share: it is a portrait, and six uppercase
       division names need the width more than the photograph does. */
    display: grid;
    grid-template-columns: minmax(240px, 30%) 1fr;
    grid-auto-flow: column;
    gap: clamp(var(--sp-5), 6vw, var(--sp-7));
    align-items: center;
  }
  /* There is room for both here, so the photograph goes back to being sized by
     its own proportions rather than by what is left over - which is also what
     lets the two columns be vertically centred against each other. */
  .navpage__media {
    flex: none;
    min-height: 0;
    max-height: none;
  }
  .navpage__media img {
    height: auto;
    max-height: min(62vh, 620px);
  }
  .navpage__inner { padding-inline: clamp(var(--gutter), 6vw, 110px); }
  /* Wide enough for both to sit in their own corner on one line, which is the
     brief for this size - email bottom left, lockup bottom right. */
  .navpage__foot {
    flex-direction: row;
    align-items: flex-end;
    justify-content: space-between;
    gap: var(--sp-4);
  }
  .navpage__logo { margin-left: auto; }
}

/* Reduced motion: everything still opens, nothing travels. The wave is not
   used at all in this mode (js/nav.js skips it), the image is already at its
   finished clip, and the button simply appears. */
@media (prefers-reduced-motion: reduce) {
  .site-header .logo,
  .menu-btn { animation: none; }
  .menu-btn__roll > .menu-btn__word,
  .navlist__name,
  .navpage__back svg { transition: none; }
}

/* --------------------------------------------------------------------------
   6. Buttons: two variants only
   -------------------------------------------------------------------------- */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--sp-1);
  padding: 14px 28px;
  border-radius: var(--radius);
  font-family: var(--f-body);
  font-weight: 600;
  font-size: 0.95rem;
  text-decoration: none;
  border: 1px solid transparent;
  transition: background-color var(--ease-standard), color var(--ease-standard), border-color var(--ease-standard), transform var(--ease-standard);
}

.btn:hover,
.btn:focus-visible {
  transform: translateY(-2px);
}

.btn-primary {
  background: var(--navy);
  color: var(--white);
}
.btn-primary:hover,
.btn-primary:focus-visible {
  background: var(--navy-soft);
}

.btn-outline {
  background: transparent;
  color: var(--navy);
  border-color: var(--navy);
}
.btn-outline:hover,
.btn-outline:focus-visible {
  background: var(--navy);
  color: var(--white);
}


.cta-group {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2);
}

/* Page-load reveal: once, respects reduced motion.

   The hidden state is behind @media (scripting: enabled) because something has
   to be able to UNDO it, and only script can. Measured 6 Sep 2026 with
   JavaScript switched off: divisions/app-development.html, property.html and
   manufacture.html each served their <h1>, their lede and their primary CTA at
   opacity 0 - "Owlbox", "Property" and "Manufacturing" were invisible, on
   pages whose only heading that is. js/main.js adds .is-revealed on every other
   path, including no-GSAP and reduced-motion, but with no JS at all nothing
   runs and opacity: 0 is simply where the copy stays. The rest of the site
   guards this properly - see .navpage-js and html.mz-motion, both set by inline
   <head> scripts for exactly this reason.

   This note used to claim .reveal-on-load was the SOLE hidden state on the
   site with no way back. That was wrong, and why it was wrong is worth
   keeping: the search behind it only looked for opacity. The clip-path wipes
   on .crew__cell/.crew__bg and .dirs__cell/.darkrun__bg had the same fault,
   and were found a day later by eye, on a band that had just been filled with
   real portraits. Both are now guarded the same way. If another hidden state
   is ever added, the question is not "is it opacity" but "what removes it,
   and does that thing run without JavaScript".

   scripting: enabled fails in the safe direction too. A browser that does not
   know the feature drops this block, so the copy is visible from the first
   frame and the reveal is a no-op fade - the animation still runs where it is
   supported, and where it is not, the words are simply already there. */
@media (scripting: enabled) {
  .reveal-on-load {
    opacity: 0;
    transform: translateY(12px);
  }
}
.reveal-on-load.is-revealed {
  opacity: 1;
  transform: translateY(0);
  transition: opacity var(--ease-reveal), transform var(--ease-reveal);
}
/* No .is-revealed transform counterpart here on purpose - fixed 23 Jul 2026:
   this used to set transform: translateY(0) scale(1), and at 3 classes deep
   it outranked .division-card:hover's own transform (scale(1.045)), so
   hover's spotlight scale silently never won once a card had scrolled into
   view. Worse, this rule's 700ms reveal transition and .division-card:hover's
   transition were both fighting over the same `transform` property, and a
   scroll that moves the grid under a resting cursor re-triggers :hover
   constantly - restarting that fight every frame, which is what was
   freezing scroll over "Our divisions". main.js now animates this card's
   reveal transform via GSAP instead (see .division-card handling in
   main.js) and clears its own inline transform when done, so CSS - and
   only :hover - ever owns this property again. Fallback if GSAP fails to
   load: main.js falls back to the plain .reveal-on-load fade (no scale-in),
   which doesn't have this conflict. */

@media (prefers-reduced-motion: reduce) {
  .reveal-on-load,
  .hero-mark.reveal-on-load,
  .division-card.reveal-on-load {
    opacity: 1;
    transform: none;
    transition: none;
  }
  * {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
  }
}

/* --------------------------------------------------------------------------
   8. Hero
   -------------------------------------------------------------------------- */
.hero {
  padding-top: var(--sp-6);
}
/* Division-page heroes (design.html, import-export.html, etc.) lead with an
   index-tag + mono-label pair ("/ 01 /" / "DIVISION") - added 23 Jul 2026:
   shifted up (less top padding than the default hero) and sized down
   slightly, so that eyebrow reads as a smaller, tighter mark rather than
   competing with the wordmark below it for attention. Scoped via :has() so
   the home hero (no index-tag) is untouched. */
.hero:has(.hero-copy .index-tag) {
  padding-top: var(--sp-4);
}
.hero-copy .index-tag,
.hero-copy .mono-label {
  font-size: 0.68rem;
}
.hero {
  /* Added 22 Jul 2026: default section bottom padding (96px desktop) stacked
     with the About section's own default top padding was leaving a ~190px
     void between the hero pills and "About Us" - tightened here instead. */
  padding-bottom: var(--sp-4);
}
.hero .container {
  display: grid;
  gap: var(--sp-3);
}
.hero-copy h1 { margin-bottom: var(--sp-3); }
.hero-copy .lede { margin-bottom: var(--sp-4); }


/* The Three.js drag-to-rotate globe that used to sit in the right half of the
   Import & Export hero was removed with that hero (21 Aug 2026 - see the
   comment above <section class="hero-poster--trade"> in
   divisions/import-export.html). Its CSS went with it on 4 Sep 2026, along
   with js/trade-globe.js, js/trade-effects.js and js/data/world-countries.js:
   no page had loaded any of the three since August, so 277 KB of JavaScript
   and this block of rules were still being deployed for an element that no
   longer exists in any markup. `git show 3de90325` has all of it if the globe
   is ever wanted back. */

/* Every hero, sitewide - reworked 23 Jul 2026, replacing the home-only
   centered logo-forward layout added 22 Jul 2026: client didn't want
   content centered on the page, wanted it left-aligned, and wanted the
   hero to fill the screen on every division page (not just Home) so the
   next section doesn't peek in above the fold on load. min-height
   subtracts the sticky header's own fixed 88px height (see .site-header
   .container) so the hero exactly fills the rest of the viewport.
   flex-direction: column keeps every other hero (a single .hero-copy
   child) stacking normally. */
.hero {
  min-height: calc(100svh - 88px);
  display: flex;
  flex-direction: column;
  justify-content: center;
}


/* --------------------------------------------------------------------------
   9a. Division scroller (pinned full-viewport slides)
   -------------------------------------------------------------------------- */
/* Replaces the "Our divisions" card grid below (section 10) on Home, added
   24 Jul 2026. Client-requested pinned scroll sequence: slide 01 (Mezzo)
   fills the viewport, and scrolling wipes 02, 03, 04, 05 up over it in turn.
   This markup/CSS IS the fallback: by default every .division-slide is just
   a normal, full-viewport, non-pinned section stacked in document order -
   exactly what mobile/touch devices, prefers-reduced-motion, and a failed
   GSAP/ScrollTrigger CDN load all get (see js/main.js's gsap.matchMedia()
   call - scoped to `(hover: hover) and (pointer: fine) and (min-width:
   900px)`, i.e. real desktop mouse users only). JS only *adds* the
   absolute-stack-and-pin behavior on top of this for that one case; nothing
   here depends on JS to be visible or usable. This is a deliberate exception
   to the build spec's "no scroll-jacking" rule (§2.6) on desktop only -
   see that section for the full note. */
.division-scroller {
  position: relative;
  /* Overrides the generic `section { padding-block: var(--section-pad) }`
     rule (~96px at this width) - found via headless Playwright inspection
     (24 Jul 2026) to be the actual root cause of the pin/content offset
     bug: that padding pushes .division-scroller-pin down inside this
     section's box, and GSAP's pin preserves that same offset once pinned -
     so the pinned slide (and its bottom-anchored content/button) rendered
     ~96px lower than intended, clipped past the viewport's bottom edge.
     This component needs to be edge-to-edge in both the pinned and static-
     fallback stack, so it gets none of the sitewide section padding. */
  padding-block: 0;
}
.division-scroller-pin {
  overflow: hidden;
}
.division-slide {
  position: relative;
  /* svh, not vh (13 Aug 2026): this is the static, non-pinned stack that
     mobile/touch actually gets, and on mobile 100vh is the LARGE viewport -
     the height the page would have if the browser's address bar were
     already collapsed. It isn't on load, so every slide started ~60-100px
     taller than the screen, and each one had to be scrolled past its own
     bottom edge before the next appeared. Five slides of that reads as the
     scroll catching on every section. svh is the small-viewport unit -
     the height that's actually visible with the address bar showing - so a
     slide fills the screen exactly, address bar or not. The desktop pinned
     path already sizes itself from the pin (100svh, set in main.js) and is
     unaffected either way. */
  min-height: 100svh;
  display: flex;
  align-items: flex-end;
  overflow: hidden;
  background: var(--navy);
}
.division-slide-media {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.division-slide-scrim {
  position: absolute;
  inset: 0;
  background: linear-gradient(to top, rgba(1, 35, 64, 0.88) 0%, rgba(1, 35, 64, 0.4) 45%, rgba(1, 35, 64, 0.05) 75%);
}
.division-slide-content {
  position: relative;
  z-index: 1;
  /* clamp(), not a flat var(--sp-6): the pinned desktop mode forces each
     slide to exactly 100vh (inset: 0) with overflow: hidden, unlike the
     static fallback where a slide can grow taller than 100vh to fit its
     content. A flat 64px top+bottom padding plus the heading/paragraph/
     button could clip against that fixed height on a short viewport (e.g.
     a laptop with a lot of browser chrome) - scaling padding down on short
     viewports instead of holding it constant keeps the button on-screen. */
  padding-block: clamp(20px, 6vh, 64px);
}
.division-slide .index-tag {
  color: var(--white);
}
.division-slide .mono-label {
  color: rgba(255, 255, 255, 0.7);
}
.division-slide h3 {
  color: var(--white);
  font-size: clamp(2rem, 5vw, 3.5rem);
  max-width: 16ch;
  margin-block: var(--sp-2);
}
.division-slide p {
  color: rgba(255, 255, 255, 0.85);
  max-width: 55ch;
  margin-bottom: var(--sp-4);
}
.division-slide .btn-outline {
  color: var(--white);
  border-color: var(--white);
}
.division-slide .btn-outline:hover,
.division-slide .btn-outline:focus-visible {
  background: var(--white);
  color: var(--navy);
}
/* The slides used to set their headings as the sub-brand's logo (h3 > img
   .hero-logo), knocked out to white. As of 25 Aug 2026 the headings are real
   text at the client's request, so that rule is gone - .division-slide h3
   above styles them, which is what it was written for in the first place.
   .hero-logo itself is untouched and still in use in the page heroes. */
.division-slide .bullet-list li {
  color: rgba(255, 255, 255, 0.85);
}


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


/* --------------------------------------------------------------------------
   13. Contact CTA banner
   -------------------------------------------------------------------------- */
.cta-banner {
  background: var(--navy);
  color: var(--white);
  /* Pinstripe seam - added 22 Jul 2026. Without this, a dark section directly
     above another dark section (e.g. the divisions .stat-panel) reads as one
     undifferentiated navy block with an awkward empty gap, not two sections. */
  border-top: 1px solid var(--line-dark);
  /* Tighter than the default section padding (96px desktop) - this is a
     compact CTA strip, not a full content section, so it shouldn't stack
     a second full section-height of empty space under the divisions panel. */
  padding-block: var(--sp-5);
}
.cta-banner .container {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: flex-start;
  gap: var(--sp-3);
}
.cta-banner h2 {
  color: var(--white);
  font-size: 1.5rem;
}
.cta-banner .btn-outline {
  color: var(--white);
  border-color: var(--white);
}
.cta-banner .btn-outline:hover,
.cta-banner .btn-outline:focus-visible {
  background: var(--white);
  color: var(--navy);
}
.cta-banner .btn-primary {
  background: var(--white);
  color: var(--navy);
}
.cta-banner .btn-primary:hover,
.cta-banner .btn-primary:focus-visible {
  background: var(--gold);
  color: var(--white);
}


/* --------------------------------------------------------------------------
   17. Feature bullets / services cards
   -------------------------------------------------------------------------- */
.bullet-list {
  display: grid;
  gap: var(--sp-3);
}
.bullet-list li {
  padding-left: var(--sp-3);
  border-left: 2px solid var(--gold-faint);
  color: var(--slate);
}

.service-grid {
  display: grid;
  gap: var(--sp-3);
  grid-template-columns: 1fr;
}
@media (min-width: 700px) {
  .service-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (min-width: 1080px) {
  .service-grid { grid-template-columns: repeat(3, 1fr); }
}
.service-card {
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: var(--sp-3);
  /* Fixed height (24 Jul 2026) - same reasoning as .division-card (see its
     own comment): with no explicit height, the .card-reveal description
     growing from
     max-height: 0 to 160px on hover grew this card's own natural height,
     which grid's default row-stretch then applied to every card in the
     row, reflowing the whole grid - and everything below it on the page -
     on every hover. A fixed height means the card's box never changes
     size; only the description's visibility toggles within it. */
  height: 190px;
  overflow: hidden;
  transition: border-color var(--ease-standard);
}
.service-card:hover {
  border-color: var(--navy);
}
.service-card h3 { font-size: 1.1rem; margin-bottom: var(--sp-1); }


/* --------------------------------------------------------------------------
   18. Media / photo placeholders + captions + galleries
   -------------------------------------------------------------------------- */
.media-photo {
  border: 1px solid var(--line);
  border-radius: var(--radius);
  width: 100%;
  height: auto;
}

figure.captioned { display: flex; flex-direction: column; gap: var(--sp-2); }
figure.captioned figcaption {
  font-family: var(--f-mono);
  font-size: 0.8rem;
  letter-spacing: 0.02em;
  color: var(--slate);
}

.gallery-grid {
  display: grid;
  gap: var(--sp-3);
  grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 700px) {
  .gallery-grid { grid-template-columns: repeat(3, 1fr); }
}

.portfolio-grid {
  display: grid;
  gap: var(--sp-4);
  grid-template-columns: 1fr;
}
@media (min-width: 700px) {
  .portfolio-grid { grid-template-columns: repeat(2, 1fr); }
}


/* --------------------------------------------------------------------------
   20. Contact and its form
   -------------------------------------------------------------------------- */
/* Rebuilt 20 Aug 2026 to a client reference (houseofhoney.com/contact), in
   Lincor's palette. Two blocks: a navy field with the lockup oversized and
   drifting behind the title, then a two-panel card floating over a
   photograph. The old layout - a bordered form in one column and a plain
   address list in the other, both on the page ground - is gone, and its
   .contact-layout / .form-grid / .field rules with it; nothing else on the
   site used them, checked across every page.

   20a. The navy field
   ------------------- */
.contact-hero {
  position: relative;
  background: var(--navy);
  color: var(--white);
  /* The mark below is deliberately taller than the space left for it, and
     this is what cuts it off. */
  overflow: hidden;
  /* The title sits at the top of the block (client, 20 Aug 2026), close under
     the header rather than centred in a tall field - so this is only the space
     the header needs to clear, not a full section's padding. The small
     "Contact" label that was above it has gone with the same change; the
     heading says it. */
  padding-block: clamp(var(--sp-4), 5vh, var(--sp-5)) 0;
}
/* The one centred block on the site - see the note in contact.html. */
.contact-hero__head {
  position: relative;
  z-index: 1;
  text-align: center;
}
.contact-hero__head h1 {
  font-size: clamp(2.5rem, 7vw, 5.5rem);
  line-height: 1;
  letter-spacing: -0.03em;
  color: var(--white);
}

/* The oversized lockup.

   Height, not width, is what is set: these are fixed-proportion marks, and a
   height plus aspect-ratio is what keeps them undistorted at any viewport.
   The block is given less room than the mark is tall (margin-top pulls it up
   into the title's space, and the section's overflow removes the rest), so it
   is clipped top and bottom as well as running off both edges - the reference
   puts its own giant type half off the frame and that is what stops it
   reading as a heading in its own right. */
.contact-hero__marquee {
  margin-top: clamp(-40px, -4vw, -12px);
  height: clamp(150px, 20vw, 300px);
  overflow: hidden;
}
.contact-hero__belt {
  display: flex;
  width: max-content;
  height: clamp(210px, 28vw, 420px);
  /* Two identical groups travelling exactly one group's width, so the frame
     after the last is identical to the first and the loop has no seam. A CSS
     animation is the whole engine here on purpose: unlike the homepage's
     brands belt, nothing about this responds to scroll or to the pointer, so
     there is nothing for a script to do that keyframes cannot. */
  animation: contact-drift 42s linear infinite;
}
.contact-hero__group {
  display: flex;
  gap: clamp(40px, 5vw, 90px);
  padding-right: clamp(40px, 5vw, 90px);
}
@keyframes contact-drift {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(-50%, 0, 0); }
}
/* The supplied lockup is navy artwork and this sits on navy, so it cannot be
   used as an <img>. Same approach as the brands row on the homepage: the
   element is a block of colour, and the PNG's alpha is the mask that cuts the
   wordmark out of it. A filter cannot get to a specific colour; a mask makes
   the colour the token itself.

   The url() is relative to THIS stylesheet, not to the page - hence ../ -
   which is why it can be hard-coded here when the homepage's equivalent had
   to be handed in from script.

   The blue is a step up from the ground rather than an accent: at roughly 2:1
   against the navy it reads clearly as a graphic while staying well below the
   white title, which is what keeps the title the thing you read first. */
.contact-hero__mark {
  flex: 0 0 auto;
  height: 100%;
  aspect-ratio: 2400 / 594;
  background: #0B4C7C;
  -webkit-mask: url("../images/logo/lincor-lockup@2x.png") center / contain no-repeat;
          mask: url("../images/logo/lincor-lockup@2x.png") center / contain no-repeat;
}

/* 20b. The card, and the photograph under it
   ----------------------------------------- */
.contact-block {
  position: relative;
  isolation: isolate;
  background: var(--navy);
  overflow: hidden;
  padding-block: clamp(var(--sp-5), 8vh, 110px);
}
/* The backdrop. The supplied file is 735x490, which is smaller than the box
   it fills on any desktop screen, so it is being scaled up - acceptable here
   only because the photograph is a long-exposure blur to begin with and has
   no fine detail to lose. A larger export would still be better if one
   exists; nothing else about this needs to change if one arrives.

   Dimmed, because the card has to sit on top of it: the eggshell panel is
   nearly the same value as the concourse floor, and without this the two
   merge along the panel's bottom edge. */
.contact-block__bg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
  opacity: 0.55;
  z-index: -1;
}

.contact-card {
  display: grid;
  /* Square corners and no gap: the two panels are one object butted together,
     which is what makes the split read as a seam rather than as two cards.
     The statement side is the narrower of the two - the form is what people
     came here to use. */
  grid-template-columns: minmax(0, 0.82fr) minmax(0, 1fr);
  box-shadow: 0 30px 80px rgba(0, 0, 0, 0.28);
}
@media (max-width: 860px) {
  .contact-card { grid-template-columns: minmax(0, 1fr); }
}

/* The statement panel. */
.contact-card__say {
  background: var(--navy);
  color: var(--white);
  padding: clamp(32px, 4vw, 64px);
  display: flex;
  flex-direction: column;
  /* The statement centred in the panel and the details settled at the foot of
     it, which is how the reference uses this side: one line of intent held in
     the middle of a mostly empty field. */
  justify-content: center;
  gap: clamp(40px, 8vh, 96px);
}
.contact-card__statement {
  /* Stated, not inherited: the site sets `p { color: var(--slate) }` globally,
     which beats the white this panel passes down and left the statement almost
     unreadable on the navy. */
  color: var(--white);
  font-family: var(--f-editorial);
  font-size: clamp(1.9rem, 3.4vw, 3.1rem);
  line-height: 1.08;
  letter-spacing: -0.01em;
  text-align: center;
  margin-block: auto;
}
/* The form panel.

   The panel's own padding and the corner photograph's width are named here
   because the top padding is DERIVED from them: the photograph is pinned into
   that padding, so the space above the label has to be the inset, plus the
   photograph's height, plus a gap. Written as its own clamp it agreed with the
   photograph at desktop widths and disagreed at phone widths - 150px of
   padding above a picture that finished 178px down - and the label ran across
   the middle of the building. Derived, they cannot come apart at any width.

   1.25 is the picture's own 4:5, so its height follows from its width. */
.contact-card__form {
  --panel-pad: clamp(28px, 3.4vw, 56px);
  --thumb-w: clamp(120px, 12vw, 168px);
  position: relative;
  background: var(--shell);
  color: var(--navy);
  padding: var(--panel-pad);
  padding-top: calc(var(--panel-pad) + var(--thumb-w) * 1.25 + var(--sp-3));
}
/* The corner photograph, flush into the panel's top-right the way the
   reference has it - it sits in the padding rather than in the flow, so it
   never pushes the form around. */
.contact-card__thumb {
  position: absolute;
  top: var(--panel-pad);
  right: var(--panel-pad);
  width: var(--thumb-w);
  height: auto;
  display: block;
}
.contact-card__label {
  font-size: 0.95rem;
  color: var(--slate);
  margin-bottom: var(--sp-3);
}

.cform {
  display: grid;
  gap: 2px;
}
/* Each field is a tinted well with no border, the divisions between them made
   by the 2px of panel showing through rather than by rules - the reference's
   fields are separated the same way. */
.cfield {
  position: relative;
  background: rgba(1, 35, 64, 0.05);
}
.cfield input,
.cfield select,
.cfield textarea {
  width: 100%;
  border: 0;
  border-radius: 0;
  background: none;
  font: inherit;
  color: var(--navy);
  /* Top padding leaves the room the label rises into. */
  padding: 26px 18px 10px;
}
.cfield textarea {
  min-height: 130px;
  resize: vertical;
  display: block;
}
.cfield select { appearance: none; cursor: pointer; }
/* The select's own chevron, since appearance: none removes the native one. */
.cfield--select::after {
  content: "";
  position: absolute;
  right: 20px;
  bottom: 20px;
  width: 8px;
  height: 8px;
  border-right: 1.5px solid var(--slate);
  border-bottom: 1.5px solid var(--slate);
  transform: rotate(45deg);
  pointer-events: none;
}

/* The label starts where the reference puts its placeholder text - inside the
   field - and rises to sit above the value once there is one. :placeholder-
   shown is what detects an empty field, which is why every input carries a
   single-space placeholder: without one that pseudo-class never matches and
   the label would never come back down.

   A plain placeholder would have looked identical at rest and then vanished
   the moment anyone typed, which is the failure this avoids: the description
   of the field is still on screen while the field is being filled in, and
   still there when someone comes back to check what they wrote. */
.cfield label {
  position: absolute;
  left: 18px;
  top: 18px;
  font-size: 1rem;
  color: var(--slate);
  pointer-events: none;
  transform-origin: left top;
  transition: transform 220ms cubic-bezier(0.16, 1, 0.3, 1), color 220ms ease;
}
.cfield input:focus + label,
.cfield input:not(:placeholder-shown) + label,
.cfield textarea:focus + label,
.cfield textarea:not(:placeholder-shown) + label,
/* A select always has a value showing, so its label has nothing to sit on top
   of and is simply raised from the start. */
.cfield--select label {
  transform: translateY(-11px) scale(0.72);
  color: var(--navy);
}
.cfield input:focus-visible,
.cfield select:focus-visible,
.cfield textarea:focus-visible {
  outline: 2px solid var(--navy);
  outline-offset: -2px;
}

/* The submit row: one full-width control with the label at one end and the
   arrow at the other, over a hairline. The reference draws the text and the
   arrow as two separate things; making the whole row the button gives the
   same picture with one target the width of the form instead of a 24px
   glyph. */
/* The honeypot (5 Sep 2026). Off-screen rather than display: none or hidden -
   some bots skip fields that are plainly hidden, and this one only works if it
   looks like an ordinary input to something reading the DOM. It is already out
   of the tab order and out of the accessibility tree in the markup
   (tabindex="-1", aria-hidden), so nothing here needs to hide it from a person
   using the page; this just takes it out of the layout without taking it out
   of the form. */
.cform__hp {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

.cform__submit {
  margin-top: var(--sp-3);
  padding: var(--sp-3) 0 0;
  border: 0;
  border-top: 1px solid rgba(1, 35, 64, 0.2);
  background: none;
  color: var(--navy);
  font: inherit;
  font-weight: 600;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-3);
}
/* While the enquiry is in flight. The button is genuinely disabled for the
   duration, so this only has to make that visible - and it must not hide the
   label, because the status line beside it is what is doing the talking. */
.cform__submit:disabled {
  cursor: progress;
  opacity: 0.55;
}
.cform__submit:disabled .cform__arrow { transform: none; }
.cform__arrow {
  display: grid;
  place-items: center;
  width: 34px;
  height: 34px;
  background: var(--navy);
  color: var(--white);
  transition: transform var(--ease-standard);
}
.cform__submit:hover .cform__arrow,
.cform__submit:focus-visible .cform__arrow { transform: translateX(4px); }

/* Empty until the script fills it, and it takes no space while empty - :empty
   rather than a class, so there is one source of truth for whether there is
   anything to show. */
.cform__note {
  margin-top: var(--sp-2);
  font-size: 0.85rem;
  line-height: 1.45;
  color: var(--slate);
}
.cform__note:empty { display: none; }

/* The "your mail is not working" dialog.

   A native <dialog> centres itself and paints its own backdrop, so there is no
   positioning here beyond the width - and because it is only in the flow when
   open, it needs no hiding rule either. */
.cdialog {
  width: min(440px, calc(100vw - 2 * var(--gutter)));
  border: 0;
  border-radius: var(--radius);
  padding: clamp(28px, 3vw, 40px);
  background: var(--white);
  color: var(--navy);
  text-align: center;
  box-shadow: 0 30px 80px rgba(0, 0, 0, 0.35);
}
.cdialog::backdrop { background: rgba(1, 35, 64, 0.6); }
.cdialog__title {
  font-size: clamp(1.15rem, 2vw, 1.4rem);
  line-height: 1.25;
  letter-spacing: -0.01em;
  margin-bottom: var(--sp-3);
}
/* The number is the point of the dialog, so it is the biggest thing in it and
   the only filled control. A link, not a button: on a phone it dials, and on a
   desktop it can still be read and copied. */
.cdialog__call {
  display: block;
  font-family: var(--f-display);
  font-size: clamp(1.3rem, 2.6vw, 1.75rem);
  font-weight: 700;
  letter-spacing: -0.01em;
  text-decoration: none;
  color: var(--white);
  background: var(--navy);
  border-radius: var(--radius);
  padding: 16px 20px;
  transition: background var(--ease-standard);
}
.cdialog__call:hover { background: var(--navy-soft); }
.cdialog__call:focus-visible {
  outline: 2px solid var(--navy);
  outline-offset: 2px;
}
.cdialog__close {
  margin-top: var(--sp-3);
  border: 0;
  background: none;
  font: inherit;
  font-size: 0.9rem;
  color: var(--slate);
  cursor: pointer;
  text-decoration: underline;
}
.cdialog__close:focus-visible {
  outline: 2px solid var(--navy);
  outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce) {
  /* The mark stays, cut off exactly as it is - it is a composition, not an
     animation. Only the travel stops. */
  .contact-hero__belt { animation: none; }
  .cfield label,
  .cform__arrow { transition: none; }
  .cform__submit:hover .cform__arrow,
  .cform__submit:focus-visible .cform__arrow { transform: none; }
}

/* --------------------------------------------------------------------------
   21. Footer
   -------------------------------------------------------------------------- */
/* Rebuilt 20 Aug 2026 at client request. The footer is now one full-bleed
   image (images/footer.png - a figure walking a sunlit corridor, mostly
   white wall) with the whole footer laid over it, rather than a three-column
   text block on the page ground.

   Layout, after the 20 Aug revisions: lockup + roots line top left, the page
   list top right as a numbered table of contents. A legal/utility set sat
   bottom left for part of that day and was dropped at client request - which
   is why the footer carries no copyright line; if one is needed later it
   wants its own band under the image, not type laid on the floor, where the
   frame is too light to hold it without a plate behind it. Everything else
   is positioned against the photograph, so what matters is that the type
   stays clear of the figure, who sits at roughly 64-73% across the frame,
   and stays navy, because the wall it sits on is bright. */
.site-footer {
  position: relative;
  isolation: isolate;
  background: var(--navy);
  color: var(--navy);
  overflow: hidden;
}
/* <picture> is only a wrapper for the format choice - it should not exist as
   a box. Left as its default inline it still generates a line box here, which
   would add a line's worth of height to the footer for an element that paints
   nothing. display: contents takes it out of the box tree entirely and leaves
   the <img> parented to .site-footer, which is what the absolute positioning
   below is measured against. */
.site-footer > picture { display: contents; }

.footer-bg {
  position: absolute;
  inset: 0;
  /* The photograph's own upper third, averaged, so the moment before it
     decodes reads as the wall rather than as a hole in the page. The footer
     ground under it is navy - correct for the section, wrong for this frame -
     and the type laid over it is navy too, so a navy gap makes the whole
     footer briefly unreadable. */
  background: #b9b8b8;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* The figure is right-of-centre in the source frame. As the viewport
     narrows the frame crops, and centre-cropping would eat him first, so
     the crop is biased right to keep him in shot. */
  object-position: 68% 50%;
  z-index: -1;
}
.footer-inner {
  /* Matches the source image's own 1672x941 so, at the widths where the
     photo is not being cropped, the layout sits on the frame exactly as
     composed. min-height keeps it usable once it is.

     This is set here rather than by the image, which is why the footer holds
     its height while the photograph is still loading and nothing below it
     shifts. */
  aspect-ratio: 1672 / 941;
  min-height: 620px;
  /* Caps the box at its container, and it is load-bearing (added 4 Sep 2026).
     aspect-ratio and min-height together can resolve through the WIDTH: once
     the viewport is narrow enough that 1672/941 would make the box shorter
     than 620px, min-height wins and the ratio is then satisfied by inflating
     the width instead - pinning it at 620 x 1.777 = 1102px no matter how
     narrow the window gets.

     .site-footer has overflow: hidden, so this never showed up as a page
     scrollbar. It silently CLIPPED the footer instead: measured across a
     width sweep, the page list ran past the right edge from 1048px down to
     861px, losing 1px at the top of that range and 198px of the column -
     numbers and all - at the bottom. Every page on the site, since the
     footer is shared. Below 861px the max-width: 860px block resets
     aspect-ratio and the bug ended, which is why it hid in a band nobody
     had measured.

     Capping the width is what keeps the 620px floor intact: the box gets
     taller than the ratio wants rather than wider than the window. Dropping
     min-height in that band fixes the clipping too, but collapses the footer
     to ~350px there and then jumps back to 614px at 860px. */
  max-width: 100%;
  display: flex;
  flex-direction: column;
  /* Both blocks sit at the top of the frame. Not centred: the brief puts the
     lockup near the top left and the page list top right, and the lower half
     of the photograph is floor. */
  justify-content: flex-start;
  padding-block: clamp(40px, 5vw, 72px);
}

/* Top row: lockup left, page list right. */
.footer-top {
  display: grid;
  grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
  gap: var(--sp-5) var(--sp-6);
  align-items: start;
}

/* The lockup, run out across the frame. It stops short of the figure - close
   enough to read as one line of movement with him, not so close that they
   touch. */
.footer-lede__logo {
  display: block;
  width: 100%;
  max-width: 620px;
  height: auto;
}
.footer-lede__text {
  margin-top: clamp(20px, 2.4vw, 36px);
  margin-bottom: 0;
  max-width: 42ch;
  font-size: clamp(0.85rem, 1.05vw, 1rem);
  line-height: 1.6;
  color: var(--navy);
}

/* The page list, top right. Same construction as the nav page's .navlist -
   hairline rules between rows and one above the first, small mono numbers,
   display type for the names - but mirrored: the name comes first in source
   order and the number sits on the outside, so the whole column flushes to
   the right edge of the frame. */
.footer-navlist {
  list-style: none;
  margin: 0;
  padding: 0;
  border-top: 1px solid var(--gold-faint);
}
.footer-navlist__item { border-bottom: 1px solid var(--gold-faint); }

.footer-navlist__link {
  display: flex;
  align-items: baseline;
  justify-content: flex-end;
  gap: clamp(12px, 1.4vw, 22px);
  padding-block: clamp(7px, 0.9vw, 12px);
  text-decoration: none;
  color: var(--navy);
}
.footer-navlist__name {
  font-family: var(--f-display);
  font-weight: 600;
  font-size: clamp(1.05rem, 1.9vw, 1.7rem);
  line-height: 1.05;
  letter-spacing: -0.02em;
  text-transform: uppercase;
  transition: transform 320ms cubic-bezier(0.16, 1, 0.3, 1);
}
.footer-navlist__num {
  flex: 0 0 auto;
  font-family: var(--f-mono);
  font-size: 0.7rem;
  letter-spacing: 0.1em;
  color: var(--gold);
  transition: color var(--ease-standard);
}
/* Hover nudges the name toward its destination - here that is rightward,
   toward the number, since the column reads right-aligned. */
.footer-navlist__link:hover .footer-navlist__name,
.footer-navlist__link:focus-visible .footer-navlist__name {
  transform: translateX(clamp(4px, 0.6vw, 10px));
}
.footer-navlist__link:hover .footer-navlist__num,
.footer-navlist__link:focus-visible .footer-navlist__num,
.footer-navlist__link[aria-current="page"] .footer-navlist__num { color: var(--navy); }

@media (max-width: 860px) {
  /* Past here the frame is cropped hard and there is no longer room beside
     the figure, so the lockup and the page list stack into one column. */
  .footer-inner {
    aspect-ratio: auto;
    min-height: 0;
    gap: var(--sp-5);
  }
  .footer-top { grid-template-columns: 1fr; }
  .footer-lede__text { max-width: 100%; }
  .footer-navlist__link { justify-content: space-between; }
}

/* --------------------------------------------------------------------------
   22. Legal page
   -------------------------------------------------------------------------- */
.legal-content {
  max-width: 72ch;
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
}
.legal-content h2 { margin-top: var(--sp-4); }
.legal-placeholder-note {
  font-family: var(--f-mono);
  font-size: 0.8rem;
  color: var(--gold);
  border: 1px dashed var(--line);
  padding: var(--sp-3);
  border-radius: var(--radius);
}

/* --------------------------------------------------------------------------
   23. Enquiry / division-routed CTA section - REMOVED 7 Sep 2026
   --------------------------------------------------------------------------
   `.enquiry-section { background: var(--bg-tint) }` lived here. Property was
   its only user and that section came out at the client's request, so the
   rule went with it.

   Confirmed dead by two routes before removal, per the rule in CLAUDE.md: no
   occurrence of the class in any HTML or JS in the repo, and zero matching
   elements in the rendered DOM at all eight test widths afterwards. Section
   numbering is left alone - renumbering 24 onwards would touch a dozen
   comments to no purpose.
   -------------------------------------------------------------------------- */

/* --------------------------------------------------------------------------
   Brand logo list (property.html)
   -------------------------------------------------------------------------- */
/* Restored 14 Aug 2026: the dead-CSS sweep after the homepage rebuild matched
   these on the "brand" prefix along with the retired homepage .brands section,
   but property.html still uses them for its Rhind/Zelpy logo list. Caught by
   diffing every class in the HTML against the surviving rules. */
.brand-list {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2);
}
.brand-list--logos {
  flex-direction: column;
  align-items: flex-start;
  gap: var(--sp-4);
}
.brand-logo {
  display: block;
  height: 64px;
  width: auto;
  object-fit: contain;
}

/* ===================================================================
   Poster hero - Import & Export only (21 Aug 2026, client brief,
   reference: neuemontreal.com's "Already in Use, Worldwide!" section).

   Deliberately NOT built on .hero: that rule is the site's conventional
   division hero (a centred flex column wrapping a .hero-copy inside
   .container, with the eyebrow/lede/CTA furniture that comes with it),
   and this is the opposite of it - one full-bleed canvas with three flat
   layers positioned against its own edges, no content container at all.
   Reusing .hero would have meant unpicking most of it. Its own class
   instead, scoped to this page, so nothing sitewide moves.

   The reference's typography is Neue Montreal Black. Not imported: the
   attitude here is carried by scale, tracking, line-height and the wave,
   and the face is --f-hero (Montserrat 900) - the same one the homepage
   hero's own giant type is set in, so the two biggest pieces of type on
   the site speak with one voice. It was General Sans 700 for a few hours
   (21 Aug 2026): correct as the sitewide *display* font, but this is hero
   type, and next to the homepage's 900 it read light.
   =================================================================== */
.hero-poster {
  position: relative;
  /* Full viewport minus the sticky header's own 88px (see .site-header
     .container) - the same measurement every other hero on the site uses.
     Briefly a flat 100svh (21 Aug 2026) to give the heading more room: the
     clip is `cover`, so a taller box crops less off the frame and leaves
     more open water above the vessel. Reverted the same day - since the
     header is opaque and the hero starts below it, those 88px come off the
     BOTTOM, and the bottom is where the paragraph lives; it was cut in
     half by the fold on load. Moving the heading into the top-left corner
     solved the overlap on its own, so the height did not need to change. */
  min-height: calc(100svh - 88px);
  width: 100%;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  /* Top-left corner (21 Aug 2026, client). Three passes to get here:
     centred, the heading sat straight across the container ship; in the
     upper third it still met the ship late in the clip, because the vessel
     does not hold still - it drifts up the frame as the shot plays, so any
     position measured against a single frame is wrong by the end of it.
     The corner is the one place in this composition the ship never
     reaches. */
  justify-content: flex-start;
  /* Overrides the sitewide `section { padding-block: var(--section-pad) }`.
     Small and fluid: enough to keep the caps off the header's edge without
     the heading drifting back down into the ship's path on a tall screen. */
  padding-block: clamp(20px, 3.5vh, 48px) 0;
  /* Explicitly square: this is edge-to-edge media, not a card. */
  border-radius: 0;
  /* What is behind the clip for the moment before its first frame decodes,
     and behind any letterboxing a very extreme viewport could produce. */
  background: var(--navy);
}

/* Layer 1 - the clip, behaving exactly as a background image would.
   object-fit: cover keeps its own proportions and crops the overflow
   rather than stretching it. pointer-events: none for the same reason
   .hero-lock__video on the homepage carries it: Chrome offers a
   picture-in-picture affordance on hover for any <video>, and this is
   scenery. */
.hero-poster__video {
  position: absolute;
  inset: 0;
  z-index: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
  border: 0;
  border-radius: 0;
  pointer-events: none;
}

/* Layer 2 - the heading. Sized in vw so it holds the same proportion of
   the composition at every width. Measured, not estimated: Montserrat 900
   sets the longer of the two lines ("LARGEST SUPPLIER") at ~9.6em at this
   tracking, so 7.4vw is what puts it at ~72% of the viewport - stepped
   down from 9.1vw on 21 Aug 2026 (client: "make it smaller"), which had it
   at ~85% and hard against both edges.

   Square, not tilted: the rotate(-4deg) this carried for a few hours is
   gone (client, 21 Aug 2026) - with the wave running through the letters,
   the tilt was a second thing happening at once and the wave is the
   subject. One line to put back if it is ever wanted again.

   The wave itself is js/hero-wave.js: it rewrites the text of each line
   below into per-character spans and writes a transform to each one every
   frame. Nothing about that is required for this rule to be correct - with
   no JavaScript, or with reduced motion asked for, the heading is simply
   this, static. */
.hero-poster__heading {
  position: relative;
  z-index: 2;
  margin: 0 0 0 4vw;
  max-width: none;
  font-family: var(--f-hero);
  font-weight: 900;
  font-size: clamp(1.6rem, 7.4vw, 10rem);
  /* 0.92, not the 0.80 this had before the wave (21 Aug 2026). Measured,
     and it is the wave that forces it: at 0.80, Montserrat 900's cap height
     leaves ~11px between line one's baseline and line two's cap tops at
     desktop size. Any wave worth seeing is bigger than that, so the two
     lines' letters crossed through each other - "SUPPLIER" descending into
     "AFRICA". 0.92 buys the clearance the letters need to move in without
     ever touching, and is still tight leading by any normal measure. The
     pair is set together: change this and AMP_RATIO in js/hero-wave.js has
     to be re-checked. */
  line-height: 0.92;
  letter-spacing: -0.05em;
  /* Tracking this tight closes the word space along with everything else -
     at display size, "SOUTH AFRICA" was reading as one word. This puts back
     just the space, not the tracking. */
  word-spacing: 0.1em;
  text-transform: uppercase;
  text-align: left;
  /* White, not the reference's orange (client, 21 Aug 2026). It is the
     colour this site already sets hero type over video in - see the home
     hero - and the footage carries the colour on its own. */
  color: var(--white);
  text-shadow: none;
  background: none;
}
/* Each line its own block, so the 0.92 above is what sets the gap between
   them. Fit-content rather than full width keeps the box the width of the
   type itself. */
.hero-poster__heading > span {
  display: block;
  width: fit-content;
  /* The break after SUPPLIER is the only break there is. Without this a
     line one pixel too wide for the viewport silently becomes two, and the
     deliberate two-line composition becomes an accidental three-line one -
     the size above is set so neither line ever needs to. */
  white-space: nowrap;
}
/* One character, written to every frame by js/hero-wave.js. inline-block is
   what makes a transform apply to it at all (transforms do not apply to
   non-replaced inline boxes); white-space: pre is what keeps the space
   characters - which are spans too, so the wave runs through the gaps
   between words as smoothly as through the letters - from collapsing.

   will-change promotes each character to its own layer up front, so the
   per-frame transform is a compositor change and never a repaint of the
   line. It is set here rather than toggled from JS because these characters
   are animating for as long as the hero is on screen. */
.hero-poster__char {
  display: inline-block;
  white-space: pre;
  will-change: transform;
  /* The transform origin is the character's own middle: the micro-rotation
     the wave adds should rock each letter about its centre, not swing it
     from a corner. */
  transform-origin: 50% 50%;
}
/* The wave is movement for its own sake - the one thing a reduced-motion
   request most directly means. js/hero-wave.js checks the same query and
   never starts, which is what actually stops it; this is the belt-and-
   braces pair to that, and what clears any transform already written if
   the preference is changed while the page is open. */
@media (prefers-reduced-motion: reduce) {
  .hero-poster__char {
    transform: none !important;
    will-change: auto;
  }
}

/* Layer 3 - the paragraph, positioned against the section's own bottom-left
   corner rather than flowing under the heading. Its left edge is the same
   4vw as the heading's margin, so the two share one margin even though
   neither is in a container. White, not --slate: it sits on moving footage,
   and white is the colour the site already uses for text over video (see
   the home hero). */
.hero-poster__lede {
  position: absolute;
  z-index: 2;
  left: 4vw;
  bottom: clamp(24px, 3.5vw, 56px);
  margin: 0;
  max-width: min(460px, 82vw);
  font-family: var(--f-body);
  font-weight: 400;
  font-size: clamp(1rem, 1.35vw, 1.375rem);
  line-height: 1.2;
  text-align: left;
  color: var(--white);
}

/* Short and wide (laptops at 100% zoom, phones in landscape): 7.4vw of type
   is more height than there is, so the type steps back off the width and
   onto the height. Same attitude, same wave - only the scale gives. */
@media (max-height: 620px) {
  .hero-poster__heading {
    font-size: clamp(1.6rem, 7.4vh, 10rem);
  }
}

/* Phones (7 Sep 2026, client): centred, bigger, and moved down the frame -
   but it has to stay clear of the vessel.

   Where the vessel actually is, measured rather than eyeballed: at 390px the
   video is 1920x1080 in a 390x692 box under object-fit: cover, so the frame is
   cropped to its middle 609px and scaled 0.64. Sampling the clip for saturated
   warm pixels through that same crop puts the topmost container at viewport
   y = 405, and unlike the desktop framing it does not move - 405 at t=0, 1.5,
   3, 4.5, 6 and 7s. The safe band is therefore y=88 (under the header) to
   y=400, and the block below lands at roughly 300-365.

   The two-line break is KEPT, and that is what caps the size. Removing nowrap
   would allow a much bigger four-line stack, but js/hero-wave.js wraps every
   character in its own inline-block: with wrapping allowed the line can break
   between any two of them, not just at spaces, so "SUPPLIER" can split
   mid-word. Holding each line unbreakable and sizing to the longer of the two
   ("Largest Supplier", 16 characters) gives 8.8vw - 34.7px at 390 against the
   28.9px it was, a fifth bigger, with the composition and the wave intact.

   Positioned as a FRACTION OF THE HERO, not with a vh margin, because that is
   what the vessel is a fraction of. Sampled across eight phone sizes, the
   ship's top edge lands at 45.8% of the hero's height every single time -
   360x640, 390x780, 390x932, 430x932 all agree - because the cover crop is
   symmetric and the vessel is centred in the source frame. A vh margin does
   not track that: at 360x640 it left 6px of clearance while at 390x932 it left
   48px. top: 30% holds a 29-70px gap at every size instead.

   min-height on the query is the other half. In landscape the header's 88px
   comes off a much shorter hero, so 45.8% of what is left sits high enough that
   no centred block clears it - measured, 780x360 overlaps however the type is
   sized. Below 620px tall the page keeps its original top-left composition,
   which the note above records as the one position the ship never reaches. */
@media (max-width: 860px) and (min-height: 620px) {
  .hero-poster__heading {
    position: absolute;
    top: 30%;
    left: 0;
    right: 0;
    margin: 0;
    padding-inline: 16px;
    text-align: center;
    font-size: clamp(1.9rem, 8.8vw, 3rem);
  }
  /* Full width so text-align can centre them; the nowrap stays. */
  .hero-poster__heading > span { width: auto; }
}

/* ===================================================================
   10a. Cutting mat - Import & Export only (21 Aug 2026, client brief,
   reference: a photograph of a green self-healing cutting mat).

   Replaces the pinned .division-scroller on that page (still in use on
   manufacture.html, so that component stays): the client wants the three
   sub-brands to read as ONE continuous surface instead of three separate
   pinned scenes, each with a big line drawing opposite its copy.

   The grid is a background-image on the section, not elements: four
   gradients (fine line, heavy line, each in both directions) cost one
   paint and stay in register down any height of section, which is the
   whole point - a mat that restarted at every block would read as three
   mats. --mat-cell is in vw so the cells are square; the 28 columns of
   the reference are what set it.
   =================================================================== */
.mat {
  /* 28 columns across, as on the mat in the reference photograph - the top
     ruler's 0-28 is generated against this same number in the markup. vw
     rather than % so the vertical repeat can use the same length and the
     cells come out square; the cost is that a classic overlay scrollbar's
     width is not accounted for, which lands the last column a few px past
     the right edge, where it is clipped and cannot be seen. */
  --mat-cell: calc(100vw / 28);
  /* Retoned 24 Aug 2026 at client request: the mat was a green self-healing
     cutting mat with white rules; it is now the site's eggshell ground with
     thin Lincor-blue rules. --mat-green keeps its name (it is referenced by
     the reveal shutters, which must match the mat's ground exactly) but now
     holds the eggshell. Ink is navy throughout, so the type that used to be
     white-on-green is navy-on-eggshell. */
  --mat-green: var(--shell);
  --mat-line: rgba(1, 35, 64, 0.16);
  --mat-line-heavy: rgba(1, 35, 64, 0.30);
  position: relative;
  overflow: hidden;
  /* Overrides the sitewide `section { padding-block: var(--section-pad) }`:
     the mat is edge to edge, and its own top padding has to be a whole
     number of cells (below) or the first horizontal rule lands mid-ruler. */
  padding-block: 0;
  background-color: var(--mat-green);
  background-image:
    linear-gradient(to right, var(--mat-line-heavy) 1px, transparent 1px),
    linear-gradient(to bottom, var(--mat-line-heavy) 1px, transparent 1px),
    linear-gradient(to right, var(--mat-line) 1px, transparent 1px),
    linear-gradient(to bottom, var(--mat-line) 1px, transparent 1px);
  background-size:
    calc(var(--mat-cell) * 5) calc(var(--mat-cell) * 5),
    calc(var(--mat-cell) * 5) calc(var(--mat-cell) * 5),
    var(--mat-cell) var(--mat-cell),
    var(--mat-cell) var(--mat-cell);
  /* Every layer starts from the same origin, so the heavy rule always lands
     on a fine one rather than between two. */
  background-position: 0 0;
  color: var(--navy);
}
/* Once js/mat-reveal.js has drawn the grid as SVG (see .mat-grid below) the
   painted one underneath it would be a second set of lines half a pixel off
   the first, so it comes off. It is what a visitor without JavaScript sees,
   and nothing else. */
.mat.has-grid {
  background-image: none;
}

/* --- Rulers ---------------------------------------------------------------
   The numerals along the top and left edges. Positioned off --mat-cell, so
   they cannot drift out of register with the grid they label. Mono, small,
   and dimmer than the type they sit beside: on the real object they are
   printed marks, not information anybody reads. */
.mat-ruler {
  position: absolute;
  z-index: 2;
  font-family: var(--f-mono);
  font-size: 0.62rem;
  line-height: 1;
  color: rgba(1, 35, 64, 0.5);
  pointer-events: none;
}
.mat-ruler__num {
  position: absolute;
}
.mat-ruler--top {
  top: 0;
  left: 0;
  right: 0;
  height: var(--mat-cell);
}
/* 3px off the gridline it belongs to, which is what the printed rulers do -
   the numeral sits just after its own line, not centred in the cell. */
.mat-ruler--top .mat-ruler__num {
  top: 7px;
  left: calc(var(--n) * var(--mat-cell) + 3px);
}
.mat-ruler--left {
  top: 0;
  bottom: 0;
  left: 0;
  width: var(--mat-cell);
}
.mat-ruler--left .mat-ruler__num {
  left: 7px;
  top: calc(var(--n) * var(--mat-cell) + 4px);
}

/* --- One sub-brand ------------------------------------------------------ */
.mat-block {
  position: relative;
  /* A whole number of cells tall, so the horizontal rules stay in step with
     the ruler down the length of the section however many blocks there are.
     min-height, not height: a block whose copy needs more room grows, and
     the only cost is that the rule spacing after it is no longer aligned to
     the numerals - which is why the copy column is width-limited below. */
  min-height: calc(var(--mat-cell) * 9);
  display: flex;
  align-items: center;
  padding-block: calc(var(--mat-cell) * 1.5);
}
.mat-block__inner {
  position: relative;
  z-index: 1;
  width: 100%;
  max-width: 1280px;
  margin-inline: auto;
  /* The copy column is narrower than the drawing's: the drawing is the thing
     that should feel oversized on the mat. */
  display: grid;
  grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr);
  align-items: center;
  gap: clamp(24px, 5vw, 80px);
  /* Clears the left ruler, and keeps the copy off the mat's own edge. */
  padding-inline: calc(var(--mat-cell) * 1.6) var(--gutter);
}
/* Which side the drawing sits on. The copy is always first in the markup -
   so it is always first in the reading and tab order, on every block and at
   every width - and this is what moves the drawing across on the blocks that
   want it on the left. */
.mat-block--art-left .mat-block__copy {
  order: 2;
}
.mat-block--art-left .mat-art {
  order: 1;
}
/* The columns are sized 0.85fr copy / 1.15fr picture, in that order - so on
   a block that puts the picture first the two have to be swapped as well,
   or the picture lands in the narrow column and comes out visibly smaller
   than the same picture on the block above it. */
.mat-block--art-left .mat-block__inner {
  grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
}

.mat-block__copy {
  max-width: 46ch;
}
.mat-block__copy h3 {
  color: var(--navy);
  font-size: clamp(2.2rem, 4.4vw, 4rem);
  line-height: 1.03;
  letter-spacing: -0.02em;
  margin: 0.35em 0 0.5em;
}
.mat-block__copy p {
  color: rgba(1, 35, 64, 0.78);
  font-size: clamp(1.15rem, 1.75vw, 1.55rem);
  line-height: 1.4;
  /* No max-width of its own any more: at the larger size set here the copy
     column itself is already the measure, and a ch limit on top of it just
     turned the paragraph into a narrow ribbon inside a wider column. */
  max-width: none;
  margin: 0;
}
/* The eyebrow pair. Both exist sitewide in navy-on-white versions; on the mat
   they need the ink inverted, and nothing else about them changes. */
.index-tag--mat,
.mono-label--mat {
  color: rgba(1, 35, 64, 0.6);
}
/* .pill--mat and the .pill-row spacing that went with it are gone (25 Aug
   2026, client): the sub-brand blocks no longer carry pills under their copy.
   The base .pill and .pill-row followed on 4 Sep 2026 - by then no page in
   the site carried either class. */
/* The one sub-brand with an outside site to send people to. Same button as
   the homepage's closing call to action - navy fill, white text - and the
   same spacing off the paragraph as the pills use, so the two blocks that
   carry something under their copy sit it at the same height. */
.mat-block__copy .mat-cta {
  margin-top: var(--sp-4);
}
/* A button whose destination does not exist yet. It keeps the full navy fill
   rather than being greyed out - it is not disabled, it is waiting - but it
   drops the pointer and the hover lift, so nothing about it invites a press
   it cannot answer. Deleting this class and giving the tag an href is all
   that is needed when the site lands. */
.btn--pending {
  cursor: default;
}
.btn--pending:hover,
.btn--pending:focus-visible {
  transform: none;
  background: var(--navy);
}

/* --- The picture --------------------------------------------------------
   Was a line drawing per block (an inline SVG); replaced 24 Aug 2026 at
   client request with a photograph per sub-brand. The photograph is the
   subject of its half of the block, so it fills the whole of the column the
   drawing used to sit in - hence width: 100% and no max-width of its own.
   The intrinsic ratio is carried by the img's width/height attributes, so
   nothing reflows once it loads.
   --------------------------------------------------------------------- */
.mat-art {
  margin: 0;
  width: 100%;
}
.mat-art picture {
  display: block;
}
.mat-art__img {
  display: block;
  width: 100%;
  height: auto;
  /* The same corner the homepage's About picture and the nav panel's
     photograph carry - one token, so the whole site's imagery rounds
     together. It survives the reveal: the ellipse that opens over the
     picture finishes larger than the frame, so what is left defining the
     corners at rest is this. And it is what the click-to-expand morph reads
     and flattens to 0 on its way out to full screen (js/mat-morph.js). */
  border-radius: var(--radius);
}

/* --- The reveal ---------------------------------------------------------
   The grid inside a block is an SVG built by js/mat-reveal.js, one <line>
   per rule, stroked in as the block arrives.

   It replaces a pair of panels in the mat's own ground colour that used to
   retract from both edges (24 Aug 2026, client). Those were cheap - two
   transforms, nothing per line - but they were a rectangular hole opening in
   a solid sheet, so what a visitor actually watched was a hard-edged block of
   grid growing sideways, with the rules appearing a column at a time as the
   edge crossed them. Chunks, not lines.

   Drawing the rules themselves is the fix, and it also fixes them at rest:
   the painted grid was four background gradients on a section whose cell is
   100vw/28, so every rule sat wherever a fractional cell put it and rendered
   one soft pixel or two hard ones depending on where it landed. Each line
   here is placed on a half-pixel, which is what makes a 1px stroke cover
   exactly one device pixel at 1x and exactly two at 2x.

   The geometry is deliberately the same: same cell, same every-fifth-rule
   emphasis, same colours. Only the way it arrives, and its crispness, change.
   ---------------------------------------------------------------------- */
.mat-grid {
  position: absolute;
  inset: 0;
  z-index: 0;                /* under .mat-block__inner, which is 1 */
  width: 100%;
  height: 100%;
  pointer-events: none;
  /* Never crispEdges: it would snap the stroke to whole device pixels and
     the rules would shift by up to half a pixel against the ruler numerals,
     which are positioned in CSS off the same cell. */
  shape-rendering: geometricPrecision;
}
.mat-grid__line {
  stroke: var(--mat-line);
  stroke-width: 1;
  /* Both set for every line, drawn or not, so a line built after a resize on
     a block that has already arrived is simply complete - there is no prior
     value to transition from, so nothing re-animates. */
  stroke-dasharray: var(--len);
  stroke-dashoffset: 0;
}
.mat-grid__line--heavy {
  stroke: var(--mat-line-heavy);
}
/* Armed and not yet arrived: the line is its own length out of view, so what
   is on screen is nothing at all rather than a faint or partial grid. */
html.mat-armed .mat-block:not(.is-drawn) .mat-grid__line {
  stroke-dashoffset: var(--len);
}
/* The draw. power3.out as a curve, at the top of the brief's range, and the
   per-line delay (--d, set in js/mat-reveal.js) is what turns 40 lines into
   one sweep rather than 40 things starting together. */
html.mat-armed .mat-grid__line {
  transition: stroke-dashoffset 820ms cubic-bezier(0.165, 0.84, 0.44, 1);
  transition-delay: var(--d, 0ms);
}

/* --- The copy and the photograph ----------------------------------------
   Both arrive a beat behind the grid above.

   Until 24 Aug 2026 they did the same thing as each other - the whole
   .mat-block__inner faded up 14px as one object - which is a single move for
   four pieces of type and a picture, and read as a panel appearing rather
   than as a block being made. They are two separate arrivals now, each
   borrowing a movement the site already has (client, 24 Aug 2026).

   1. The copy: the homepage's masked line reveal. Each line sits in a box of
      its own with overflow hidden and slides up out of it, one after another,
      so the type reads as being set. The boxes are built by js/mat-reveal.js
      (see maskCopy there) and only ever exist while the mat is armed.

      It clips with clip-path rather than overflow: hidden, so it does not
      establish a formatting context and the lines' margins go on collapsing
      exactly as they did before the wrapper existed - the wrapper is invisible
      to the layout rather than compensated for. The negative bottom inset is
      the descender allowance, extending the clip past the box instead of
      padding the box out, and in em so it scales with the line it clips; the
      negative side insets keep the clip off a focus ring or an italic's
      overhang. See maskCopy() in js/mat-reveal.js. */
.mat-line {
  display: block;
  clip-path: inset(0 -0.5em -0.16em -0.5em);
}
html.mat-armed .mat-line > * {
  /* Past the descenders as well as the baseline, so nothing shows above the
     clip's bottom edge before the line starts to move. */
  transform: translateY(118%);
  transition: transform 900ms cubic-bezier(0.16, 1, 0.3, 1);
}
html.mat-armed .mat-block.is-drawn .mat-line > * {
  transform: none;
}
/* The stagger. Written as delays rather than as a per-line custom property
   because there are only ever four lines in a block - eyebrow, heading,
   paragraph, pills - and a fifth would simply arrive with the fourth, which
   is a better failure than a line that never moves. */
html.mat-armed .mat-line:nth-child(1) > * { transition-delay: 260ms; }
html.mat-armed .mat-line:nth-child(2) > * { transition-delay: 350ms; }
html.mat-armed .mat-line:nth-child(3) > * { transition-delay: 440ms; }
html.mat-armed .mat-line:nth-child(n+4) > * { transition-delay: 530ms; }

/* 2. The photograph: the menu button's expansion. When the Menu control is
      clicked an ellipse opens out from the button until it has swallowed the
      screen (js/nav.js); here the same shape opens out from the middle of the
      picture until it has swallowed the frame. Same idea, same curve, and
      close to the same beat - power2.out over 1.05s there, its cubic-bezier
      equivalent over 1s here.

      ellipse() rather than circle() because that is what the menu grows: the
      semi-axes are percentages of the box's own width and height, so the
      shape stays in proportion with the picture at every width instead of
      sweeping across a 16:9 frame as a circle. 72% each way is the covering
      size - the corner of the box sits at (50%, 50%) of the semi-axes, and
      0.5^2 + 0.5^2 = 0.5, so anything past 71% has the whole frame inside it
      - with a little over for the sub-pixel edge.

      clip-path is the right tool rather than a scale: the photograph itself
      never moves or resamples, so there is no softness during the animation
      and nothing to settle at the end. */
html.mat-armed .mat-art__img {
  clip-path: ellipse(0% 0% at 50% 50%);
  transition: clip-path 1000ms cubic-bezier(0.215, 0.61, 0.355, 1);
  transition-delay: 300ms;
}
html.mat-armed .mat-block.is-drawn .mat-art__img {
  clip-path: ellipse(72% 72% at 50% 50%);
}

/* --- Click to expand -----------------------------------------------------
   The morph layer built by js/mat-morph.js. Everything that moves is set on
   the element by that file - this is the ground it moves over, and the two
   states of the picture it moves out of.

   The layer is display: none while closed rather than merely transparent: it
   is a full-screen fixed box, and a transparent one left in the document
   would sit over the whole page swallowing clicks on the mat underneath it.
   ---------------------------------------------------------------------- */
.imorph {
  display: none;
  position: fixed;
  inset: 0;
  z-index: 300;              /* over .navpage, which is 200 */
}
.imorph.is-open {
  display: block;
}
.imorph__backdrop {
  position: absolute;
  inset: 0;
  /* The navy, not black: the page dims into the site's own dark rather than
     into a generic scrim. Kept light enough that the mat is still legible
     behind the picture - the photograph has left the layout for a moment, it
     has not navigated anywhere. */
  background: rgba(1, 35, 64, 0.82);
  opacity: 0;
}
.imorph__img {
  position: fixed;
  /* cover, so the crop changes with the box while the box is changing shape.
     The morph ends on the picture's own aspect ratio (see targetRect), so
     nothing is cropped away once it has arrived. */
  object-fit: cover;
  margin: 0;
  max-width: none;           /* beats the sitewide img { max-width: 100% } */
  cursor: zoom-out;
}
.imorph__close {
  position: absolute;
  top: clamp(12px, 2.4vw, 28px);
  right: clamp(12px, 2.4vw, 28px);
  width: 44px;
  height: 44px;
  display: grid;
  place-items: center;
  padding: 0;
  border: 1px solid rgba(255, 255, 255, 0.4);
  border-radius: 50%;
  background: transparent;
  color: var(--white);
  cursor: pointer;
  opacity: 0;
  transition: background-color 240ms ease, border-color 240ms ease;
}
.imorph__close:hover,
.imorph__close:focus-visible {
  background: rgba(255, 255, 255, 0.14);
  border-color: rgba(255, 255, 255, 0.7);
}
.imorph__close svg {
  width: 18px;
  height: 18px;
}
/* The picture in the page. The affordance is the cursor and the focus ring -
   no overlay, no icon, nothing added to the design of the section. */
.mat-art__img.is-zoomable {
  cursor: zoom-in;
}
.mat-art__img.is-zoomable:focus-visible {
  outline: 2px solid var(--navy);
  outline-offset: 4px;
}

@media (prefers-reduced-motion: reduce) {
  html.mat-armed .mat-line > *,
  html.mat-armed .mat-art__img {
    transition: none;
    transform: none;
    clip-path: none;
  }
  /* .mat-block in the selector so this matches the not-yet-drawn rule above
     for specificity and wins on order. Under reduced motion every block is
     drawn on the spot anyway, so this only ever matters as a guard. */
  html.mat-armed .mat-block .mat-grid__line {
    transition: none;
    stroke-dashoffset: 0;
  }
}

/* --- Narrow ------------------------------------------------------------- */
/* One column, drawing under its copy. The mat gets coarser rather than
   denser: 28 columns of 1cm cells on a phone is a texture, not a grid, so
   the cell steps up to a twelfth of the viewport and the rulers - printed
   marks nobody reads, and the first thing that becomes noise at this size -
   come off entirely. */
@media (max-width: 860px) {
  .mat {
    --mat-cell: calc(100vw / 12);
  }
  .mat-ruler {
    display: none;
  }
  .mat-block__inner,
  /* Beats the two-column swap above, which is more specific than the bare
     .mat-block__inner selector and would otherwise survive into one column. */
  .mat-block--art-left .mat-block__inner {
    grid-template-columns: minmax(0, 1fr);
    padding-inline: var(--gutter);
    gap: clamp(20px, 6vw, 40px);
  }
  /* The drawing follows its copy at every width - the alternating sides are
     a two-column idea and mean nothing in one column. */
  .mat-block--art-left .mat-block__copy,
  .mat-block--art-left .mat-art {
    order: 0;
  }
  .mat-block {
    min-height: 0;
    padding-block: calc(var(--mat-cell) * 0.9);
  }
}

/* --------------------------------------------------------------------------
   The dark run - homepage, division stack to footer
   24 Aug 2026, client. Placeholder director imagery; the three pictures are
   stand-ins.

   One section carrying three things that used to be three: the directors, the
   closing call to action, and the logo conveyor. It is one box rather than
   three sharing a colour because the photograph behind it has to be
   continuous - three backgrounds cannot line up across three boxes of
   different heights, and one can.
   -------------------------------------------------------------------------- */
.darkrun {
  position: relative;
  /* Its own stacking context, so the backdrop's negative z-index stays inside
     this section rather than sliding under the page. */
  isolation: isolate;
  overflow: hidden;
  background: var(--navy);
  /* Nothing at the top: the division stack above hands straight over to the
     directors' frame with nothing between them, which is the point of bringing
     the two together.

     The bottom is the room under the marks before the footer, and it lives
     here rather than on the conveyor because the conveyor cannot carry it.
     .conveyor.is-live in home.css is a balanced trio - 26px of padding for the
     focus scale, -52px of margin, and a -26px shift - whose whole purpose is
     to add nothing to the layout, so padding set on it is cancelled by its own
     compensation. The 26px overhang that trio leaves below the track sits
     inside this padding. */
  padding: 0 0 clamp(var(--sp-6), 12vh, 140px);
}
/* The one photograph the whole run sits on. Dimmed for the same reason the
   contact block dims it: at full strength it competes with everything it is
   meant to be behind. */
.darkrun__bg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
  opacity: 0.5;
  z-index: -1;
}

/* --- The directors ------------------------------------------------------
   The row's padding and its gap are the same length, driven by one custom
   property: set them separately and the border down the middle stops matching
   the border at the edge, which is the one thing that would give it away as
   two decisions rather than one. The border no longer stops at the bottom of
   the row - it carries on as the run's own ground.
   --------------------------------------------------------------------- */
.dirs {
  --dirs-gap: clamp(12px, 1.5vw, 24px);
  padding: var(--dirs-gap);
}
/* Sized from the cell count, not from a hard-coded three (10 Sep 2026): the
   placeholder third cell came out and two portraits at a fixed 1/3 each would
   have left a third of the row empty. grid-auto-flow: column lays every cell
   on one row and grid-auto-columns splits it evenly between however many
   there are, so adding a third director back is a matter of adding the <li>
   and changing nothing here. minmax(0, 1fr) rather than 1fr so a wide
   portrait cannot push the track past its share - 1fr floors at min-content. */
.dirs__row {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: minmax(0, 1fr);
  gap: var(--dirs-gap);
}
.dirs__cell {
  overflow: hidden;
}
.dirs__cell img {
  display: block;
  width: 100%;
  /* Big, per the brief: the portraits are the section, not a strip of
     thumbnails under one. Viewport-relative so the row keeps its proportions
     from a laptop up to a large monitor. */
  height: clamp(340px, 56vh, 660px);
  object-fit: cover;
}

/* --- The two wipes ------------------------------------------------------
   Both are clip-path insets, so nothing moves and nothing scales: what
   changes is how much of an image is painted. inset() reads
   top/right/bottom/left, so a bottom of 100% hides everything and drawing it
   back to 0 uncovers the picture downward; a left of 100% does the same from
   the right-hand edge.

   The order is the point. The three portraits go first, a beat apart, and the
   photograph behind them follows once they are nearly down - so it reads as
   the ground arriving behind pictures that are already there, rather than
   everything starting at once.
   --------------------------------------------------------------------- */
/* Guarded exactly as .crew__cell and .crew__bg are, and for the same reason -
   see the note there (7 Sep 2026). This is the homepage's own copy of that
   wipe, armed by js/home.js rather than js/main.js, and it had the identical
   fault: with no JS the directors' row is an empty dark band. Fixed here in
   the same pass rather than left as a known bug in a sibling component. */
@media (scripting: enabled) {
  .dirs__cell { clip-path: inset(0 0 100% 0); }
  .darkrun__bg { clip-path: inset(0 0 0 100%); }
}

.darkrun.is-in .dirs__cell,
.darkrun.is-in .darkrun__bg {
  clip-path: inset(0 0 0 0);
}
.darkrun.is-in .dirs__cell {
  transition: clip-path 900ms cubic-bezier(0.16, 1, 0.3, 1);
}
.darkrun.is-in .dirs__cell:nth-child(2) { transition-delay: 130ms; }
.darkrun.is-in .dirs__cell:nth-child(3) { transition-delay: 260ms; }
.darkrun.is-in .darkrun__bg {
  transition: clip-path 1100ms cubic-bezier(0.16, 1, 0.3, 1) 620ms;
}

/* --- The closing block, on the dark run ---------------------------------
   Same copy and the same spacing it had on the eggshell surface; only the ink
   changes, because it is now over a photograph rather than a flat sheet.
   --------------------------------------------------------------------- */
.darkrun .closing {
  padding-block: clamp(var(--sp-6), 12vh, 140px);
  text-align: center;
}
.darkrun .closing h2 { color: var(--white); }
.darkrun .closing .mono-label { color: rgba(255, 255, 255, 0.62); }
/* Navy on white would vanish here. The swap is the one the header already
   makes over the video hero: solid white, navy text, and the same --bg-tint
   hover, so it stays a filled button rather than becoming an outline. */
.darkrun .closing .btn-primary {
  background: var(--white);
  border-color: var(--white);
  color: var(--navy);
}
.darkrun .closing .btn-primary:hover,
.darkrun .closing .btn-primary:focus-visible {
  background: var(--bg-tint);
  border-color: var(--bg-tint);
}

/* --- The conveyor, on the dark run --------------------------------------
   The marks are painted by .bcard.has-logo::after, which fills a mask with
   --mark-ink, and the hairlines above and below each card come from
   --mark-rule. Both are custom properties on .conveyor, so turning the whole
   row white is a matter of redefining those two here rather than restyling
   any of the parts. .bcard__word is real text and takes its colour directly.
   --------------------------------------------------------------------- */
.darkrun .conveyor {
  --mark-ink: var(--white);
  --mark-rule: rgba(255, 255, 255, 0.18);
}
.darkrun .bcard__word { color: var(--white); }
/* The no-JS floor: without .has-logo the artwork shows in its own colours,
   which are dark, and here that means invisible. Knocked out to white the same
   way the header inverts the Lincor lockup over the video hero. */
.darkrun .bcard:not(.has-logo) .bcard__logo {
  filter: brightness(0) invert(1);
}

@media (max-width: 760px) {
  /* Three columns on a phone is three thumbnails. Stacked, each portrait is
     still the full width of the screen and still big, which is the point. */
  .dirs__row {
    grid-auto-flow: row;
  }
  .dirs__cell img {
    height: clamp(260px, 42vh, 420px);
  }
  /* The stagger is between columns; stacked, the second and third are a screen
     away and would arrive already finished. */
  .darkrun.is-in .dirs__cell:nth-child(2),
  .darkrun.is-in .dirs__cell:nth-child(3) { transition-delay: 0ms; }
}

@media (prefers-reduced-motion: reduce) {
  .dirs__cell,
  .darkrun__bg,
  .darkrun.is-in .dirs__cell,
  .darkrun.is-in .darkrun__bg {
    clip-path: none;
    transition: none;
  }
}

/* --------------------------------------------------------------------------
   manufacture.html - the sub-brands, as ONE section (7 Sep 2026, client)

   Plaspro and Mouldman are two blocks on a single eggshell ground: copy one
   side, the photograph as a card on the other, mirrored between them.

   This replaced a pinned scroll-through, and the history is worth one
   paragraph because it explains what is NOT here any more. Each brand used to
   be a full-viewport block with a sticky stage inside it, holding a
   full-bleed photograph under a dark scrim with the copy laid over it; the
   blocks overlapped 12vh and descended in z-index so one lifted away to
   reveal the next. All of that - .slide-stage and every [data-mfg-choreo]
   rule, which is what js/manufacture.js set to arm it - was deleted here. The
   brief was for the two brands to read as one section rather than as two
   takeovers of the page.

   Two things it took with it, both deliberate:

   The / 01 / and / 02 / index tags are gone from the markup at the client's
   request. .index-tag itself is untouched - app-development.html and
   property.html still use it.

   js/manufacture.js was restructured in the same change and HAD to be: its
   scroller code and its hero code shared one function behind
   `if (!scroller) return`, so removing this markup would have silently
   stopped the hero's scrub tween as well. See that file's header.

   Section 9a above still holds the base .division-scroller / .division-slide
   rules. Nothing in the repo carries that markup now - import-export.html's
   only mention is a comment about having removed its own - so those rules and
   the generic scroller block in js/main.js are both dead. They are left for a
   separate pass rather than swept up here: they are shared machinery, and the
   two are entangled.
   -------------------------------------------------------------------------- */
.mfg-brands {
  position: relative;
  /* Its own stacking context, so the texture's negative z-index stays inside
     this section rather than sliding under the page. */
  isolation: isolate;
  overflow: hidden;
  /* The eggshell the client asked for: --shell is the site's page ground
     (Shoji White SW 7042), not a colour invented for this section. */
  background: var(--shell);
  /* The brand name's size, declared once here because two rules need it: the
     heading itself, and the clip box the reveal slides that heading out of.
     See the reveal notes further down. */
  --mfg-name: clamp(32px, 4.5vw, 64px);
}

/* The texture. The client's line drawing of a moulding floor, sitting ON the
   eggshell rather than bringing its own ground with it.

   That is why it is keyed rather than used as supplied. The source is black
   ink on white paper, and white paper is opaque - dropped in as-is it would
   have covered the eggshell completely and the section would have been a
   white box. images/manufacture-texture.* is the same drawing with the paper
   removed: alpha is derived from the source's luminance, so every line keeps
   its antialiased edge instead of being thresholded into jaggies, and 71% of
   the sheet is now fully transparent. The ink is recoloured to Lincor navy
   on the way through, so the drawing belongs to the palette rather than
   sitting on it as a foreign black.

   A pseudo-element, not a background on the section itself, because opacity
   is the whole point: at full strength a perspective drawing behind body copy
   is a picture competing with text.

   0.06, down from 0.16 (8 Sep 2026, client: "a lot more faint"). At 0.16 the
   drawing read as an illustration the copy was sitting on top of; at 0.06 it
   is a grain in the eggshell that you notice only once you look for it.

   Contrast against the worst case - solid ink at full alpha, an effective
   ground of rgb(230,231,228): --slate body copy 6.30:1 and --navy headings
   12.86:1, against AA's 4.5:1 floor. On bare eggshell the same two are
   7.10:1 and 14.48:1, so at this strength the texture costs body copy about
   a tenth of its headroom rather than the quarter it cost at 0.16. For
   reference if this is ever retuned: 0.08 gives 6.06:1, 0.16 gave 5.17:1.

   Masked to fade at top and bottom so it reads as a ground the section sits
   on rather than a picture that starts and stops. Same device as the halftone
   field on the Design page's work grid.

   image-set() so the browser takes AVIF (244 KB) over WebP (466 KB) over PNG
   (755 KB); the plain url() before it is the fallback for anything that does
   not understand image-set. cover rather than repeat: it is one drawing in
   perspective, and tiling it would put a hard seam through the middle. */
.mfg-brands::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  opacity: 0.06;
  background-image: url(../images/manufacture-texture.png);
  background-image: image-set(
    url(../images/manufacture-texture.avif) type("image/avif"),
    url(../images/manufacture-texture.webp) type("image/webp"),
    url(../images/manufacture-texture.png) type("image/png"));
  background-position: center;
  background-size: cover;
  background-repeat: no-repeat;
  -webkit-mask-image: linear-gradient(to bottom, transparent, #000 12%, #000 88%, transparent 100%);
          mask-image: linear-gradient(to bottom, transparent, #000 12%, #000 88%, transparent 100%);
}

.mfg-brands__inner {
  position: relative;
  z-index: 1;
}

/* Copy in one column, the photograph as a card in the other, mirrored between
   the two brands. The card is the wider side in both directions, so the
   rhythm is the alternation and not a change of proportion.

   The proportions are the Design page's feature blocks used as a reference
   (8 Sep 2026, client): 4fr/6fr columns, 56px between them, a 5:4 card at a
   40px radius, 24px between the heading and its sentence, and 120px between
   blocks. Those are .mz-feature's own numbers rather than approximations of
   them, so the two pages read as the same device.

   The flip is `order` on the two children rather than a second set of column
   rules, so the DOM keeps reading heading-then-picture for a screen reader
   and for a no-CSS render, whichever side the picture is painted on. */
.mfg-brand {
  display: grid;
  grid-template-columns: 4fr 6fr;
  gap: clamp(32px, 4vw, 56px);
  align-items: center;
}

.mfg-brand + .mfg-brand { margin-top: clamp(72px, 8vw, 120px); }

.mfg-brand--flip { grid-template-columns: 6fr 4fr; }
.mfg-brand--flip .mfg-brand__text { order: 2; }
.mfg-brand--flip .mfg-brand__card { order: 1; }

.mfg-brand__text {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: clamp(16px, 1.7vw, 24px);
}

/* The brand's own mark, as the mini header above its name (8 Sep 2026,
   client). Sized by HEIGHT with width auto, because the two logos are
   different shapes - 339x202 and 454x261 - and matching their widths would
   have left one visibly larger than the other. Height keeps them optically
   equal, which is what reads as a consistent label.

   Small enough to be a header rather than the headline: 44px against the
   name's 64px at full size, so the eye lands on the name.

   No max-width needed: measured at 1440 the two come out 74px and 77px wide
   against a 502px column, and 50px/52px at 390 against 350px. */
.mfg-brand__logo {
  display: block;
  height: clamp(30px, 3.1vw, 44px);
  width: auto;
}

/* The brand name, at the scale the client asked for. This is the same clamp
   .mz-display--block carries on the Design page - up from the sitewide h3's
   flat 1.4rem, which at 22px was a caption next to a 549px-tall picture.

   The FACE is deliberately not copied with the size: .mz-display is
   Montserrat, which belongs to Mezzo's own scoped system and must not leak
   onto the other seven pages. This keeps the site's display face and borrows
   only the proportion. line-height 1 because at 64px the default leading
   opens a visible gap under a single word.

   The size is a custom property rather than a literal because the reveal
   below has to clip against it - see --mfg-name there for why a second copy
   of this clamp would have been a bug waiting on the next resize. */
.mfg-brand__text h3 {
  font-size: var(--mfg-name);
  line-height: 1;
  margin: 0;
}

.mfg-brand__text p { margin: 0; }

/* 5:4, matching the Design page's visual. The alternative was keeping the
   photographs' own shape, which is what made the card look small beside its
   own copy in the first place.

   The crop this costs got much cheaper on 8 Sep 2026, when the client's real
   photographs replaced the images/generated/ stand-ins: those were 1.75:1 and
   lost about 29% off the sides, where these are 4:3 and lose 6.25% (1.333
   against the box's 1.25, so 3.1% off each edge). Near enough the whole frame
   in both cards now. */
/* No ground of its own (8 Sep 2026, client). It carried --bg-tint, which at
   rest was invisible - the opened ellipse covers the whole frame, so nothing
   of the card showed through it - but the reveal put it on screen: for the
   second that the ellipse was growing, the photograph opened inside a
   near-white rounded panel instead of out of the page.

   transparent rather than --shell, so it is genuinely the section's ground
   and not a match for it. The eggshell is only half of what is behind this
   box; the line drawing is the other half, and a flat fill would have held
   the drawing out of the card's rectangle and drawn its own edge exactly
   where the panel used to be. Transparent lets the texture run straight
   through, so during the burst there is nothing there at all.

   Nothing about the burst changes - it is the same clip on the same element -
   and nothing about the resting state changes either, because the resting
   state never showed this colour. The one other moment it was doing work was
   before the photograph decoded, where it is now equally invisible. */
.mfg-brand__card {
  margin: 0;
  border-radius: 40px;
  overflow: hidden;
  aspect-ratio: 5 / 4;
  background: transparent;
}

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

/* --- The arrival --------------------------------------------------------
   8 Sep 2026, client: the reveal the Import & Export sub-brands get, brought
   across to these two. Two separate arrivals per block, fired once when the
   block is properly in view by the observer in js/manufacture.js - not a
   scroll-linked effect, so nothing recomputes for the rest of the page's
   life.

   1. The copy sets line by line. Each child of the text column gets a
      clipping box of its own and slides up out of it, one after another, so
      the type reads as being SET rather than as fading in. Here a "line" is
      the brand mark, the name, the paragraph. The boxes are built by
      wrapText() in js/manufacture.js and only exist while the section is
      armed.
   2. The photograph opens out of its own centre - an ellipse growing from
      nothing to larger than the frame. That is the Menu button's expansion
      (js/nav.js), which is where the mat borrowed it from too, on the same
      curve and close to the same beat.

   Both halves are the mat's numbers exactly: 900ms on the same cubic-bezier
   with a 260/350/440ms stagger for the copy, 1000ms from 300ms in for the
   picture, ellipse 0% to 72%. 72% is the covering size - the box's corner
   sits at (50%, 50%) of the semi-axes and 0.5^2 + 0.5^2 = 0.5, so anything
   past 71% has the whole frame inside it - with a little over for the
   sub-pixel edge. clip-path rather than a scale, so the photograph never
   resamples and there is no softness to settle at the end.

   ONE NUMBER IS DERIVED RATHER THAN COPIED, and it is the descender
   allowance. Worth the paragraph, because copying it would have been a
   visible fault on the first block.

   The mat clips its lines with inset(0 -0.5em -0.16em -0.5em): 2.56px of
   room below the box, em against its copy column's 16px. That is nowhere
   near enough for a heading here. .mfg-brand__text h3 is line-height 1, so
   its box is exactly 1em, and its glyphs fall 8px BELOW that box at 1440px -
   measured, with 4.5px at 900 and 4px at 390. Copied as-is, the clip would
   have shaved 5px off the tails of both p's in "Plaspro", permanently, at
   rest - not during the animation, forever after it.

   The mat never shows this because not one of Nexus Global, Subsahara or
   Signature Brands contains a descender. The fault is latent there; it would
   have been on screen here.

   So: the allowance is 0.2em against the LINE'S OWN size, which is what
   .mfg-line--name is for - it carries --mfg-name so that em is 12.8px at
   1440 and 6.4px at the phone size, past the ink at both, and tracks the
   heading's clamp on resize instead of being a second copy of it that drifts.
   (A px value measured once and written in is the same trap the mat hit and
   documented: a value frozen at load stays frozen while the clamp moves.)

   And the travel is calc(100% + 0.4em) rather than the mat's flat 118%. 118%
   only hides a line by 18% of its own height, which for a line-height-1
   heading is 0.18em - LESS than the 0.2em the clip has just been widened by,
   so the top of the hidden heading would have sat inside its own clip and
   showed as a sliver before it moved. Deriving the travel from the same em
   keeps the hidden line 0.2em clear of the widened clip whatever the box's
   height-to-font-size ratio turns out to be. Same movement to look at.
   -------------------------------------------------------------------- */
.mfg-line {
  display: block;
  /* clip-path, not overflow: hidden - it clips the subtree without
     establishing a formatting context, so it stays invisible to the layout.
     The side insets are negative to keep the clip off a focus ring. */
  clip-path: inset(-0.2em -0.5em -0.2em -0.5em);
}
/* The name's box, sized to the name so the em above resolves against 64px
   rather than the column's 16px. It sets no line box of its own - a block
   with a single block child - so this changes the size of nothing. */
.mfg-line--name {
  font-size: var(--mfg-name);
}
html.mfg-armed .mfg-line > * {
  transform: translateY(calc(100% + 0.4em));
  transition: transform 900ms cubic-bezier(0.16, 1, 0.3, 1);
}
html.mfg-armed .mfg-brand.is-drawn .mfg-line > * {
  transform: none;
}
/* Delays rather than a per-line custom property, for the mat's reason: there
   are only ever three lines in a block - mark, name, paragraph - and a fourth
   would simply arrive with the third, which is a better failure than a line
   that never moves. */
html.mfg-armed .mfg-line:nth-child(1) > * { transition-delay: 260ms; }
html.mfg-armed .mfg-line:nth-child(2) > * { transition-delay: 350ms; }
html.mfg-armed .mfg-line:nth-child(3) > * { transition-delay: 440ms; }
html.mfg-armed .mfg-line:nth-child(n+4) > * { transition-delay: 530ms; }

/* The picture. The 40px corner stays where it is - it is on .mfg-brand__card,
   which is a separate clip, and the ellipse finishes larger than the frame
   anyway, so what defines the corners at rest is still the card. */
html.mfg-armed .mfg-brand__card img {
  clip-path: ellipse(0% 0% at 50% 50%);
  transition: clip-path 1000ms cubic-bezier(0.215, 0.61, 0.355, 1);
  transition-delay: 300ms;
}
html.mfg-armed .mfg-brand.is-drawn .mfg-brand__card img {
  clip-path: ellipse(72% 72% at 50% 50%);
}

@media (prefers-reduced-motion: reduce) {
  /* The JS draws every block on the spot under reduced motion, so this is
     belt and braces for the case where the class is armed but nothing should
     move. .mfg-brand in the second selector to match the not-yet-drawn rule
     above for specificity and win on order. */
  html.mfg-armed .mfg-line > *,
  html.mfg-armed .mfg-brand__card img {
    transition: none;
    transform: none;
    clip-path: none;
  }
}

@media (max-width: 860px) {
  /* One column, and the order reset with it: stacked, `order` would put the
     picture above its own heading on the flipped block. */
  .mfg-brand,
  .mfg-brand--flip { grid-template-columns: 1fr; }
  .mfg-brand--flip .mfg-brand__text,
  .mfg-brand--flip .mfg-brand__card { order: 0; }
}

/* Flow Master's section came out 25 Aug 2026 (client) along with the
   not-sticky, parallax-only treatment that made it the page's release. "Our
   brands" is the release now, and it needs nothing: it is an ordinary
   section. */

/* The .logo-seq stack that held Unitwist's three supporting marks in one
   shared cell is gone with the Unitwist section (25 Aug 2026, client). It was
   the only thing that used it. */

/* --------------------------------------------------------------------------
   The team band (25 Aug 2026, client)

   The same device as the directors' row on the homepage - three portraits on
   a photograph that shows through as a border around and between them - but
   as a component the division pages can each carry one of, and with a deeper
   band of that photograph above and below the row than the homepage has.

   Where no real photography has been supplied, every cell points at one flat
   SVG placeholder. That is deliberate: an obviously empty slot reads as "a
   person goes here", where a stand-in photograph of a building reads as a
   mistake. The backdrop IS a real photograph, chosen per page from that
   division's own imagery, so the band looks like the finished thing while the
   slots do not pretend to be filled. app-development.html is past that stage
   and carries one real portrait - see .crew__row--single at the end of this
   block.
   -------------------------------------------------------------------------- */
.crew {
  --crew-gap: clamp(12px, 1.5vw, 24px);
  position: relative;
  /* Its own stacking context, so the backdrop's negative z-index stays inside
     this section rather than sliding under the page. */
  isolation: isolate;
  overflow: hidden;
  background: var(--navy);
  /* The one difference from the homepage row: the band above and below the
     portraits is several times the gap between them, rather than equal to it.
     Overrides the sitewide `section { padding-block: var(--section-pad) }`. */
  padding: clamp(56px, 8vw, 128px) var(--crew-gap);
}
.crew__bg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
  /* Dimmed for the same reason the homepage's is: at full strength it
     competes with the portraits it is meant to sit behind. */
  opacity: 0.45;
  z-index: -1;
}
.crew__row {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--crew-gap);
  list-style: none;
  margin: 0;
  padding: 0;
}
.crew__cell {
  overflow: hidden;
}
.crew__cell img {
  display: block;
  width: 100%;
  height: clamp(340px, 56vh, 660px);
  object-fit: cover;
}

/* The wipes, as on the homepage: the portraits uncover downward a beat apart,
   and the photograph behind them follows in from the right once they are
   nearly down - so the ground reads as arriving behind pictures that are
   already there. clip-path, so nothing moves and nothing scales; what changes
   is how much of each image is painted. */
/* Behind @media (scripting: enabled) for the same reason .reveal-on-load is,
   and found the same way (7 Sep 2026). Only script adds .is-in - js/main.js
   does it on the IntersectionObserver path and again on the fallback path for
   browsers without one - so with no JS at all nothing removes these two
   insets and the band renders as an empty navy strip: the cells clipped to
   zero height, the photograph behind them clipped to zero width. Measured at
   1440x900 with JavaScript off, on manufacture.html: clip-path computed as
   inset(0px 0px 100%) with the cell still 444px tall and its image fully
   decoded. Four division pages carried it.

   Why it went unnoticed for two weeks: clip-path clips PAINT, not layout. The
   cells keep their full geometry and opacity 1, so nothing in tools/verify.mjs
   registered a fault - not the invisible-content check, which reads opacity
   and viewport intersection, and not the clipping check, which looks for
   ancestor overflow. The harness has since been taught to see this; see the
   clip-path case in __vtState. It only became visible to a person when the
   placeholders here were replaced by photographs of four named people. */
@media (scripting: enabled) {
  .crew__cell { clip-path: inset(0 0 100% 0); }
  .crew__bg { clip-path: inset(0 0 0 100%); }
}
.crew.is-in .crew__cell,
.crew.is-in .crew__bg { clip-path: inset(0 0 0 0); }
.crew.is-in .crew__cell {
  transition: clip-path 900ms cubic-bezier(0.16, 1, 0.3, 1);
}
.crew.is-in .crew__cell:nth-child(2) { transition-delay: 130ms; }
.crew.is-in .crew__cell:nth-child(3) { transition-delay: 260ms; }
/* import-export.html runs six cells over two rows. The delay is per column
   rather than per cell, so the second row wipes down alongside the first
   instead of trailing half a second behind it - six beats in sequence reads
   as a list being dealt out, three reads as one movement. */
.crew.is-in .crew__cell:nth-child(4) { transition-delay: 0ms; }
.crew.is-in .crew__cell:nth-child(5) { transition-delay: 130ms; }
.crew.is-in .crew__cell:nth-child(6) { transition-delay: 260ms; }
.crew.is-in .crew__bg {
  transition: clip-path 1100ms cubic-bezier(0.16, 1, 0.3, 1) 620ms;
}

@media (max-width: 760px) {
  /* Three columns on a phone is three thumbnails. Stacked, each portrait is
     still the full width of the screen. */
  .crew__row { grid-template-columns: minmax(0, 1fr); }
  .crew__cell img { height: clamp(260px, 42vh, 420px); }
  /* The stagger is between columns; stacked, the second and third are a
     screen away and would arrive already finished. */
  .crew.is-in .crew__cell:nth-child(2),
  .crew.is-in .crew__cell:nth-child(3) { transition-delay: 0ms; }
}

@media (prefers-reduced-motion: reduce) {
  .crew__cell,
  .crew__bg,
  .crew.is-in .crew__cell,
  .crew.is-in .crew__bg {
    clip-path: none;
    transition: none;
  }
}

/* One portrait instead of three (app-development.html, 1 Sep 2026).

   The three-column grid would stretch a lone cell across the whole band, so
   this gives it a single centred column capped at 720px - min() rather than a
   bare 720px so it still fits a phone, where the cap is the screen.

   Both rules are two classes deep, so they beat the base `.crew__cell img`
   and its 760px override wherever those sit in the file. The base cell is a
   fixed-height box with object-fit: cover, sized for a portrait crop; the
   photograph here is 4:3 landscape, and covering that box would cut its
   sides off. Height auto plus the source's own ratio shows it whole, and the
   ratio is declared so the space is reserved before the image arrives. */
.crew__row--single {
  grid-template-columns: minmax(0, min(720px, 100%));
  justify-content: center;
}
.crew__row--single .crew__cell img {
  height: auto;
  aspect-ratio: 1448 / 1086;
}

/* Two 3:4 portraits, capped and centred (property.html, 8 Sep 2026).

   The band was three shared placeholder SVGs and the client's answer was two
   people, so the third cell is gone rather than left empty. Two cells in the
   base three-column grid would have sat hard left with a column of nothing
   beside them, which is the whole of what "centred like the rest" is asking
   for: .crew__row--six calls itself a centred block on the photograph and
   .crew__row--four becomes one at its two-up breakpoint, and this is the same
   idiom for two.

   920px is not a new number - it is what .mz-crew-pair caps the Design page's
   two-person band at, which is the site's existing answer to exactly this
   question. At that cap and the row's 24px gap maximum the cells come out
   448x597, which sits between the four-up's 333x444 and the base cell's 660px
   ceiling.

   Both cell rules are two classes deep, so they beat the base .crew__cell img
   and its 760px override wherever those sit in the file. The base cell is a
   fixed-height cover box; these two are centre-cropped to 3:4 on the way in,
   so they declare that ratio and take their height from it, and nothing is
   cropped by CSS at any width.

   THE NARROW FORM HAS TO BE DECLARED, and assuming otherwise was a bug for
   the length of one measurement. The base 760px block stacks `.crew__row` to
   one column, and I took that as covering this row too. It does not: both
   selectors are one class deep, so they are the same specificity and the one
   further down the file wins - and this rule is below that block, so a
   two-column grid was winning at every width including 360px. Measured, the
   cells came out 162px wide there, the same as the four-up's 2x2, and both
   job titles hit the label's ellipsis while "Sue Prinsloo" wrapped. A media
   query cannot break the tie; only order or specificity can. So the narrow
   form is spelled out below, the way .crew__row--four spells its own out.

   Stacked rather than held two-up, which is the opposite of what the four-up
   needed: four stacked was 4.5 screens of band, but two is about 1.2 with the
   faces at full width, and at 390 that is a 366px cell against the 177px a
   two-up gives. The base block also zeroes cell two's wipe delay there, which
   is right once the two are a screen apart. */
.crew__row--two {
  grid-template-columns: repeat(2, minmax(0, 1fr));
  max-width: 920px;
  margin-inline: auto;
}
.crew__row--two .crew__cell img {
  height: auto;
  aspect-ratio: 3 / 4;
}
@media (max-width: 760px) {
  .crew__row--two { grid-template-columns: minmax(0, 1fr); }
}

/* Six near-square portraits over two rows (import-export.html, 2 Sep 2026).

   The supplied portraits are 356x374 - very nearly square, and that is the
   whole of them; they came out of the client's own cards at that size, so
   there is no larger original to go back to.

   Two consequences, and this rule is both of them.

   The cells keep the source's own ratio instead of the band's default tall
   box. Covering a 340-660px tall cell with a square frame would crop the
   sides off a head-and-shoulders portrait and upscale what was left.

   And the row is capped at the width where three of those cells plus their
   two gaps come out at native resolution, then centred. Past that cap every
   portrait would be painted larger than it exists and the whole row would go
   soft; below it they downscale, which costs nothing. So the band is a
   centred block on the photograph rather than edge-to-edge - the deliberate
   consequence of the pictures being the size they are.

   Both cell rules are two classes deep, so they beat the base .crew__cell img
   and its 760px override wherever those sit in the file. */
.crew__row--six {
  /* 3 cells at 356 + 2 gaps at the row's own 24px maximum. */
  max-width: 1128px;
  margin-inline: auto;
}
.crew__row--six .crew__cell img {
  height: auto;
  aspect-ratio: 356 / 374;
}

/* Four 3:4 portraits in one row (manufacture.html, 7 Sep 2026).

   The supplied photographs are 1086x1448 and one 1150x1368, all centre-
   cropped to a single 3:4 on the way in, so the cells declare that ratio and
   take their height from it rather than covering the band's default fixed-
   height box. Nothing is cropped by CSS at any width - the four just scale
   together, which is what keeps a row of four faces looking like one sitting.

   Three widths, because four across stops being faces and starts being
   thumbnails as the viewport narrows. Measured at the 24px gap maximum:
   at 1440 each cell is ~348px, at 1200 ~277px, and by 1024 ~250px, which is
   a contact sheet. Two-up holds them near 500px instead, and the existing
   760px rule stacks them one per screen below that.

   Both cell rules are two classes deep, so they beat the base
   `.crew__cell img` and its 760px override wherever those sit in the file. */
.crew__row--four {
  grid-template-columns: repeat(4, minmax(0, 1fr));
}
.crew__row--four .crew__cell img {
  height: auto;
  aspect-ratio: 3 / 4;
}

/* The fourth cell's wipe delay. The base block sets nth-child(4) to 0ms
   deliberately, for import-export.html's six cells over two rows, where cell
   four is the start of the second row and has to come down alongside cell
   one. Here all four are in ONE row, so inheriting that would land the last
   portrait at the same instant as the first and break a left-to-right
   cascade into two simultaneous pairs. 390ms continues the 130ms step.

   Five classes deep against the base rule's four, so it wins on specificity
   rather than on source order. */
.crew.is-in .crew__row--four .crew__cell:nth-child(4) {
  transition-delay: 390ms;
}

@media (max-width: 1099px) {
  .crew__row--four { grid-template-columns: repeat(2, minmax(0, 1fr)); }

  /* Capped and centred on the photograph, as .crew__row--six is, and for a
     reason worth writing down: without the cap, halving the column count
     doubles each cell's width, which doubles its height, and the second row
     doubles the band again. Measured across the breakpoint before this rule
     existed - 1102px: band 516px, cells 255 wide. 1099px: band 1592px, cells
     525 wide. Three times the height and twice the face for a 3px change in
     viewport, and 2.27 screens of team photographs at tablet width against
     the 1.26 that import-export.html's six portraits take.

     560 is just above the width at which two cells plus their gap equal the
     four-up cell size at the breakpoint (525px), so crossing 1100 steps the
     portraits from 255 to 272 wide instead of from 255 to 525. min() rather
     than a bare 560px so the row still fits a viewport narrower than the cap
     before the stacked rule below takes over. */
  .crew__row--four { max-width: min(560px, 100%); margin-inline: auto; }
}

/* This row does NOT stack to one column on a phone, and it is the only one
   that does not. The base 760px rule stacks .crew__row because three portraits
   side by side on a phone are three thumbnails - true, and it stays true for
   the other bands. Four is a different shape: it grids into 2x2 evenly, where
   three cannot, and stacking it is what makes the band unreadable rather than
   what saves it.

   Measured with the stack in place: 864px of band at 761px wide, then 3144px
   at 760px - 1.23 screens to 4.49 across a single pixel, because one column
   doubles the cell width, which doubles its height, over four rows. Nearly
   four and a half screens of one band, for four people. Held at 2x2 the same
   viewport is 596px, and it is continuous with the two-up above it rather
   than tripling at a breakpoint.

   The gap is that a 2x2 at 360px gives cells about 162px wide, so the name
   over each foot is checked at that width rather than assumed - see the
   .crew__row--four override on .portrait-label__name below. */
@media (max-width: 760px) {
  .crew__row--four { grid-template-columns: repeat(2, minmax(0, 1fr)); }

  /* The base 760px block zeroes the delay on cells two and three, because
     stacked they are a screen apart and would otherwise arrive already
     finished. This row is not stacked here, so it wants its cascade back -
     all four cells are on screen together. Restated at one class deeper than
     the rules being overridden, and after them. */
  .crew.is-in .crew__row--four .crew__cell:nth-child(2) { transition-delay: 130ms; }
  .crew.is-in .crew__row--four .crew__cell:nth-child(3) { transition-delay: 260ms; }

  /* Holding 2x2 down to the narrowest phone means the name has to fit a cell
     about 160px wide instead of the 330px the four-up gives it, and the
     shared sizing does not. Measured with it: at 360px "Justin Foster" and
     "Wesley Piper" both wrapped to two lines, at 320px all four did, and the
     scrim grew from 21% of the cell at 1440px to 40% at 360 and 57% at 320,
     because its 48px minimum top padding does not care how short the cell is.

     The cap is 1.25rem, which is exactly the base rule's floor, so the two
     agree at this breakpoint rather than stepping. Both overrides are scoped
     to this row - the other bands' labels are unchanged. */
  .crew__row--four .portrait-label {
    padding-top: clamp(20px, 8%, 96px);
    padding-inline: 8px;
  }
  .crew__row--four .portrait-label__name {
    font-size: clamp(0.8125rem, 4.4vw, 1.25rem);
  }

  /* The job titles arrived a day after the names (8 Sep 2026, client) and the
     longest of them does not fit a 2x2 cell on a phone. Measured: "Head Of
     Administration" wants 163px of tracked uppercase, and the cell gives it
     146px at 360 and 161px at 390 - so the base rule's ellipsis engaged and it
     read "HEAD OF ADMINISTRA...". The other three fit at every width, and all
     four fit from 768px up, which is why this is scoped to this breakpoint and
     this row.

     WRAPPING RATHER THAN SHRINKING. Getting it onto one line at 360 needs the
     tracking down to about 0.06em, which lands with less than a pixel spare -
     a rule that passes today and breaks the first time someone lengthens a
     title. Two lines is the same device .portrait-label__region already uses
     for Import & Export's long regions, and it says the whole title at full
     size.

     The min-height is what keeps the names level. Without it Kelly's name
     sits a line higher than Justin's beside her in the 2x2, which reads as a
     mistake rather than as a longer title. Two lines' worth whether a title
     needs it or not; a title is free to be one line, it just cannot make the
     label shorter than two. 1.2em to match the base rule's line-height.

     Tracking is deliberately left at the base 0.14em: with the title on two
     lines the longest word is "ADMINISTRATION" at 104px against the same
     146px, so there is nothing to buy by tightening it, and the mono
     treatment stays identical to every other small label on the site. */
  .crew__row--four .portrait-label__role {
    white-space: normal;
    min-height: calc(2 * 1.2em);
  }
}

/* --------------------------------------------------------------------------
   19b. Portrait label
   --------------------------------------------------------------------------
   A person's name and title over the foot of their photograph. Used by the
   homepage directors' row and by the team band on the division pages - one
   implementation rather than two, because it arrived on the second row a day
   after the first and the two must not drift apart.

   Wrap the <picture> and a <figcaption> in .portrait-figure inside whichever
   cell the row uses. The figure is the positioning context, not the cell:
   both rows put the clip-path wipe on the cell, and an absolutely positioned
   child of that would be measured against a box mid-clip. display: block with
   the default figure margin off, so the figure is exactly the picture's box
   and "bottom" means the bottom of the photograph.
   -------------------------------------------------------------------------- */
.portrait-figure {
  position: relative;
  display: block;
  margin: 0;
}
.portrait-label {
  position: absolute;
  inset: auto 0 0 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  padding: clamp(48px, 8%, 96px) var(--sp-3) clamp(16px, 3%, 28px);
  text-align: center;
  color: #fff;
  /* These are photographs of people in lit rooms, so white text is legible
     over part of the foot of the frame and not the rest. The scrim buys
     legibility across the width without reading as a solid bar: transparent
     at its own top edge, so it fades in over the padding above the text. */
  background: linear-gradient(to top, rgba(1, 35, 64, 0.88) 0%, rgba(1, 35, 64, 0.62) 45%, rgba(1, 35, 64, 0) 100%);
  /* Decoration. It must never intercept a click or a text selection meant
     for the picture. */
  pointer-events: none;
}
.portrait-label__name {
  font-family: var(--f-display);
  font-weight: 600;
  /* Sized off the viewport rather than the cell, and capped: a bare vw would
     keep growing past the point where the name outweighs the face. */
  font-size: clamp(1.25rem, 2.4vw, 1.75rem);
  line-height: 1.1;
  letter-spacing: -0.01em;
}
.portrait-label__region {
  font-family: var(--f-body);
  /* Not the mono uppercase the role takes. These are long strings - one runs
     to nine words - and tracked-out uppercase would need three or four lines
     of the picture to say them. Sentence case at a small size says the same
     thing in one or two. */
  font-size: clamp(0.6875rem, 0.95vw, 0.8125rem);
  line-height: 1.35;
  margin-top: 4px;
  /* Held off the cell's own edges so a wrapped second line still reads as a
     centred caption rather than as text filling the frame. 30ch rather than
     26 because at 26 the longest of the six regions took a third line, and a
     row where one name sits a line lower than its neighbours reads as a
     mistake. */
  max-width: 30ch;
  /* Two lines' worth of space whether the region needs it or not, so every
     name in a row sits at the same height. Without it the names step up and
     down across the row as each region wraps differently. min-height rather
     than height: a region is free to be one line, it just cannot make the
     label shorter than two. */
  min-height: calc(2 * 1.35em);
  /* Dimmer than the role, which is dimmer than the name: three levels for
     three ranks of information. */
  opacity: 0.72;
}

.portrait-label__role {
  font-family: var(--f-mono);
  /* The mono treatment small labels take sitewide - uppercase, tracked out,
     well below the name. */
  text-transform: uppercase;
  font-size: clamp(0.625rem, 1vw, 0.75rem);
  letter-spacing: 0.14em;
  line-height: 1.2;
  /* One line, as briefed. Both current titles fit at every width; a longer
     one pasted in later would wrap and shove the name up, so this holds the
     shape and ellipsises instead. */
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 100%;
  opacity: 0.85;
}

/* --------------------------------------------------------------------------
   Film hero - manufacture.html (25 Aug 2026, client)

   The hero was typography on the page's own eggshell; it now runs the
   Manufacturing clip behind that same copy. Nothing about the copy's
   structure changes - the eyebrow pair, the heading, the lede and the button
   are the markup they always were - only the ground under them and therefore
   their ink.

   The header is NOT made transparent over this. That machinery is scoped to
   the homepage (body:has(.hero-lock)), where the client asked for the video
   to reach the very top of the page; here the white bar stays and the film
   begins beneath it, which is how every other division page already reads.
   -------------------------------------------------------------------------- */
.hero-film {
  position: relative;
  /* Its own stacking context, so the video and scrim's negative z-indexes
     stay inside this section rather than sliding under the page. */
  isolation: isolate;
  overflow: hidden;
  background: var(--navy);
  /* Tall enough for the footage to read as footage rather than a strip
     behind some type, and capped so it does not become a full screen of
     video on a tall monitor with the copy stranded at the bottom. svh, not
     vh: on a phone vh is the height the page would have if the address bar
     were already collapsed, and it is not on load. */
  /* The footage is the whole header now (25 Aug 2026, client), so the section
     is the screen. svh, not vh: on a phone vh is the height the page would
     have if the address bar were already collapsed, and it is not on load -
     100vh there is a hero that is cut off at the bottom. */
  /* Less the header, not a flat 100svh. The header is sticky and in normal
     flow, so the hero starts 88px down the page - at a flat 100svh the
     bottom-right line finished 88px below the fold and was cut in half on
     load, which is the one line that has to be visible for the composition
     to read. 88px measured at 1920, 1440, 1024, 905, 768, 480, 390 and 320:
     the bar is the same height at every width, so this is a constant rather
     than something that needs measuring at runtime. */
  min-height: calc(100svh - 88px);
  /* Column, stated rather than inherited. The section was picking up a
     column direction from elsewhere, which made align-items govern WIDTH -
     so `align-items: stretch` did nothing to the height and the type block
     sized to its content and sat centred: measured 454px inside a 900px box,
     with "Manufacturing" 270px below the top edge instead of hard against
     it. Declaring the direction and growing the child on the main axis is
     unambiguous either way. */
  display: flex;
  flex-direction: column;
  padding-top: clamp(var(--sp-5), 11vh, 108px);
  /* The 12vh is the overlap the sub-brand sequence is pulled up by (see
     [data-mfg-choreo] .division-scroller): it sits ABOVE this section in the
     stack and paints over the hero's bottom 12vh, which is the whole point -
     Plaspro rises over the hero rather than following it. The type has to
     clear that band or it is simply covered: measured, "The World" and the
     Get a Quote button were both cut in half by it. The video still fills the
     section edge to edge; only the type stops short. */
  padding-bottom: calc(12vh + clamp(var(--sp-5), 7vh, 72px));
}
.hero-film__video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
  z-index: -2;
}
/* Bottom-weighted, because the copy is bottom-aligned: heavy where the type
   sits, almost nothing at the top where the footage should simply be itself.
   The same shape the division slides use, for the same reason. */
.hero-film__scrim {
  position: absolute;
  inset: 0;
  background: linear-gradient(to top,
    rgba(1, 35, 64, 0.9) 0%,
    rgba(1, 35, 64, 0.55) 42%,
    rgba(1, 35, 64, 0.12) 78%);
  z-index: -1;
}

/* --- The diagonal ------------------------------------------------------
   One line hard against the top-left, one against the bottom-right, the
   height of the video between them. A three-row grid rather than two blocks
   pushed apart: the middle row is the empty space, so it is the thing that
   absorbs every viewport height and neither line has to be positioned
   against a number.
   --------------------------------------------------------------------- */
.hero-film .film-type {
  width: 100%;
  /* Grows to the full height of the section's content box, which is what
     gives the middle row something to absorb. */
  flex: 1 1 auto;
  display: grid;
  grid-template-rows: auto 1fr auto;
  align-content: stretch;
}
.hero-film .film-type__top {
  grid-row: 1;
}
/* The foot used to carry two things - the supporting copy on the left and the
   second big line on the right. The copy and its button were removed on
   7 Sep 2026 at the client's request, so this now holds the one line and
   pushes it to the right. The flex row is kept rather than collapsed to a
   block: it is what puts the line against the section's right edge without
   the line itself needing a margin, and it is one declaration away from
   holding a second item again if the CTA comes back.

   The .film-type__aside rules that sat here are gone with the markup -
   confirmed by querying the live page, where .hero-film .film-type__aside,
   .hero-film .lede, .hero-film .btn-primary, .hero-film .index-tag and
   .hero-film .mono-label all match zero elements, and .hero-film exists on no
   other page. */
.hero-film .film-type__foot {
  grid-row: 3;
  display: flex;
  align-items: flex-end;
  justify-content: flex-end;
}

/* The two big lines. Sized off the viewport width so the composition holds
   its proportions from a laptop to a large monitor, with a ch-based measure
   so a long line breaks rather than running under the other column. */
.hero-film .film-word {
  color: var(--white);
  font-family: var(--f-display, inherit);
  font-weight: 700;
  font-size: clamp(2.6rem, 8.4vw, 8rem);
  line-height: 0.92;
  letter-spacing: -0.035em;
  margin: 0;
  max-width: 11ch;
  text-wrap: balance;
}
.hero-film .film-word--end {
  text-align: right;
  margin-left: auto;
  flex: 0 1 auto;
}

/* The white-over-video colour overrides for .index-tag, .mono-label and .lede
   were here. All three elements left this hero on 7 Sep 2026 and the selectors
   are scoped to .hero-film, which only manufacture.html carries - so nothing
   else was relying on them. The page's own sub-brand slides further down still
   use .index-tag and .mono-label; they get their colour from the
   .division-slide rules, not from these. */

@media (max-width: 860px) {
  /* The full width for each line, so the diagonal survives as top-of-screen
     against bottom-of-screen rather than two narrow columns. */
  .hero-film .film-word {
    max-width: none;
  }

  /* Phones (7 Sep 2026, client): "Manufacturing" hard into the top left,
     "From SA To The World" into the bottom right, and nothing else in between
     - the lede and the button were removed the same day.

     Padding is what was stopping the diagonal from reading. Measured before:
     the heading started 92px below the header and the second line finished
     148px above the foot of the hero, so the whole composition sat in the
     middle third with dead footage above and below it. Trimming the section's
     own padding hands those 240px to the 1fr row between the two lines, which
     is what actually opens the diagonal - nothing here repositions anything by
     hand. */
  .hero.mfg-hero.hero-film {
    padding-block: 20px 24px;
  }
  /* Bottom right. text-align alone only moves the ink inside a full-width box;
     the flex row on .film-type__foot is what puts the box itself against the
     right edge. max-width keeps the line breaking as two words per line rather
     than running the full width of the phone. */
  .hero-film .film-word--end {
    text-align: right;
    max-width: 14ch;
  }
}
/* Navy on navy would vanish. The same swap the header already makes over the
   homepage video: solid white, navy text, --bg-tint on hover, so it stays a
   filled button rather than becoming an outline. */
/* The navy-on-navy button swap for this hero was here. The button was removed
   from the hero on 7 Sep 2026 and there is no other .btn inside .hero-film, so
   these had nothing left to style. Kept as a note because a CTA returning to
   this hero WILL need them again: over the footage a navy-filled button
   disappears, which is why the swap existed - solid white, navy text,
   --bg-tint on hover, the same exchange the header makes over the homepage
   video. */

/* --------------------------------------------------------------------------
   22. Property header film (property.html, 8 Sep 2026)
   --------------------------------------------------------------------------
   The client's footage as the whole header, full width, with one big line
   hard against the top left.

   ITS OWN CLASSES RATHER THAN .hero-film. That component looks like the right
   thing to reuse and is not: its padding-bottom carries `12vh +` because the
   sub-brand scroller used to overlap manufacture.html's hero by that much,
   its type is a three-row grid built to throw one line to the top-left and
   another to the bottom-right, and its own comments say the rules are scoped
   to a class only that page carries. Reusing it would have dragged another
   page's geometry onto this one for the sake of four shared declarations.

   The 88px is the header: it is sticky and in normal flow, so the hero starts
   88px down the page, and a flat 100svh would put the bottom of the section
   that far below the fold. Measured at 1920, 1440, 1024, 905, 768, 480, 390
   and 320 in the film hero's own note - the bar is the same height at every
   width, so it is a constant rather than something to measure at runtime.
   svh, not vh: on a phone vh is the height the page would have if the address
   bar were already collapsed, and it is not on load.
   -------------------------------------------------------------------------- */
.prop-hero {
  /* Sticky, not relative (9 Sep 2026): the statement section right after it
     now climbs up and over it on scroll, the same way .surface--over-hero
     climbs over the homepage's .hero-lock. top is 88px, not 0, because
     .site-header is ALSO sticky at top:0 (see style.css) and property.html's
     header is never touched to become fixed the way the homepage's is for
     its own hero-lock - this sticks just below wherever the header's own
     sticky slot already sits, so the header needs no changes at all. */
  position: sticky;
  top: 88px;
  z-index: 0;
  /* Its own stacking context, so the video's negative z-index stays inside
     this section rather than sliding under the page. */
  isolation: isolate;
  overflow: hidden;
  /* Behind the footage until it paints, and behind the letterbox if a future
     clip does not cover. */
  background: var(--navy);
  min-height: calc(100svh - 88px);
  display: flex;
  flex-direction: column;
  /* TOP, not centre, and it has to be said. The sitewide .hero rule is
     `display: flex; flex-direction: column; justify-content: center`, and
     this section carries .hero as well for its min-height and its place in
     the page's rhythm. Without this the line inherits that centring: measured
     before the override, the title sat 298px down an 812px section at 1440
     and 419px down at 768, which is the middle of the footage rather than the
     top left corner the brief asks for. Both selectors are one class deep, so
     this wins on order, not specificity - keep it below that rule. */
  justify-content: flex-start;
  /* Clears the sticky bar, so the line is hard against the top of what the
     visitor can actually see rather than tucked under the logo. */
  padding-top: clamp(var(--sp-5), 11vh, 108px);
  padding-bottom: clamp(var(--sp-5), 7vh, 72px);
}
.prop-hero__video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
  z-index: -1;
}
/* Top-left: the inner block takes the top of the column and the title sits at
   its start. No justify-content on the section, so the video is free to fill
   the height while the type stays put. */
.prop-hero__inner {
  width: 100%;
}
/* NO SCRIM AND NO TEXT-SHADOW, at the client's instruction on 8 Sep 2026:
   "do not add a dark overlay over the video it must just be how it is". So
   this is white type straight onto the footage, which is what was asked for.

   WHAT THAT COSTS, MEASURED, because it is the one thing to look at before
   calling this finished. Eight frames were sampled across the 5.05s loop and
   scored as WCAG contrast against white, worst-5%-of-the-area per frame:

     t=0.0s  1.05:1     t=2.5s  2.99:1
     t=0.6s  1.03:1     t=3.2s  2.52:1
     t=1.2s  1.13:1     t=3.9s  1.41:1
     t=1.8s  1.14:1     t=4.6s  1.56:1

   Large text needs 3:1. It never reaches it. Worse, a per-pixel map of the
   brightest each pixel ever gets across the whole clip shows only 8.0% of
   the top-left heading area staying dark enough for white type at EVERY
   frame - the camera moves, so the dark stone arch that sits top-left in the
   opening frame does not stay there. There is no safe corner to move the
   line to.

   Three ways out, none of them a dark overlay over the whole video, listed
   so the next person does not have to re-derive them:

     1. A local vignette behind the type only, exactly what
        .dstack__body::before does on the homepage panels - the footage stays
        untouched everywhere except immediately behind the words.
     2. text-shadow on the glyphs. Not a layer over the video at all, and the
        site already uses `0 1px 12px rgba(0,0,0,0.55)` on the division
        panels. It sharpens the edges rather than raising measured contrast,
        so it is a help and not a fix on its own.
     3. Different footage, or a graded copy of this footage with the top-left
        darker in the file.

   Adding any of them is a few lines here. Doing it without being asked is
   what the instruction rules out. */
.prop-hero__title {
  color: var(--white);
  font-family: var(--f-display, inherit);
  font-weight: 700;
  /* The film hero's scale, one step down: that one sets two short words and
     this is five long ones, so at 8.4vw "Management" alone would outrun the
     column at every width below 1200. */
  font-size: clamp(2.2rem, 6.2vw, 6rem);
  line-height: 0.94;
  letter-spacing: -0.03em;
  margin: 0;
  /* Wraps by measure rather than on a hard <br>, so no width strands a lone
     word on its own line. balance keeps the three lines close in length. */
  max-width: 14ch;
  text-wrap: balance;
}
/* "Lincor Property" in Bodoni Moda (see the markup comment above the h1 for
   why), tightened with negative tracking since the family has no condensed
   width of its own - at this size Bodoni Moda's own hairline/stem contrast
   already reads narrower than General Sans, so -0.01em on top is a nudge,
   not a fight against the letterforms. */
.prop-hero__title-mark {
  font-family: 'Bodoni Moda', var(--f-display, inherit);
  letter-spacing: -0.01em;
}
/* "Management & Development", deliberately lighter than the mark beside it -
   General Sans actually has a 300 weight (added to the Fontshare <link>
   above), rather than faking thinness with -webkit-text-stroke or a
   font-synthesis the browser would have to invent on its own. */
.prop-hero__title-thin {
  font-weight: 300;
}
/* Bottom-left, clear of the title above it and inset to match .container's
   own gutter so both edges line up. Absolute rather than a flex child of
   .prop-hero__inner: that block sizes to its own content (just the h1), not
   the section's height, so a flex trick to push a sibling to the bottom
   would need an explicit height on it anyway - simpler to anchor this to
   .prop-hero itself, which is already the positioned ancestor. */
.prop-hero__logos {
  position: absolute;
  left: var(--gutter);
  bottom: clamp(var(--sp-4), 6vh, var(--sp-6));
  display: flex;
  /* flex-start, not center: the two images are now deliberately different
     heights (see below), and centering them would undo the alignment that
     buys. */
  align-items: flex-start;
  gap: var(--sp-4);
}
/* A shared bounding box (the first pass here) equalised the BOXES, not the
   WORDS inside them, and left "ZELPY" sitting higher and smaller than
   "RHIND" - which is what was actually wrong. Measured both source PNGs
   (pixel rows with alpha > 10, see the numbers below): Rhind's "RHIND" is
   rows 2-71 of its 123px-tall image; Zelpy's "ZELPY" is rows 281-415 of its
   504px-tall image, under a numeral mark and above a tagline that "RHIND"
   has no equivalent of. Scaling each image so that band alone comes out
   the same cap-height, then nudging Rhind down by exactly the gap between
   the two bands' start offsets, is what actually top-aligns "RHIND" and
   "ZELPY" themselves rather than the icon/numeral above them. Nothing is
   cropped - the R-mark, "INVESTMENTS" and Zelpy's numeral/tagline are all
   still fully there, just extending further above (Zelpy's numeral) or
   below (both subtext lines) the shared word-line than before. */
.prop-hero__logos img {
  width: auto;
  filter: brightness(0) invert(1) drop-shadow(0 1px 6px rgba(0, 0, 0, 0.55));
}
.prop-hero__logos img.rhind {
  /* "RHIND" (70/123 = 56.9% of the image) at a 20px cap-height means the
     whole image renders at 20 / 0.569 ~= 35px; "RHIND" starts 2px into
     that 123px source, i.e. 0.6px into the scaled image. */
  height: 35px;
  /* Zelpy's word starts 41.6px into ITS scaled image (below); pushing
     Rhind's own 0.6px-in word down by that same 41px difference is what
     lines the two words up. */
  margin-top: 41px;
}
.prop-hero__logos img.zelpy {
  /* "ZELPY" (135/504 = 26.8% of the image) at the same 20px cap-height
     means the whole image renders at 20 / 0.268 ~= 75px; "ZELPY" starts
     281px into that 504px source, i.e. 41.6px into the scaled image. */
  height: 75px;
}

/* --------------------------------------------------------------------------
   Typewriter reveal - shared primitives
   --------------------------------------------------------------------------
   Moved here from css/home.css 8 Sep 2026 (see that file's own note): the
   markup, the caret and the blink are generic and now used by both the
   homepage's .statement block (js/home.js) and property.html's
   .prop-statement (js/property.js). Each page keeps its own layout rules for
   the text itself; only the per-character mechanics live here. */

/* One wrapper per word, holding that word's per-character spans. nowrap only
   restates what a run of letters does anyway - it is here because the caret
   moves through the middle of a word as it types, and an inline box sitting
   between two letters is otherwise a place the line is allowed to break. */
.type-word { white-space: nowrap; }

/* The caret that leads the typing, moved character by character to sit in
   front of the next letter to appear. Removed from the DOM once the line is
   finished, so it never blinks on a settled page.

   It occupies no width and paints from a pseudo-element instead. A caret with
   real width sits on the end of the text while it types, which is enough to
   push the last word onto another line and then let it jump back when the
   caret goes - the block has to wrap identically with and without it. */
.type-caret {
  position: relative;
  display: inline-block;
  width: 0;
  height: 0.9em;
  vertical-align: -0.08em;
  animation: type-blink 0.85s steps(1, end) infinite;
}
.type-caret::before {
  content: "";
  position: absolute;
  inset: 0 auto 0 0.06em;
  width: 0.06em;
  min-width: 2px;
  background: currentColor;
}
@keyframes type-blink {
  0%, 50% { opacity: 1; }
  50.01%, 100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .type-caret { display: none; }
}

/* --------------------------------------------------------------------------
   Property: statement section
   --------------------------------------------------------------------------
   Replaces the "Our companies" section (8 Sep 2026) - see the removal note
   on the markup. A single centred line of trust copy, typed in the same way
   as the homepage's about statement, in its own section between the header
   and the gallery.

   9 Sep 2026: now slides up and over .prop-hero on scroll, the same
   technique as the homepage's .surface--over-hero climbing over
   .hero-lock - .prop-hero is sticky (see its own rule above), this section
   sits after it in normal flow with a higher z-index and a top shadow, and
   the two together read as one sheet physically sliding over the footage.
   A video now sits above the paragraph, both capped to the same 42ch
   column, and the section's own padding grew to give that stack room. */
.prop-statement {
  position: relative;
  z-index: 1;
  /* Must be at least as tall as .prop-hero's own sticky range
     (min-height: calc(100svh - 88px)) or there is a scroll position where
     this section has fully scrolled past while the hero underneath is
     still stuck - the footage then shows through in the gap below the
     text. min-height here, not just generous padding, is what guarantees
     that never happens regardless of how long the paragraph's copy runs. */
  min-height: 100svh;
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  background: var(--shell);
  box-shadow: 0 -32px 60px rgba(1, 35, 64, 0.3);
  padding-block: clamp(var(--sp-7), 16vh, 220px);
}
/* TEXTURE: PENDING A FILE. The client described a paper/fabric texture
   reference in chat and said it had been added to images/ as
   "texturebackground", but no such file exists anywhere in the repo
   (checked 9 Sep 2026 - only manufacture-texture.* and
   manufacturingbackgroundtexture.png, both unrelated to Property). Once
   the real file lands, this is a two-line change on .prop-statement above:

     background:
       linear-gradient(var(--shell), var(--shell)),
       url("../images/<real-filename>");
     background-blend-mode: multiply;

   The flat --shell layer underneath a blend mode (try multiply first, then
   overlay) is what keeps this reading as eggshell WITH texture rather than
   as the photo at full strength - the same reasoning as .surface::before's
   0.02 opacity on the homepage, just via blend mode instead of opacity
   because a photographed texture (unlike the generated grain there) has
   its own tonal range worth keeping some of. */
/* 42ch (9 Sep 2026: widened to 72ch, client reference) wrapped the
   paragraph to 5-6 short lines; the reference wraps it to 4 much longer
   ones. Only the column width changes here - the paragraph's own font,
   size, colour and spacing are untouched, so the wider block is purely the
   shape of the wrap, nothing about how it reads. The video shares this same
   column, so it widens by exactly the same amount automatically. */
.prop-statement__col {
  max-width: 96ch;
  margin: 0 auto;
}
/* "Thin" per the brief: property-building.mp4 (re-encoded from the client's
   HEVC upload - see images/hero/ and the markup comment) is an ordinary
   1280x720 construction-site shot, not natively a banner. aspect-ratio +
   object-fit: cover crops it to one without a second re-encode.
   2.2/1, not the original 3/1 (9 Sep 2026): a little taller now that the
   column itself is much wider, so the video does not thin out to a sliver
   at the new width. */
.prop-statement__video {
  display: block;
  width: 100%;
  aspect-ratio: 2.2 / 1;
  object-fit: cover;
  border-radius: var(--radius);
  margin-bottom: var(--sp-5);
}
.prop-statement__text {
  margin: 0 auto;
  font-family: var(--f-display, inherit);
  font-size: clamp(1.3rem, 2.4vw, 1.9rem);
  line-height: 1.45;
  letter-spacing: -0.01em;
  color: var(--navy);
}

/* --------------------------------------------------------------------------
   Property: editorial spread
   --------------------------------------------------------------------------
   Same "one object, not two cards" construction as .contact-card
   (contact.html) - square corners, no gap, the two panels butted directly
   together - reused rather than invented fresh, since that pattern is
   already proven at this exact job: a navy panel of centred Instrument
   Serif copy (.contact-card__say / .contact-card__statement). The client's
   own reference is two-tone - a dark copy side against a cream photo side -
   and that is what this is: --navy on the left, --shell on the right.

   The section's OWN background is stated as --shell too (9 Sep 2026). It
   was transparent, and the sitewide `section { padding-block:
   var(--section-pad) }` gives it 96px bands above and below the panels, so
   those bands were showing whatever sat behind them rather than being
   eggshell themselves - the visible seam between this section and the
   eggshell statement above it. Same fragility as the sticky-hero leak in
   .prop-hero-wrap: a transparent band over a coloured ancestor is a gap
   waiting to show something. Stating the colour closes it. */
.prop-editorial {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  background: var(--shell);
  /* Above .prop-statement (z-index: 1), not just after it in the DOM.
     MEASURED CAUSE of the seam this fixes: .prop-statement's slide-over
     `box-shadow: 0 -32px 60px` is offset upward, but a 60px blur has a long
     tail, and the part of that tail below the statement's own bottom edge
     is outside its border box - so it is NOT clipped, and painted a ~36px
     smudge from rgb(234,234,230) back up to the eggshell across the top of
     this section. Confirmed by re-measuring with box-shadow: none, which
     left a flat rgb(245,244,238) through the whole seam. Stacking this
     section above the statement covers the tail. Same fix, same reason, as
     .dstack sitting above .surface on the homepage. */
  position: relative;
  z-index: 2;
}
@media (max-width: 860px) {
  .prop-editorial { grid-template-columns: minmax(0, 1fr); }
}
/* position: relative for the corner shading below, which is a
   pseudo-element rather than a real box-shadow: the reference's is a soft
   diagonal falloff INSIDE the bottom-left of the panel, not a shadow cast
   outside its edges, and box-shadow: inset cannot be aimed at one corner
   the way a gradient can. */
.prop-editorial__say {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  background: var(--navy);
  color: var(--white);
  padding: clamp(32px, 6vw, 96px);
  min-height: clamp(480px, 70vh, 760px);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  gap: var(--sp-4);
}
.prop-editorial__say::before {
  content: "";
  position: absolute;
  /* Oversized and anchored past the corner so the visible part is the
     outer, softest edge of the falloff rather than a circle sitting in the
     panel. */
  left: -30%;
  bottom: -40%;
  width: 90%;
  height: 85%;
  z-index: -1;
  pointer-events: none;
  background: radial-gradient(closest-side,
              rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0));
}
.prop-editorial__statement {
  /* Stated, not inherited: `h1, h2, h3 { color: var(--navy) }` is sitewide
     and is an actual declaration, not just an unset inherited value, so it
     wins over .prop-editorial__say's white unless restated here - the bug
     this caught was navy text on a navy panel, invisible except as a very
     faint ghost. */
  color: var(--white);
  font-family: var(--f-editorial);
  font-size: clamp(1.9rem, 3.4vw, 3.1rem);
  line-height: 1.08;
  letter-spacing: -0.01em;
  max-width: 16ch;
  margin: 0;
}
/* Stated, not inherited, same reason as .contact-card__statement above:
   `p { color: var(--slate) }` is sitewide and would otherwise beat this. */
.prop-editorial__text {
  max-width: 38ch;
  color: rgba(255, 255, 255, 0.7);
  font-size: 0.95rem;
  line-height: 1.7;
  margin: 0;
}
/* Eggshell, not navy (9 Sep 2026, client): the reference's photo side is the
   light one, and the photo sits inset in it like a print in a mount. Same
   padding as .prop-editorial__say so the two halves read as one object with
   one margin, rather than a full-bleed photo butted against a padded panel. */
.prop-editorial__image {
  background: var(--shell);
  padding: clamp(32px, 6vw, 96px);
  min-height: clamp(480px, 70vh, 760px);
}
/* Square corners, per the reference - deliberately NOT var(--radius), which
   is what the rest of the site's photography carries. A mounted print has
   cut edges; the rounding is what made this read as a card instead. */
.prop-editorial__image img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* The arrival (9 Sep 2026, client: the burst on the image, the copy fading
   in). Same two mechanisms the Manufacture sub-brand blocks use - the
   ellipse opening out of the picture's own centre, the copy arriving just
   ahead of it - driven off one .is-drawn class added by js/property.js, so
   the two halves are choreographed as one event rather than two independent
   reveals that happen to fire near each other.

   Everything hangs off html.prop-armed, set in property.html's <head>
   before first paint and taken back off at DOMContentLoaded if
   js/property.js never arrives. Without that gate a failed script leaves
   the heading, the paragraph and the picture permanently invisible - which
   is the single failure mode this site keeps having, so the gate is not
   optional.

   72%, not 71% or 100%: half the diagonal of a square is 70.7%, so 72% is
   the first round number that clears the corners, and stopping there keeps
   the tail of the transition from being time spent on nothing.

   ORDER (9 Sep 2026, client): the navy block lands FIRST, then the copy
   appears on it. The copy's delays below are what enforce that - they start
   after the block's sweep has finished, so the words never arrive on a
   sheet that is still moving. */

/* The navy block falling in: a diagonal sweep out of its own top-left
   corner, past the bottom-right. A three-point polygon collapsed onto that
   corner growing into one big enough to cover the box - both states have
   the same point count, which is what makes clip-path interpolable at all
   (mismatched counts do not animate, they snap).
   205%, and the number matters. Polygon percentages resolve per axis, so
   the sweep's edge is the line x + y = X in width/height units and the
   bottom-right corner sits at (1, 1) - meaning coverage completes the
   moment X passes 200%, whatever the panel's aspect ratio. Anything past
   that is duration spent on motion nobody can see: measured at 250% with an
   expo-out ease, the sweep was visually finished at ~250ms of its 700ms and
   the remaining 450ms was a dead wait before the copy arrived. 205% clears
   the corner without shaving it and keeps almost the whole duration
   on screen.

   THE CURVE AND DURATION, worked out rather than picked - the first two
   passes here both shipped a sweep that read as a flash, and the reason was
   not the duration.

   What the eye reads is the panel's COVERED AREA, and that is quadratic in
   X near both corners, not linear. Coverage as a fraction of the panel is
   X^2/2 while X <= 100%, then 1 - (2-X)^2/2 up to 200%. Run any decelerating
   ease through that and the sweep is over before the timer is: easeOutCubic
   put the panel 54% covered at a fifth of the duration and 98% covered at
   half, so 700ms of animation was ~300ms of visible motion. expo-out was
   worse - 51% covered at a TENTH.

   Sampling curves against that coverage function, the span between 5% and
   95% covered comes out as: easeOutCubic 30% of the duration, a strong
   ease-in-out 42-48%, linear 67%. Deceleration is simply the wrong
   direction here, and so is a hard S - both compress the motion into a
   window. Constant edge velocity reads longest, and it is also what the
   contact line of a sheet slapping flat actually does.
   So this sits just off linear: prompt off the mark (3% covered by 200ms,
   no dead pause to read as a stall), accelerating through the middle like
   something falling (15% -> 38% -> 68% at 400/600/800ms), settling at the
   end. 870ms of visible sweep out of 1500ms, against ~300ms before. */
html.prop-armed .prop-editorial__say {
  clip-path: polygon(0 0, 0 0, 0 0);
  transition: clip-path 1500ms cubic-bezier(0.3, 0.22, 0.7, 0.9);
}
html.prop-armed .prop-editorial.is-drawn .prop-editorial__say {
  clip-path: polygon(0 0, 205% 0, 0 205%);
}

/* Delays follow the sweep rather than guessing at it: coverage reaches 100%
   at ~1350ms of the 1500ms above (the tail is the last sliver at the
   bottom-right corner), so the heading starts there and the paragraph a
   beat behind it. 700ms rather than 900ms on the fade - with the sheet now
   taking three times as long to land, a slow fade on top of it just made
   the whole arrival drag. */
html.prop-armed .prop-editorial__statement,
html.prop-armed .prop-editorial__text {
  opacity: 0;
  transition: opacity 700ms cubic-bezier(0.16, 1, 0.3, 1);
}
html.prop-armed .prop-editorial__statement { transition-delay: 1350ms; }
html.prop-armed .prop-editorial__text { transition-delay: 1500ms; }
html.prop-armed .prop-editorial.is-drawn .prop-editorial__statement,
html.prop-armed .prop-editorial.is-drawn .prop-editorial__text {
  opacity: 1;
}

/* 1200ms, up from 1000ms, so the photograph opening and the sheet landing
   sit at the same pace - the two run concurrently and a burst noticeably
   quicker than the sweep beside it made the section feel like two separate
   animations. Its own ease stays easeOutCubic: an ellipse growing from a
   point does not have the coverage problem the diagonal does, because the
   area it adds per frame grows rather than tapering into a corner. */
html.prop-armed .prop-editorial__image img {
  clip-path: ellipse(0% 0% at 50% 50%);
  transition: clip-path 1200ms cubic-bezier(0.215, 0.61, 0.355, 1);
  transition-delay: 300ms;
}
html.prop-armed .prop-editorial.is-drawn .prop-editorial__image img {
  clip-path: ellipse(72% 72% at 50% 50%);
}

@media (prefers-reduced-motion: reduce) {
  /* js/property.js draws the section on the spot under reduced motion, so
     this is belt and braces for the case where the class is armed but
     nothing should move. Matches the not-yet-drawn selectors' depth so it
     wins on order. */
  html.prop-armed .prop-editorial__say,
  html.prop-armed .prop-editorial__statement,
  html.prop-armed .prop-editorial__text,
  html.prop-armed .prop-editorial__image img {
    transition: none;
    opacity: 1;
    clip-path: none;
  }
}
