/* ==========================================================================
   Components — header, buttons, cards, popup, consent, footer, preloader.
   ========================================================================== */

/* --------------------------------------------------------------------------
   Header
   Logo left, menu and language switcher right, contact button last. The header
   sits over the hero rather than above it, so it has no background of its own.
   -------------------------------------------------------------------------- */

/* Sticky, so the bar follows you down the page. It stays in normal flow — which
   keeps the hero offsets below it correct — and pins once its top edge reaches
   the viewport. XD row runs y25-83.

   No background when pinned: the design doesn't have one, and the menu items
   carry their own light pill so they stay legible over whatever scrolls under. */
.site-header {
  position: sticky;
  inset-block-start: 0;
  z-index: var(--z-header);
  padding-block-start: 25px;   /* top only — the row must stay 83px (25 + 58) */
}

/* Scrolled state — a scrim, not a bar.

   The artboards only ever draw the header over the hero, where the band behind
   it is dark and the logo reads cleanly. Scrolling puts it over whatever
   happens to be passing underneath: a light grey step card, the green contact
   marquee, a case-study photograph. The menu items carry their own pill, but
   the logo, the language switcher and the burger do not, and over those
   surfaces they disappear entirely.

   A gradient rather than a filled bar keeps the design's "the header has no
   background" intent: at rest over the hero nothing is painted, and the scrim
   only fades in once the page has actually moved. */
.site-header::before {
  content: '';
  position: absolute;
  inset-inline: 0;
  inset-block-start: 0;
  height: 150px;
  z-index: -1;
  pointer-events: none;
  opacity: 0;
  background: linear-gradient(180deg,
    rgb(24 28 24 / 0.92) 0%,
    rgb(24 28 24 / 0.75) 40%,
    rgb(24 28 24 / 0) 100%);
  transition: opacity var(--dur-base) var(--ease-out);
}

.is-scrolled .site-header::before { opacity: 1; }

/* Spacing is set per element rather than with one uniform gap, because the
   design does not use a single rhythm across the row: the logo ends at 181 and
   the pill starts at 237 (56 apart), while "En" ends around 1348 and the button
   starts at 1366 (18 apart). */
.site-header__inner {
  display: flex;
  align-items: center;
  gap: 0;
}

.site-header__logo {
  flex: 0 0 auto;
  display: block;
}

/* 33, not 28, for the accented wordmark — and the row does not grow.

   The two files do not fill their boxes the same way. The old one's glyphs run
   the full 155.33 of its viewBox; this one's run 48 of 57.29, because the macron
   takes the top 2.2 and there is a gap under it. At a 28px box that put the
   letters themselves at 23.5 — the same mark, set 16% smaller. 28 / 0.8377 is
   33.4, which restores the old letter height and, at this file's 4.70 aspect,
   also lands the width back on 157 against the old 155.

   Free of the header's height: the row is a flex line whose tallest item is the
   58px nav pill, and 25 + 58 is the 83 the design asks for. 33 sits inside that
   and changes nothing. It is the pill that would have to grow before the bar
   did. */
.site-header__logo img { width: auto; height: 33px; }

.site-nav {
  flex: 0 0 auto;
  margin-inline-start: 44px;   /* logo ends 181, pill starts 237 */
}

/* The menu items sit inside a light rounded container — 483x58 at r10 in
   #f0eceb — not loose on the dark band. */
/* XD: pill 483 wide, first item 20px in from the left edge, items spaced ~32px
   apart, ~25px of padding after the last one. */
/* Pill 237-720 (483 wide, 58 tall). Items at 257 / 447 / 595, so 20px in from
   the left edge, 30px between each, 25px after the last.

   The floor puts the pill's right edge on the case cards' right edge once the
   container is capped (from ~1490): measured 486.56 from pill start to card end
   at both 1600 and 1920. The French labels alone reach 485.28 and the English
   426, so both sat short; space-around spreads whatever room is left. Narrower
   than the cap the cards shrink and no pill width can follow them. Safe at
   every width the pill exists: below 1025 it becomes the drawer, and the pill
   ends at 712 there with the switcher at ~900. */
.site-nav__list {
  display: flex;
  align-items: center;
  justify-content: space-around;
  min-inline-size: 486.56px;
  gap: 30px;
  height: 58px;
  padding-inline: 20px 25px;
  background: #f0eceb;
  border-radius: var(--radius-card);
}

/* Items sit muted by default; the hovered one — and the current page — come up
   to full black. The static artboard stores every item at full-opacity #212121,
   so these states come from the interaction spec rather than the file. */
.site-nav__link {
  position: relative;
  font-family: var(--font-sans);
  font-size: var(--t-lead);
  font-weight: var(--fw-medium);
  line-height: 1;
  color: var(--c-panel);
  opacity: 0.45;
  transition: opacity var(--dur-fast) var(--ease-out);
}

/* The colour is restated because base.css turns every `a:hover` green, which
   would otherwise win here — the menu items go to black, not accent. */
.site-nav__link:hover,
.site-nav__link:focus-visible,
.site-nav__item.current-menu-item .site-nav__link,
.site-nav__item.current_page_item .site-nav__link,
.site-nav__item.current-menu-ancestor .site-nav__link {
  opacity: 1;
  color: var(--c-panel);
}

/* Underline grows from the left on hover; on the current page it stays drawn. */
.lang-switcher {
  display: flex;
  gap: var(--s-1);
  margin-inline: auto 18px;   /* pushed right; 18px clear of the button */
  font-size: var(--t-label);
  font-weight: var(--fw-medium);
  color: var(--c-off-white);
}

.lang-switcher__item[aria-current='true'] { color: var(--c-accent); }

/* Contact trigger — a 48x48 green square with a speech bubble. */
.contact-toggle {
  flex: 0 0 auto;
  display: grid;
  place-items: center;
  width: 48px;
  height: 48px;
  border-radius: 10px;
  background: var(--c-accent);
  color: var(--c-panel);
  transition: background var(--dur-fast) var(--ease-out), scale var(--dur-fast) var(--ease-out);
}

.contact-toggle:hover { background: var(--c-green-deep); scale: 1.05; }

/* The address, phone and privacy link the open drawer carries below its rule.
   They belong to the drawer only — at desktop widths that content lives in the
   footer, and leaving it in flow here stretched the header from 83px to 173 and
   pushed the whole page down with it. responsive.css turns it back on. */
.site-nav__extra { display: none; }

/* Burger — shown from the tablet breakpoint down. */
.nav-toggle {
  display: none;
  width: 44px;
  height: 44px;
  position: relative;

  /* The burger is the other control a finger rests on — nothing inside it to
     select, and the drag that opens a selection should scroll the page. */
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
}

.nav-toggle__bar {
  position: absolute;
  left: 10px;
  width: 24px;
  height: 1.5px;
  background: currentColor;
  transition: translate var(--dur-base) var(--ease-out),
              rotate var(--dur-base) var(--ease-out),
              opacity var(--dur-fast) linear;
}

/* Two bars, not three — header.php draws the artboard's 25px pair. These rules
   used to describe a three-bar burger whose middle bar was faded out on open;
   with only two in the markup that `opacity: 0` landed on the bar that is
   supposed to rotate the other way, so the open state drew a single diagonal
   instead of a cross. Both bars meet in the middle and rotate opposite ways. */
.nav-toggle__bar:nth-child(1) { top: 19px; }
.nav-toggle__bar:nth-child(2) { top: 25px; }

.nav-toggle[aria-expanded='true'] .nav-toggle__bar:nth-child(1) { translate: 0 3px; rotate: 45deg; }
.nav-toggle[aria-expanded='true'] .nav-toggle__bar:nth-child(2) { translate: 0 -3px; rotate: -45deg; }


/* --------------------------------------------------------------------------
   Buttons

   The arrow button is the site's signature control. Geometry is taken from the
   404 artboard: a 60px square icon cell beside a 60px-tall label cell. On hover
   the arrow travels — the leading icon slides out and the trailing one slides
   in, so the arrow appears to cross behind the label.
   -------------------------------------------------------------------------- */

/* XD geometry: a 60x60 icon box at radius 10, then a 275x60 pill at radius 30,
   both outlined, label in DM Sans SemiBold 16 green. They are two separate
   shapes in the design, not one segmented control. */

/* 60 + 275 = 335, which is exactly the three-column track the design places this
   button in. That equality only holds at 1440: at 1280 the track is 291 and at
   1025 it is 228, while the button stayed 335 and ran out past the right edge —
   silently, because .site clips rather than scrolls. So the natural width stays
   335 (the design value at 1440 and above) and `max-width` lets it fall back to
   the track below that, which is the same thing the design means by putting the
   button in a column. Only the label gives way; the 60px icon is fixed. */
.btn-arrow {
  display: inline-flex;
  align-items: center;
  flex: none;          /* never let a flex parent squeeze the label below its width */
  max-width: 100%;
  height: 60px;
  color: var(--c-accent);

  /* The label is a control's name, not copy anyone needs to lift. Holding a tap
     long enough to see the travel is also long enough for a touch browser to
     start selecting the word under the finger, or to raise iOS's callout. */
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
}

/* Hover mechanic, reproduced from the original stylesheet: the leading box
   collapses to 0x0 with no border while the trailing one expands to 60x60, so
   the arrow travels from one end of the pill to the other and the control keeps
   its overall width. The label stays transparent throughout — filling it was my
   invention and read as a different button entirely.

   The easing here is a real CSS transition, so unlike the GSAP tweens the
   cubic-bezier in the original source genuinely applied. */

.btn-arrow__icon {
  display: flex;
  align-items: center;
  justify-content: center;
  flex: none;          /* the label absorbs a narrow track, never the icon */
  width: 60px;
  height: 60px;
  border: 2px solid currentColor;
  border-radius: 10px;
  overflow: hidden;
  transition: width 0.5s cubic-bezier(0.3, 0.86, 0.36, 0.95),
              height 0.5s cubic-bezier(0.3, 0.86, 0.36, 0.95),
              border-width 0.5s cubic-bezier(0.3, 0.86, 0.36, 0.95);
}

.btn-arrow__icon--trail { width: 0; height: 0; border-width: 0; }

.btn-arrow__label {
  display: flex;
  align-items: center;
  justify-content: center;

  /* The label wraps to a second line rather than being cut off. Once the track
     squeezes the pill below the label's natural width -- around 1060, where the
     hero pill drops to roughly 190 and "Découvrez notre expertise" needs 197 --
     nowrap plus overflow: hidden shaved the first and last words instead of
     folding them, so the button read "Découv notr expertis".

     The height does not move. Line-height here is 18, so two rows come to 36
     inside a 60px label and align-content keeps them centred; the pill, its
     border radius and the icons are untouched. white-space is what folds the
     line -- the label is one plain text node now that the word-by-word reveal
     is gone, so flex-wrap has nothing of its own to wrap and stays only because
     align-content needs a wrapping container to mean anything.

     overflow: hidden stays below as the backstop for a single word longer than
     the pill, which no amount of wrapping can fold. */
  flex-wrap: wrap;
  align-content: center;
  row-gap: 0;

  flex: 0 1 auto;      /* shrinks with the button's max-width; never grows past 275 */
  width: 275px;
  min-width: 0;
  max-width: 100%;
  height: 60px;

  /* The label is 275 wide and its text is centred, so this padding is never
     visible at the design width — it only sets how close the text may come to
     the pill's ends once the track forces the pill narrower. The design's 40
     would have clipped "EN SAVOIR PLUS" at 1025, where the pill is 168. */
  padding-inline: 20px;
  border: 2px solid currentColor;
  border-radius: 30px;
  background: transparent;
  font-size: var(--t-button);
  font-weight: var(--fw-semibold);

  /* `justify-content: center` above is not enough, and this is why: the label is
     one plain text node, so the flex container wraps it in a single anonymous
     item. Centring the ITEM is all justify-content can do. While the text fits
     one line the item is exactly as wide as the text and the two look the same;
     the moment it folds, the item is the full track and its two lines set to
     `start` inside it — which is the left-aligned "Nos champs / d'expertise".
     text-align is what centres the lines within the item. */
  text-align: center;

  white-space: normal;
  overflow: hidden;
  transition: all 0.5s cubic-bezier(0.3, 0.86, 0.36, 0.95);
}

/* `.is-pressed` is the same travel, driven from a tap instead of a hover — a
   touch screen has neither hover nor focus-visible, so without it the buttons
   were inert on a phone right up until the page changed. animations.js adds the
   class on pointerdown and only on devices that report no hover. */
.btn-arrow:hover .btn-arrow__icon--lead,
.btn-arrow:focus-visible .btn-arrow__icon--lead,
.btn-arrow.is-pressed .btn-arrow__icon--lead { width: 0; height: 0; border-width: 0; }

.btn-arrow:hover .btn-arrow__icon--trail,
.btn-arrow:focus-visible .btn-arrow__icon--trail,
.btn-arrow.is-pressed .btn-arrow__icon--trail { width: 60px; height: 60px; border-width: 2px; }

/* On the green panels the outline needs to invert or it disappears. */
.band--accent .btn-arrow,
.popup .btn-arrow { color: var(--c-panel); }

/* Solid / ghost — used by the consent banner and the popup. */
.btn {
  display: inline-grid;
  place-items: center;
  text-align: center;   /* same reason as .btn-arrow__label — centres the lines, not just the box */
  min-height: 48px;
  padding-inline: var(--s-4);
  font-family: var(--font-mono);
  font-size: var(--t-button-sm);
  font-weight: var(--fw-medium);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  border: 1px solid currentColor;
  transition: background var(--dur-fast) var(--ease-out),
              color var(--dur-fast) var(--ease-out);
}

.btn--solid {
  background: var(--c-accent);
  color: var(--c-on-accent);
  border-color: var(--c-accent);
}

.btn--solid:hover { background: var(--c-green-deep); border-color: var(--c-green-deep); color: var(--c-on-accent); }
.btn--ghost:hover { background: currentColor; }
.btn--ghost:hover > * { color: var(--c-bg); }


/* --- Marquee button ------------------------------------------------------
   The submit control in the contact popup. The label repeats and scrolls; the
   track is duplicated so the loop has no visible seam. Duplicates are marked
   aria-hidden so the accessible name stays a single "Envoyer". */

/* XD: 735 x 48 at r10 in #3a3e3d, label in IBM Plex Mono 18 green. */
.btn-marquee {
  position: relative;
  display: block;
  width: 100%;
  height: 48px;
  margin-block-start: 36px;
  overflow: hidden;
  border-radius: var(--radius-card);
  background: var(--c-ink);
  color: var(--c-accent);
  font-family: var(--font-mono);
  font-size: var(--t-label);
  font-weight: var(--fw-medium);
}

.btn-marquee__track {
  display: flex;
  align-items: center;
  gap: var(--s-5);
  height: 100%;
  width: max-content;
  /* Slower than the 12s it was: adding the arrow separators lengthened the
     track, and the same duration over a longer loop reads as speeding up. */
  animation: z1-marquee 26s linear infinite;
}

/* Hold on hover, same as the footer bar. It used to speed up, which read as a
   glitch rather than a response. */
.btn-marquee:hover .btn-marquee__track,
.btn-marquee:focus-visible .btn-marquee__track { animation-play-state: paused; }

.btn-marquee[disabled] { opacity: 0.5; cursor: progress; }

@keyframes z1-marquee {
  from { translate: 0 0; }
  to   { translate: -50% 0; }
}

@media (prefers-reduced-motion: reduce) {
  .btn-marquee__track { animation: none; justify-content: center; width: 100%; }
}


/* --------------------------------------------------------------------------
   Three.js sphere canvas

   sphere.js appends the renderer's canvas straight into its host element. A
   canvas is display:block and in-flow by default, so without this it is laid
   out as a sibling of the content and adds its own height — which inflated the
   hero from 1057px to 1921px and pushed the particles below the fold.

   The host must establish a containing block; .hero and .section-sphere both
   set position:relative and isolation:isolate.
   -------------------------------------------------------------------------- */

.sphere-canvas {
  position: absolute;
  inset: 0;
  z-index: -1;
  width: 100%;
  height: 100%;
  pointer-events: none;   /* the CTA button sits over this */
}


/* --- Marquee strip ------------------------------------------------------- */

/* XD: an inset rounded bar, 1388 x 48 at r10 — not a full-bleed strip.
   Items repeat every 166px in IBM Plex Mono 18. */

.marquee {
  display: flex;
  align-items: center;
  width: 100%;
  height: 48px;
  overflow: hidden;
  border-radius: var(--radius-card);
  background: var(--c-accent);
  color: var(--c-ink);
  cursor: pointer;
  transition: background var(--dur-fast) var(--ease-out);
}

/* One repeat is 166px in the design: the 7-character label at 18px mono is
   ~76 wide and the glyph 14, leaving 76 of clear space to split either side.
   The gap applies between every flex child, label and separator alike, so it is
   half of that rather than the whole distance between two labels. */
.marquee__track {
  display: flex;
  align-items: center;
  gap: 38px;
  width: max-content;
  animation: z1-marquee 30s linear infinite;
}

/* Hold the label still while the pointer is over it, so the target you are
   about to click stops moving. */
.marquee:hover .marquee__track,
.marquee:focus-visible .marquee__track { animation-play-state: paused; }

.marquee:hover { background: var(--c-green-deep); }

/* The footer bar was the one piece of continuous motion on the site with no
   reduced-motion rule — `.btn-marquee__track` above has had one all along — so
   it kept scrolling for the whole session for anyone who has asked their system
   to stop that. Held at its start, the track reads exactly as the artboard
   draws the bar: the first "+ CONTACT" on the left edge, repeats running off
   into the overflow. */
@media (prefers-reduced-motion: reduce) {
  .marquee__track { animation: none; }
}

.marquee__item {
  font-family: var(--font-mono);
  font-size: var(--t-label);
  font-weight: var(--fw-medium);
  line-height: 1;
  white-space: nowrap;
}


/* --------------------------------------------------------------------------
   Case study card
   Green content panel beside a photograph; the pair alternate sides down the
   page. The dark cell on the panel's edge carries the E1/ numeral.
   -------------------------------------------------------------------------- */

/* XD geometry, measured from the Accueil artboard:

     panel  x27  605 x 427  #ade542 r10
     number x632  80 x 427  #a4d93f r10   <- its own column, not a badge
     image  x728 686 x 686  r10           <- square, taller than the panel

   605 + 80 = 685, then a 16px gutter, then 686 — so the row is two halves,
   and the left half splits again into body and number column. */

.case-studies > * + * { margin-top: var(--grid-gutter); }

.case-card {
  display: grid;
  grid-template-columns: 685fr 686fr;
  gap: var(--grid-gutter);
  align-items: start;
}

.case-card--right .case-card__panel { order: 2; }

/* The green panel pins while the photograph scrolls past it — this is what the
   old module's name (md-sticky-card) referred to, and dropping it lost the
   section's defining interaction. Original values: sticky, top 100px,
   height fit-content, align-self start. */
.case-card__panel {
  position: sticky;
  top: 100px;
  align-self: start;
  height: fit-content;
  display: grid;
  grid-template-columns: 605fr 80fr;
  min-height: 427px;
  color: var(--c-ink);          /* XD text colour is #3a3e3d, not the panel black */

  /* The whole panel opens the case study -- case-card.js forwards the click to
     the anchor in the column. The cursor is the only thing announcing that.
     Inherited, so it still reaches the children below 768, where the panel is
     display: contents and has no box of its own. */
  cursor: pointer;
}

/* The two halves each carry their OWN 10px radius rather than sharing one on
   the panel. Rectangle 2365 and Rectangle 2364 are both r=[10,10,10,10] and
   they abut at x632 with nothing drawn between them -- stroke.type is "none"
   on both. Where the two curves meet, the dark band behind shows through as a
   20x10 notch at the head and foot of the seam, and that notch IS the divider:
   #ade542 against #a4d93f is otherwise too small a step to read. Rounding the
   panel instead, as this did, butt-joins the halves into one slab. */
.case-card__body {
  display: flex;
  flex-direction: column;
  background: var(--c-accent);
  border-radius: var(--radius-card);
  padding: 44px 36px 25px 29px; /* content box 29..595.5, foot 25 above the panel's */
}

.case-card__title {
  font-size: var(--t-card-title);
  line-height: calc(var(--t-card-title-lh) / var(--t-card-title));
  font-weight: var(--fw-light);
  max-width: 498px;             /* XD text frame width */
}

.case-card__stats {
  display: flex;
  gap: 60px;                    /* stat columns at x56 and x213 */

  /* 51, not 128. The title is an autoHeight frame in XD, so its node y is the
     frame TOP and its baselines live in text.paragraphs.lines -- +60 and +124
     inside the frame. Reading that y as a baseline (which the point-text nodes
     around it genuinely are) put everything below the title 77px too low. */
  margin-block-start: 51px;

  /* The stats block is NOT full-strength ink: an ancestor group in XD carries
     opacity 0.7, so the figures and their captions sit back from the title and
     from the bold values below the rule. Written as an alpha colour rather
     than opacity so the block does not become its own stacking context.

     The 21 floor matters more here than in the design. Ligne 154 sits 25 under
     the caption's baseline -- 21 under its box -- and .case-card__details wins
     that space with margin-block-start:auto, which resolves to 0 the moment the
     content stops fitting the 427 min-height. A caption of one word does fit; a
     real one ("commandes traitees par annee") wraps to two lines, auto goes to
     zero and the rule lands against the descenders. The panel grows instead. */
  margin-block-end: 21px;
  color: rgb(58 62 61 / 0.7);
}

.case-card__stat-value {
  font-size: var(--t-lead);
  line-height: calc(var(--t-lead-lh) / var(--t-lead));
  font-weight: var(--fw-medium);
}

.case-card__stat-label {
  margin-top: 2px;
  font-size: var(--t-body);
  line-height: calc(var(--t-body-lh) / var(--t-body));
}

/* Ligne 154: the full content width, #3a3e3d at 20% -- deliberately fainter
   than the per-row rules below it, which carry no opacity at all. */
.case-card__details {
  margin-block-start: auto;
  border-block-start: 1px solid rgb(58 62 61 / 0.2);
  padding-block-start: 31px;

  display: flex;
  gap: var(--s-2);
}

.case-card__rows {
  flex: 1 1 auto;
  min-width: 0;
}

/* Held together as one group and centred against the block, rather than one
   pill per row. The artboard steps them 29 apart at 24 tall, so the gap is 5,
   and that spacing now holds whatever the rows do -- a value wrapping to two
   lines grows its row but no longer drags a pill with it. */
.case-card__tags {
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: flex-end;
  gap: 5px;                     /* pills step 29: 24 tall + 5 */
}

.case-card__row {
  /* Ligne 160 measures 219 on the 1440, 768 AND 375 artboards -- a fixed rule
     under the label and value, not a border running out to meet the tag. A
     border-bottom can only span the whole row, so the rule is painted as a
     background bar instead; min() holds it inside the content box at 375,
     where the design lets its 219 overhang. */
  --rule-w: 219px;

  display: flex;
  align-items: center;
  gap: var(--s-2);
  min-height: 28px;             /* rules step 28 apart: 346 / 374 / 402 */
  background-image: linear-gradient(var(--c-ink), var(--c-ink));
  background-repeat: no-repeat;
  background-size: min(var(--rule-w), 100%) 1px;
  background-position: left bottom;
}

.case-card__label {
  flex: 0 0 65px;               /* label x56, value x121 */
  color: rgb(58 62 61 / 0.7);   /* same 0.7 as the stats; the values stay at full */
  font-size: var(--t-micro);
  line-height: calc(var(--t-micro-lh) / var(--t-micro));
}

.case-card__value {
  font-size: var(--t-strong);
  line-height: calc(var(--t-strong-lh) / var(--t-strong));
  font-weight: var(--fw-bold);

  /* The rule under the row is a fixed 219 (see .case-card__row), so a value
     long enough to pass it -- "Automatiser les processus", "Extranet + Caisse
     enregistreuse" -- reads as an underline that gave up halfway. Wrapping the
     value keeps it over its own rule. 219 less the 65 label and the 16 gap
     leaves 138; 120 is the width picked on the live card, which holds the wrap
     clear of the rule's end rather than right against it. */
  max-width: 120px;

  /* A one-line row is 18 of text inside 28, so the text already floats 5 clear
     of the rule above and below. A wrapped value is 36 tall, min-height stops
     governing, and it filled the row edge to edge -- sitting on both rules at
     once. This is that same 5 made explicit: one line still measures
     18 + 10 = 28 and nothing moves, two lines grow the row to 46 and keep the
     clearance instead of spending it. */
  padding-block: 5px;
}

.case-card__tag {
  display: grid;
  place-items: center;
  min-height: 24px;
  padding-inline: var(--s-1);
  border: 1px solid currentColor;
  border-radius: 4px;
  font-family: var(--font-mono);
  font-size: var(--t-tag);
  line-height: 1;
  text-transform: uppercase;
}

/* --- Tag tooltip ---------------------------------------------------------

   A pill like POS or GPAO says nothing to a reader who does not already work
   in the field, and the card has no room to spell it out. The gloss comes from
   tag1_desc..tag3_desc on the case study and rides in on data-tip.

   Painted from the pill's own pseudo-elements rather than a positioned div,
   which keeps it out of the markup and out of the JS. It can escape the card
   because nothing between here and .case-card clips: the body and the column
   carry a radius but no overflow, deliberately.

   Right-aligned, not centred. The pills sit against the panel's right edge, so
   a centred bubble would hang past it; anchoring the bubble's right edge to the
   pill's sends it inward across the panel, where there is always room. It opens
   upward for the same reason -- above every pill is either the stats block or
   another row, never the panel's edge.

   pointer-events: none so the bubble cannot swallow the click that the rest of
   the tile now uses to open the case study. */

.case-card__tag[data-tip] { position: relative; }

.case-card__tag[data-tip]::before,
.case-card__tag[data-tip]::after {
  position: absolute;
  bottom: calc(100% + 9px);
  opacity: 0;
  visibility: hidden;
  transform: translateY(4px);
  transition: opacity var(--dur-fast) var(--ease-out),
              transform var(--dur-fast) var(--ease-out),
              visibility var(--dur-fast);
  pointer-events: none;
}

.case-card__tag[data-tip]::after {
  content: attr(data-tip);
  right: 0;
  z-index: 5;

  width: max-content;
  max-width: 240px;
  padding: 9px 11px;
  border-radius: 6px;
  background: var(--c-panel);
  color: var(--c-mist-3);
  box-shadow: 0 8px 24px rgb(0 0 0 / 0.28);

  /* The pill is 12px uppercase mono; the gloss is a sentence, so it drops back
     to the body face and normal case or it reads as more label, not less. */
  font-family: var(--font-sans);
  font-size: 13px;
  line-height: 1.4;
  font-weight: var(--fw-light);
  letter-spacing: 0;
  text-transform: none;
  text-align: left;
  white-space: normal;
}

/* The nib. 6px of border on a zero-size box, only the top edge coloured. */
.case-card__tag[data-tip]::before {
  content: "";
  right: 14px;
  bottom: calc(100% + 3px);
  z-index: 6;
  border: 6px solid transparent;
  border-block-start-color: var(--c-panel);
}

/* `.is-tip-open` is the tap equivalent of :hover, set by case-card.js on a
   pointer that cannot hover. It is not scoped to a breakpoint: the class is
   only ever added where there is no hover, and keeping the open state one rule
   means the bubble cannot end up styled two different ways. */
.case-card__tag[data-tip]:hover::before,
.case-card__tag[data-tip]:hover::after,
.case-card__tag[data-tip]:focus-visible::before,
.case-card__tag[data-tip]:focus-visible::after,
.case-card__tag[data-tip].is-tip-open::before,
.case-card__tag[data-tip].is-tip-open::after {
  opacity: 1;
  visibility: visible;
  transform: translateY(0);
}

@media (prefers-reduced-motion: reduce) {
  .case-card__tag[data-tip]::before,
  .case-card__tag[data-tip]::after { transition: none; transform: none; }
}


.case-card__number {
  display: flex;
  flex-direction: column;
  align-items: center;
  background: var(--c-green-deep);
  border-radius: var(--radius-card);

  /* Asked for, and against the note below: the boards draw this half unstroked
     and leave the notch to divide it. The stroke is a later call — it reads the
     seam more plainly than the notch does at small sizes. Costs no width:
     box-sizing is border-box for everything. */
  border: 1px solid var(--c-green-edge);

  /* No stroke, deliberately. Rectangle 2364 carries `stroke: none` on every
     artboard that draws it -- 1440, 768 and 375 alike -- and so does the body
     beside it. The divider is the notch: the two halves each carry their own
     10px radius and abut exactly, so where the curves meet the dark band
     behind shows through at the head and the foot of the seam. A hairline was
     tried here and it is not what the design draws.

     The 375 breakpoint is the one place a hairline survives, because the card
     is a single clipped slab there and has no notch to divide it -- see the
     divergence noted on .case-card in responsive.css. */

  /* E1/ sits 35 below the panel top, the link 25 above its bottom. */
  padding-block: 35px 25px;
}

/* The link is pushed to the foot of the column rather than given a top margin,
   so it stays put whatever height the panel resolves to. */
.case-card__link {
  margin-block-start: auto;
  display: grid;
  place-items: center;
  width: 30px;
  height: 30px;
  border-radius: 10px;
  background: var(--c-panel);
  color: var(--c-mist);
}

/* Hovering anywhere on the green panel drives the control, because the whole
   panel is what opens the case study now -- a 30x30 target that lit up
   only when the pointer was exactly on it read as the one live thing in a dead
   strip. Keyboard focus still lands on the anchor itself, which is the only
   focusable thing here, so the second selector is not a duplicate of the
   first. */

/* Three quarters of a turn of the split-plus on hover, ending on a colour
   inversion -- and on the way out, the same turn played backwards, with the
   inversion held until the glyph is home.

   Transitions, not keyframes. A keyframe animation exists only while :hover
   matches, so leaving snapped the glyph to 0 and the colours back in one frame:
   there was no way out but a cut. A transition runs in both directions, and
   reverses from wherever it has got to if the pointer leaves halfway.

   The sequence is carried entirely by delays on 0s colour transitions:

     in    0ms     glyph goes green, starts turning
           300ms   turn ends; background goes green, glyph goes dark
     out   0ms     turn starts back, inverted colours held
           300ms   glyph home; background and glyph back to rest

   The glyph needs two colour steps on the way in (green while turning, dark
   after), which one property cannot transition through. So they are split: the
   svg's `color` carries the first, and the path's `stroke` -- a currentColor
   attribute until it is overridden here -- carries the second.

   300ms over 270deg is 400ms per revolution -- the speed, not the length, is
   what this number is protecting. It was 600 over 540: the same 400ms per
   revolution, but a turn and a half, and it outstayed its welcome. Halving
   both keeps the rotation exactly as fast as it was and simply ends it sooner;
   halving the duration alone would have doubled the speed instead.

   The original reasoning still stands. Three turns was unfollowable, and
   the only way to slow it enough was to make the whole thing outstay its
   welcome; walking both down together — fewer turns AND a slower turn — is
   what buys a rotation the eye can actually track.

   The rotation is LINEAR on purpose. --ease-out here is cubic-bezier(0.16, 1,
   0.3, 1), which puts roughly 85% of the rotation inside the first 20% of the
   time: the glyph whips once and then crawls, so the turns never read. Linear
   is also what lets the colour cut land exactly on the turn boundary.

   The background changes ONCE each way, and on the last frame of the turn.
   Putting the cut at the halfway mark meant the glyph kept turning after the
   colour had already flipped, which read as two separate events; landing them
   together makes the colour change the thing that ends the rotation.

   (An earlier pass ran the background dark -> green -> navy -> green. Green
   arriving twice read as a glitch rather than an effect.) */
.case-card__link {
  transition: background-color 0s linear 300ms;
}

.case-card__link svg {
  display: block;
  transition: transform 300ms linear, color 0s linear 300ms;
}

.case-card__link path {
  stroke: currentColor;
  transition: stroke 0s linear 300ms;
}

.case-card__panel:hover .case-card__link,
.case-card__link:focus-visible {
  background-color: var(--c-accent);
}

/* `color 0s` with no delay: on the way in the glyph goes green on the first
   frame. The base rule's 300ms delay is what holds it on the way out. */
.case-card__panel:hover .case-card__link svg,
.case-card__link:focus-visible svg {
  color: var(--c-accent);
  transform: rotate(270deg);
  transition: transform 300ms linear, color 0s;
}

.case-card__panel:hover .case-card__link path,
.case-card__link:focus-visible path {
  stroke: var(--c-panel);
}

@media (prefers-reduced-motion: reduce) {
  .case-card__link,
  .case-card__link svg,
  .case-card__link path,
  .case-card__panel:hover .case-card__link svg,
  .case-card__link:focus-visible svg { transition: none; }

  .case-card__panel:hover .case-card__link svg,
  .case-card__link:focus-visible svg { transform: none; }
}

.case-card__number-text {
  font-family: var(--font-display);
  font-size: var(--t-step);
  line-height: 1;
  font-weight: 200;

  /* Every OT Bulb node in the XD carries letterSpacing -70 (-0.07em), the case
     numerals included. .step__number and .row-card__mark already set it; this
     one did not, so E1/ sat wider than S1/ on the same page. */
  letter-spacing: -0.07em;
  color: var(--c-panel);
}

/* Sticky too, and on the same 100px line as the panel.

   The interaction is "the shorter column pins while the taller one scrolls
   past". Only the panel was told to pin, which encodes an assumption — that the
   photograph is always the taller of the two — and it holds at 1440, where the
   square is 686 against a 427 panel, but not lower down: the columns are half
   the content width, so the square shrinks with the page while the panel grows
   as its title wraps. Somewhere around 1026 they cross and the photograph is the
   short one, standing still beside a panel that has nothing to pin against.

   Where they cross depends on the length of the copy in the card, not on a
   width, so there is no breakpoint that would catch it. Making both sticky
   settles it without one: a grid item can only pin inside its own containing
   block, and the taller column is already as tall as the row, so it has no room
   to move and behaves exactly as it does now. Whichever is shorter gets the
   slack, and that is the one that pins. `align-items: start` on the card is what
   keeps this true — stretch would give both of them the full row height and
   neither would ever move. */
.case-card__media {
  position: sticky;
  top: 100px;
  align-self: start;
  aspect-ratio: 1;              /* XD: 686 x 686 */
  border-radius: var(--radius-card);
  overflow: hidden;
}

.case-card__media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  filter: grayscale(1);
  transition: filter var(--dur-slow) var(--ease-out), scale var(--dur-slow) var(--ease-out);
}

.case-card:hover .case-card__media img { filter: grayscale(0); scale: 1.03; }


/* --------------------------------------------------------------------------
   Process step card — three across
   -------------------------------------------------------------------------- */

/* XD: three 452 x 461 cards at x26/494/962 on the #181c18 band. The card face
   is light (#d8dcd8), not a dark outlined box — tags stack vertically, and the
   foot pairs the step number with a small filled DÉTAILS button. */

.process-steps {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--grid-gutter);
  align-items: stretch;
}

/* The grid stretches .step, but the card inside it would keep its own height —
   real copy is longer than the design's placeholder, so without this the three
   cards end at different depths and the DÉTAILS buttons stop lining up. */
.step {
  display: flex;
  flex-direction: column;
  height: 100%;
}

.step > .step-card { flex: 1 1 auto; }

/* No min-height at this width. The board's 461 was sized for five lines of
   placeholder; the real copy is three or four, and the fixed face left it
   sitting high with 50-70px of empty card under it. The card is its content
   now, and the grid's stretch still evens the three — see .step-card__desc. */
.step-card {
  display: flex;
  flex-direction: column;
  padding: 41px 30px 40px;
  border-radius: var(--radius-card);
  background: var(--c-mist-3);
  color: var(--c-ink);
}

/* The foot is a second block of the card, not bare band: a 452 x 102 bar in the
   next grey down, flush under the 461px face and the same width. It reads as one
   two-tone tile, which is why dropping it left the number and button floating on
   the dark band.

   XD: Rectangle 2367, y4671-4773, #ccd3cf r10 — stored rotated 90°, so a walk
   that only reads tx/ty puts it in the wrong place at 102 x 452. Number and
   button are centred on the bar (both at y4722, its midline). */
.step__foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-2);
  height: 102px;
  padding-inline: 30px;   /* aligns with the card's own inset */
  border-radius: var(--radius-card);
  background: var(--c-mist-2);
}

/* letterSpacing -70 in the boards — 1/1000 em, so -0.07em, about 2.8px at 40.
   Every OT Bulb node on all four artboards carries it; applied here and on
   .row-card__numeral, the two that appear on the pages measured. */
.step__number {
  font-family: var(--font-display);
  font-size: var(--t-step);
  line-height: 1;
  font-weight: 200;
  letter-spacing: -0.07em;
  color: var(--c-panel);
}

/* The board gives the three cards one shared y for their tags, their rule and
   their description — it draws the title in a fixed slot. In flow, a title that
   wraps to two lines pushed everything under it down by one 46px line, so the
   middle card sat 46 low against its neighbours while keeping the same card
   height, leaving it 30px of bottom margin against their 94.

   Two lines is the slot: at 1440 the widest title takes two, and at 1025 —
   where the column is down to 249 — two is still the most any of them needs. A
   longer title added later would reopen the gap, and the real fix then is
   subgrid across .process-steps / .step / .step-card. */
.process-steps .step-card__title { min-height: calc(2 * var(--t-h2-lh)); }

.step-card__title {
  font-size: var(--t-h2);
  line-height: calc(var(--t-h2-lh) / var(--t-h2));
  font-weight: var(--fw-light);
  color: var(--c-panel);
}

/* Stacked, not inline — each tag is its own 24px pill on its own row. */
/* Offsets from the card's top edge in the design: title 41, tags 170,
   rule 284, description 341, card bottom 461. The last two are no longer
   followed — see .step-card__desc. */
.step-card__tags {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 5px;
  margin-block-start: 37px;
}

.step-card__tag {
  display: grid;
  place-items: center;
  min-height: 24px;
  padding-inline: var(--s-1);
  border: 1px solid currentColor;
  border-radius: 4px;
  font-family: var(--font-mono);
  font-size: var(--t-tag);
  line-height: 1;
  text-transform: uppercase;
}

/* var(--c-ink), not currentColor. An <hr> is the one element that does not
   inherit its colour here: the UA stylesheet gives it its own `color: gray`,
   so currentColor resolved to #808080 rather than to the card's #3a3e3d that
   every other child inherits. Against the #d8dcd8 face, 35% of that grey lands
   on #b9b9b9 — a line that measures 1px and visibly is not there, which is why
   the cards read as having no rule at all. The ink takes it to #a1a1a1. */
.step-card__rule {
  width: 100%;
  height: 1px;
  margin-block: 32px auto;
  border: 0;
  background: var(--c-ink);
  opacity: 0.35;
}

/* The copy sits midway between the rule and the card's bottom edge: 40 above
   (was the board's 56) against the card's 40 of bottom padding. The longest
   description sets the row's height; on its shorter neighbours, stretched to
   match, the auto margins here and on the rule split the spare height evenly
   so the text stays centred rather than riding up under the rule.

   Desktop only in effect — responsive.css hides the rule and resets the top
   margin and padding below 1025, where the extra space falls to the bottom as
   before. */
.step-card__desc {
  margin-block: 40px auto;
  font-size: var(--t-body);
  line-height: calc(var(--t-body-lh) / var(--t-body));
}

/* Small button — filled, unlike the large outlined one, and it carries the
   split-plus rather than the arrow: the XD draws four 4px segments here, not the
   three that make up an arrowhead. Same collapse/expand travel, scaled down.
   XD: 30x30 green square at r10, then an 82x30 dark pill at r15. */
/* Height is a floor, not a fixed value.
 *
 * It was a flat 30px, which is right for the labels this button was drawn for
 * — DÉTAILS, and nothing longer. The case study links on the expertise pages
 * put a real title in it, and at 375 "Borderon et fils — Extranet et caisse"
 * needs 35px of text: it wrapped to two lines inside a 30px box and
 * `overflow: hidden` on the label cropped the second one.
 *
 * So the pill grows with its label instead of cropping it. A one-line label
 * still measures exactly 30 (18px of line plus the 6+6 below), so every button
 * already in the design is untouched; only a wrapping one gets taller. The
 * icon keeps its 30px square and centres against whatever height the label
 * takes, which is what `align-items: center` on .btn-arrow already does. */
.btn-arrow--sm {
  height: auto;
  min-height: 30px;
}

.btn-arrow--sm .btn-arrow__icon {
  width: 30px;
  height: 30px;
  border: 0;
  border-radius: 10px;
  background: var(--c-accent);
  color: var(--c-ink);
}

/* 12px between the segment endpoints plus a 2px square cap at each end — the
   glyph measures 14 across, centred in the 30px square. */
.btn-arrow--sm .btn-arrow__icon svg { width: 14px; height: 14px; }
.btn-arrow--sm .btn-arrow__icon--trail { width: 0; height: 0; }

.btn-arrow--sm:hover .btn-arrow__icon--lead,
.btn-arrow--sm:focus-visible .btn-arrow__icon--lead,
.btn-arrow--sm.is-pressed .btn-arrow__icon--lead { width: 0; height: 0; }

.btn-arrow--sm:hover .btn-arrow__icon--trail,
.btn-arrow--sm:focus-visible .btn-arrow__icon--trail,
.btn-arrow--sm.is-pressed .btn-arrow__icon--trail { width: 30px; height: 30px; border-width: 0; }

.btn-arrow--sm .btn-arrow__label {
  width: auto;
  min-width: 82px;

  /* See the note on .btn-arrow--sm. 18 + 6 + 6 = the 30 the design draws, and
     a second line adds exactly one line-height rather than being cut off. */
  height: auto;
  min-height: 30px;
  line-height: 18px;
  padding-block: 6px;

  padding-inline: var(--s-2);
  border: 0;
  border-radius: 15px;
  background: var(--c-panel);
  color: var(--c-mist);
  font-family: var(--font-mono);
  font-size: var(--t-button-sm);
  font-weight: var(--fw-medium);
}


/* --------------------------------------------------------------------------
   Row card — a wide green row with a numeral column on its right

   One component, two pages. The Étude de cas artboard draws it for the
   Solutions list and the À propos artboards draw it for the Subvention row and
   the four Approche steps; the rects are the same object in both files, down to
   the pair of greens:

     1440   body x26  w1308 #ade542   ·  numeral x1334 w80 #a4d93f   h219
      768   body x26  w626  #ade542   ·  numeral x652  w96 #a4d93f   h246
      375   body x20  w252  #ade542   ·  numeral x272  w80 #a4d93f   h293

   It used to exist only as `.solution`, scoped to the case study, which meant
   its 768 and 375 behaviour was invented — that page has no responsive
   artboard. À propos has all three, so the breakpoints below are measured
   rather than guessed, and the case study inherits them.

   Heights are a floor, not a fixed value: the artboard's E2 row is already
   taller than its siblings because its title wraps, so real copy growing the
   row is the drawn behaviour.
   -------------------------------------------------------------------------- */

.row-cards > * + * { margin-top: 20px; }

/* The wide step — the green row that closes the Expertises group rather than
   opening a section of its own. 20px under the last card's foot, which is the
   same gap the rows under the Approche band keep between themselves. XD 1440:
   the card feet end at 2914 and the row opens at 2934.

   It lives here and not under About in pages.css, because it is no longer an
   À propos block — it is a process step like the grey ones and renders wherever
   they do. See z1_render_process_steps(). */
.process-steps-wide { margin-block-start: 20px; }

/* Built like the case card's panel, because the boards draw it the same way:
   Rectangle 2363 and Rectangle 2364 are two abutting rectangles, each
   r=[10,10,10,10] and each with `stroke: none`, on every artboard — 1308+80 at
   1440, 720+78 at 768, 252+80 at 375. They touch exactly, so where the two
   curves meet the dark band behind shows through as a notch at the head and
   foot of the seam, and that notch is the divider.

   This carried one radius on the card with `overflow: hidden`, which
   butt-joins the halves into a single slab and loses the notch — the numeral
   column read as a painted stripe rather than as its own tile. The fill moves
   to the two halves for the same reason: a fill here paints the notch green. */
.row-card {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 80px;
  min-height: 219px;
  color: var(--c-ink);
}

/* Title in the first column at x56, body from x611 — the same axis the
   editorial blocks use, so a page keeps one text column throughout. */
.row-card__body {
  display: grid;
  grid-template-columns: 392px minmax(0, 1fr);
  gap: 163px;
  padding: 32px 30px;
  border-radius: var(--radius-card);
  background: var(--c-accent);
}

.row-card__title {
  font-size: var(--t-h2);
  line-height: calc(var(--t-h2-lh) / var(--t-h2));
  font-weight: var(--fw-light);
  color: var(--c-panel);
}

.row-card__text {
  max-width: 392px;
  margin-block-start: 7px;
  font-size: var(--t-body);
  line-height: calc(var(--t-body-lh) / var(--t-body));
}

.row-card__number {
  display: flex;
  flex-direction: column;
  align-items: center;
  background: var(--c-green-deep);
  border-radius: var(--radius-card);
  padding-block: 40px 25px;

  /* See .case-card__number — the same stroke, for the same reason, and it
     covers the Approche steps, the financement row, the case study's solution
     rows and the expertise pages, which are all this one component. */
  border: 1px solid var(--c-green-edge);

  /* Unstroked, like the case card's column — Rectangle 2364 is `stroke: none`
     on all three À propos boards too. The notch between the two radii is the
     divider at every width here: this block is 219 tall at 1440 and 293 at
     375, short enough that the two bites read as the join they are. */
}

/* The whole column is the target where it carries the trigger, the way the case
   card gives its deep-green column to the click. The pointer is what says so:
   without it only the 30px mark looked live, which is the reading the change was
   meant to undo. Scoped to the attribute rather than asserted on the class, so a
   row rendered without a trigger keeps the default arrow and stays honestly
   inert. */
.row-card__number[data-popup-open] { cursor: pointer; }

.row-card__numeral {
  font-family: var(--font-display);
  font-size: var(--t-step);
  line-height: 1;
  font-weight: 200;
  letter-spacing: -0.07em;   /* see .step__number */
  color: var(--c-panel);
}

.row-card__mark {
  margin-block-start: auto;
  display: grid;
  place-items: center;
  width: 30px;
  height: 30px;
  border-radius: 10px;
  background: var(--c-panel);
  color: var(--c-mist);
}

/* --- The mark as a control -----------------------------------------------

   The mark opens the contact popup wherever it is rendered as a <button>: the
   Approche steps and the wide process step on À propos, the wide step again
   wherever else the steps are drawn, and the solution rows on a case study.
   The behaviour hangs off the `--action` modifier rather than off the element,
   so a row that the design gives nothing to do can still render the mark as a
   plain span that never lights up.

   Where the column itself carries `data-popup-open` — the Approche steps and
   the wide step — the whole strip is the target, as the case card's deep-green
   column is, and the rule above gives it the pointer to say so. The button
   inside is still the only tab stop. On the case study's solution rows the mark
   alone is clickable. Either way the hover is driven from the column, so the
   strip and its control light up as one thing.

   The animation is the case card's, turn and rewind alike — see the note
   above .case-card__link for the sequence and why the colour cuts sit where
   they do. */

.row-card__mark--action {
  appearance: none;
  border: 0;
  padding: 0;
  font: inherit;
  cursor: pointer;
  transition: background-color 0s linear 300ms;
}

.row-card__mark--action svg {
  display: block;
  transition: transform 300ms linear, color 0s linear 300ms;
}

.row-card__mark--action path {
  stroke: currentColor;
  transition: stroke 0s linear 300ms;
}

.row-card__number:hover .row-card__mark--action,
.row-card__mark--action:focus-visible {
  background-color: var(--c-accent);
}

.row-card__number:hover .row-card__mark--action svg,
.row-card__mark--action:focus-visible svg {
  color: var(--c-accent);
  transform: rotate(270deg);
  transition: transform 300ms linear, color 0s;
}

.row-card__number:hover .row-card__mark--action path,
.row-card__mark--action:focus-visible path {
  stroke: var(--c-panel);
}

@media (prefers-reduced-motion: reduce) {
  .row-card__mark--action,
  .row-card__mark--action svg,
  .row-card__mark--action path,
  .row-card__number:hover .row-card__mark--action svg,
  .row-card__mark--action:focus-visible svg { transition: none; }

  .row-card__number:hover .row-card__mark--action svg,
  .row-card__mark--action:focus-visible svg { transform: none; }
}


/* --------------------------------------------------------------------------
   Contact popup
   Full-viewport green overlay. Opening locks body scroll (see popup.js).
   -------------------------------------------------------------------------- */

/* Not a full-bleed overlay. The popup is the 803px panel alone, pinned top
   right beneath the CONTACT button — the full green rectangle in the artboard
   is the page state behind it, not the dialog. The fixed layer here is
   transparent and exists only to catch clicks outside the panel. */
.popup {
  position: fixed;
  inset-inline: 0;

  /* Not `inset: 0`, and the reason is a phone.
   *
   * A fixed element resolves against the layout viewport, and on iOS that box
   * keeps the height it has with the toolbars hidden — it does not shrink when
   * they come back. So once the URL bar is on screen, the top of this layer is
   * behind it, and the panel, which starts at that top, goes up with it. At the
   * very top of a page the two boxes agree and the form lands correctly; scroll
   * down, the bar reappears, and the head of the form is under it. Reported
   * from an iPhone; it cannot be reproduced in a desktop browser at any width,
   * where the layout, small and large viewports are all the same box.
   *
   * popup.js publishes the visual viewport — what is actually on screen — into
   * these two properties. The fallbacks are exactly the old `inset: 0`, so a
   * browser without the API, or a page where the script has not run yet,
   * behaves as it did before. */
  top: var(--popup-vv-top, 0px);
  height: var(--popup-vv-h, 100%);

  z-index: var(--z-popup);
  color: var(--c-panel-deep);

  /* The layer itself must never scroll. It holds a child parked off-screen to
     the right, and letting it be the scroll container meant the browser was
     resolving a scrollport around a moving element mid-transition — which is
     what made the slide stutter. The panel does its own scrolling instead. */
  overflow: hidden;

  /* No visibility toggle. Flipping visibility in the same frame as the
     transform is what made the slide misfire — the browser can skip a
     transition on an element that was not being rendered a frame earlier.
     The panel is simply parked off-screen, and the layer stops swallowing
     clicks when closed. popup.js marks it inert so it stays out of the tab
     order while hidden. */
  pointer-events: none;
}

.popup.is-open { pointer-events: auto; }

/* Scrim — dims and blurs the page so the panel reads as a layer above it rather
   than a card floating on live content. Its own element so it can fade
   independently of the panel's slide. */
.popup::before {
  content: '';
  position: fixed;
  inset: 0;
  z-index: -1;

  /* A heavy dim rather than backdrop-filter. Blurring here means compositing a
     blur over a 6000px document with two WebGL canvases behind it, and it froze
     the renderer outright in testing — twice. The dim reads the same at this
     strength and costs nothing. */
  background: rgb(24 28 24 / 0.82);

  opacity: 0;
  transition: opacity 0.45s var(--ease-out);
}

.popup.is-open::before { opacity: 1; }


/* 803 wide, right-aligned to the content edge, starting 25 from the top.

   The padding resolves to the page margin on narrow viewports and to the
   centred 1440 content box on wide ones, so the panel lands on the same right
   edge as the rest of the site instead of hugging the window. */
/* Distance from the viewport's right edge to the panel's right edge: the page
   margin on narrow screens, half the leftover plus the margin once the 1440
   content box is centred.

   Expressed in vw, not %, deliberately. A percentage inside a custom property
   resolves against whatever element consumes it — and this value is consumed
   both as padding on the layer and inside the panel's own translate, where %
   means the panel's width. vw means the same thing in both places. */
.popup {
  --popup-inset: max(
    var(--page-margin),
    calc((100vw - var(--page-max)) / 2 + var(--page-margin))
  );

  /* Panel width as an absolute length rather than a percentage — see the
     translate below for why that matters. */
  --popup-w: min(803px, calc(100vw - var(--popup-inset) * 2));

  padding-inline: var(--popup-inset);
}

/* Slides in from the right edge and lands with its top on the same line as the
   contact button that opened it (button top is y=30: 25px header padding plus
   the 5px offset the design gives it inside the menu row). */
.popup__panel {
  width: var(--popup-w);
  max-height: 100%;
  margin-inline: auto 0;
  /* Bar top y25, not y30. The 48px close button is centred in the 58px bar, so
     it lands on y30 — the same line as the header's own contact button, which
     is what this offset was reaching for. Setting the panel itself to 30 put
     the bar 5px low and the button 5px lower still. */
  padding-block: 25px 0;          /* the body carries its own 40px bottom inset */
  overflow-y: auto;
  overscroll-behavior: contain;   /* don't chain scrolling to the page behind */

  /* One plain pixel length, measured and set by popup.js.

     Every composed form of this failed to interpolate. calc(100% + inset)
     snapped the percentage and eased only the pixels — an instant 788px jump
     then a 266px glide. Swapping to calc(var(--popup-w) + var(--popup-inset))
     moved the problem rather than fixing it: the min() inside --popup-w then
     snapped instead. A single resolved length has no parts to disagree about.

     The fallback keeps the panel off-screen if the script has not run yet. */
  translate: var(--popup-park, 120vw) 0;

  /* Closing is a plain accelerating exit — nothing to land, so nothing to
     settle. The arrival curve is set on .is-open below. */
  transition: translate 0.42s cubic-bezier(0.4, 0, 1, 1);
  will-change: translate;
}

/* At the design's sizing the panel is 870 tall and fits a 900 viewport, so this
   only engages on shorter screens — and when it does it should not put a bar
   down the middle of the form. */
.popup__panel { scrollbar-width: none; }
.popup__panel::-webkit-scrollbar { width: 0; height: 0; }

/* Arrives with a slight overshoot and settles back, so it lands rather than
   stops dead. The >1 second control point is what carries it a few pixels past
   its resting place before it comes back. */
.popup.is-open .popup__panel {
  translate: 0 0;
  transition: translate 0.62s cubic-bezier(0.34, 1.4, 0.64, 1);
}

@media (prefers-reduced-motion: reduce) {
  .popup__panel,
  .popup.is-open .popup__panel { transition: none; }
  .popup::before { transition: none; }
}

.popup__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: 58px;
  padding-inline: 23px;
  border-radius: var(--radius-card);
  background: var(--c-green-deep);
}

.popup__label {
  font-family: var(--font-display);
  font-size: var(--t-step);
  line-height: 1;
  font-weight: 200;
  color: var(--c-panel);
}

/* Inherits .contact-toggle — same 48x48 green square as the header button. */
.popup__close { margin-inline-end: -18px; }

/* Desktop only, and the inverse of .contact-toggle's two states.

   That button was drawn for the header, where it sits on the dark bar and the
   light accent is what makes it a button; deepening on hover is the press. Here
   it sits *on* the deep-green head bar, so at rest it should be the bar — one
   square of the same green, the glyph carrying it — and the lighter accent is
   what it lifts to under the cursor.

   The scale and the transition are inherited untouched; only the two fills
   trade places. Held to ≥1025 because the tablet and phone popups keep the
   shared behaviour. */
@media (min-width: 1025px) {
  .popup__close { background: var(--c-green-deep); }
  .popup__close:hover { background: var(--c-accent); }
}

/* The body is its own rounded green card sitting directly under the header bar
   (bar at y25 is 58 tall, body starts at y83 — they touch, no gap). */
/* Offsets measured down the artboard from the panel's top edge (y83):
   title 154, mandatory 290, fields 313, rows pitch 76 (60 + 16 gap),
   message 541 at 196 tall, submit 777, panel bottom 865. */
.popup__body {
  /* Body top y83, title y145 — so 62, not 71. */
  padding: 62px 34px 40px;
  background: var(--c-accent);
  border-radius: var(--radius-card);
}

.popup__title {
  max-width: 646px;
  font-size: var(--t-h2);
  line-height: calc(var(--t-h2-lh) / var(--t-h2));
  font-weight: var(--fw-light);
}

/* The artboard sets this caption 56 under the last line of the title and lets
   its box finish on the first field row — 300 against fields at 313, for a
   15px line. The 23px foot it carried pushed every field, the message and the
   send bar 20px down the panel. */
.popup__mandatory {
  margin-block: 56px 4px;
  font-family: var(--font-mono);
  font-size: var(--t-tag);
  color: var(--c-ink);
  text-align: right;
}


/* --- Form ---------------------------------------------------------------- */

/* XD: 362-wide fields in two columns with a 12px gutter; full-width rows are
   735. Field faces are #f0eceb at r10, 60 tall, textarea 196. */

.form__grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  column-gap: 12px;   /* fields at x645 and x1019, each 362 wide */
  row-gap: 16px;      /* rows pitch 76 = 60 field + 16 */
}

.form__field--wide { grid-column: 1 / -1; }

/* Label sits inside the filled input, floating up on focus or when filled. */
.form__field {
  position: relative;
  background: #f0eceb;
  border-radius: var(--radius-card);
}

/* The message field is 183 tall at 375 and 286 at 768, not the 60 of the rest,
   so centring dropped its label halfway down the box — 136 from the top against
   the 21 every other label sits at. Both boards keep it on the same line as the
   others, 36 to the baseline from the field's own top. Set on the field rather
   than the label so the float-up rules below still outrank it on focus. */
.form__field:has(textarea) { --label-top: 21px; --label-shift: 0; }

.form__label {
  position: absolute;
  inset-inline-start: 20px;
  inset-block-start: var(--label-top, 50%);
  translate: 0 var(--label-shift, -50%);
  font-size: var(--t-button);
  font-weight: var(--fw-semibold);
  color: var(--c-panel-deep);
  pointer-events: none;
  transition: all var(--dur-fast) var(--ease-out);
}

.form__input {
  width: 100%;
  min-height: 60px;
  padding: 26px 20px 8px;
  background: none;
  border: 1px solid transparent;
  border-radius: var(--radius-card);
  color: var(--c-panel-deep);
}

textarea.form__input { min-height: 196px; resize: vertical; }

.form__field:focus-within .form__label,
.form__input:not(:placeholder-shown) + .form__label {
  inset-block-start: 10px;
  translate: 0 0;
  font-size: var(--t-credit);
  opacity: 0.7;
}

.form__field:focus-within .form__input { border-color: var(--c-panel); outline: none; }

/* Errors stay inside the field's own face. The message used to be appended
   below the input, inside the same rounded box, so it sat on the bottom edge
   and pushed the row out of line with its neighbour — a form missing two
   fields had two rows of different heights. Pinned to the label's line at the
   right, it costs no height at all, and the ring replaces the outline, which
   drew outside the radius and widened the box by 2px on every side. */
.form__field.has-error {
  box-shadow: inset 0 0 0 2px var(--c-error);
  animation: z1-field-shake 420ms var(--ease-out);
}

.form__field.has-error .form__label { color: var(--c-error); }

.form__error {
  position: absolute;
  inset-block-start: 10px;
  inset-inline-end: 16px;
  max-width: 55%;
  font-family: var(--font-mono);
  font-size: 11px;
  line-height: 1.2;
  text-align: right;
  color: var(--c-error);
  pointer-events: none;
  animation: z1-error-in 260ms var(--ease-out) both;
}

@keyframes z1-field-shake {
  0%, 100% { translate: 0 0; }
  20%      { translate: -6px 0; }
  40%      { translate: 5px 0; }
  60%      { translate: -3px 0; }
  80%      { translate: 2px 0; }
}

@keyframes z1-error-in {
  from { opacity: 0; translate: 0 -4px; }
  to   { opacity: 1; translate: 0 0; }
}

/* Preference toggle — two exclusive options styled as a segmented control.
   Built on radios so it works without JS and is keyboard navigable.

   One row: question at the left, choices at the right, both on the row's
   vertical centre. The height comes from padding, not min-height: the flex
   container is the fieldset's anonymous content box, which does not stretch
   to the fieldset's min-height, so `align-items: center` centred the pair in
   a 24px box at the top of a 60px face and both sat high. `margin: 0` because
   a fieldset carries a 2px inline margin by default, which made this the one
   row narrower than the fields above and below it. */
.form__toggle {
  grid-column: 1 / -1;
  display: flex;
  align-items: center;
  gap: var(--s-3);
  min-width: 0;
  margin: 0;
  padding: 18px 20px;
  border: 0;
  background: #f0eceb;
  border-radius: var(--radius-card);
  color: var(--c-panel-deep);
}

.form__toggle-legend {
  float: left;                /* a floated legend becomes a flex item of the fieldset */
  padding: 0;
  font-size: var(--t-button);
  font-weight: var(--fw-semibold);
  line-height: 1.3;
}

.form__toggle-options { margin-left: auto; display: flex; gap: 4px; flex: 0 0 auto; }

.form__toggle-input { position: absolute; opacity: 0; pointer-events: none; }

/* The two chips hold one line each — 122 and 93 wide in both the 1440 and the
   768 board. Left to shrink, the tablet width broke "PAR TÉLÉPHONE" across two
   lines inside its own 24px chip, which is the one place on the site where a
   label was clipped by its own box. The legend gives way instead. */
.form__toggle-label {
  display: grid;
  place-items: center;
  min-height: 24px;
  padding-inline: 12px;
  white-space: nowrap;
  border: 1px solid var(--c-ink);
  border-radius: 4px;
  font-family: var(--font-mono);
  font-size: var(--t-tag);
  line-height: 1;
  color: var(--c-ink);
  cursor: pointer;
}

.form__toggle-input:checked + .form__toggle-label { background: var(--c-ink); color: #f0eceb; border-color: var(--c-ink); }
.form__toggle-input:focus-visible + .form__toggle-label { outline: 2px solid var(--c-panel); outline-offset: 2px; }

/* Honeypot. Not display:none — some bots skip hidden fields; this is
   off-screen and removed from the tab order and the accessibility tree. */
.form__hp {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

/* Status banner, just above the send bar — where the eye is after pressing it.
   It was a 16px line of text in dark red on the green, easy to miss entirely.
   Now a dark card that slides in, the same ink as the send bar, so it reads as
   part of the control rather than as footnote copy. Empty means not there. */
.form__status {
  display: flex;
  align-items: center;
  gap: 14px;
  margin-top: var(--s-3);
  padding: 14px 18px;
  border-radius: var(--radius-card);
  background: var(--c-panel);
  color: var(--c-off-white);
  font-size: var(--t-body);
  line-height: 1.35;
}

.form__status:empty { display: none; }

.form__status.is-entering { animation: z1-status-in 380ms var(--ease-out) both; }

.form__status-icon {
  display: grid;
  place-items: center;
  flex: 0 0 26px;
  height: 26px;
  border-radius: 50%;
  font-family: var(--font-mono);
  font-size: 14px;
  font-weight: var(--fw-bold);
}

.form__status[data-state='error'] .form__status-icon { background: var(--c-error-bright); color: var(--c-white); }

/* Sending: a ring spinning in the accent green. */
.form__status[data-state='sending'] .form__status-icon {
  border: 2px solid rgb(173 229 66 / 0.25);
  border-top-color: var(--c-accent);
  animation: z1-spin 800ms linear infinite;
}

@keyframes z1-status-in {
  from { opacity: 0; translate: 0 10px; scale: 0.98; }
  to   { opacity: 1; translate: 0 0;    scale: 1; }
}

@keyframes z1-spin { to { rotate: 360deg; } }

/* --- Sent ----------------------------------------------------------------
   Replaces the title and the form once the message has gone, so there is no
   mistaking it: a check that draws itself, a line saying it worked, and a way
   out. popup.js resets it to the form the next time the panel closes. */
.form-success {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--s-3);
  min-height: 420px;
  justify-content: center;
  outline: none;
}

.form-success[hidden] { display: none; }

.form-success__icon {
  width: 72px;
  height: 72px;
  color: var(--c-panel);
}

.form-success__icon circle {
  stroke-dasharray: 202;
  stroke-dashoffset: 202;
}

.form-success__icon path {
  stroke-dasharray: 40;
  stroke-dashoffset: 40;
}

.form-success.is-shown .form-success__icon circle { animation: z1-draw 600ms var(--ease-out) 100ms forwards; }
.form-success.is-shown .form-success__icon path   { animation: z1-draw 360ms var(--ease-out) 560ms forwards; }

.form-success__title {
  font-size: var(--t-h2);
  line-height: calc(var(--t-h2-lh) / var(--t-h2));
  font-weight: var(--fw-light);
  color: var(--c-panel-deep);
}

.form-success__text {
  max-width: 520px;
  font-size: var(--t-lead);
  line-height: 1.3;
  color: var(--c-ink);
}

.form-success__close {
  margin-top: var(--s-2);
  min-height: 48px;
  padding-inline: var(--s-4);
  border-radius: var(--radius-card);
  background: var(--c-ink);
  color: var(--c-accent);
  font-family: var(--font-mono);
  font-size: var(--t-label);
  font-weight: var(--fw-medium);
  text-transform: uppercase;
  transition: background var(--dur-fast) var(--ease-out);
}

.form-success__close:hover,
.form-success__close:focus-visible { background: var(--c-panel); }

/* Children rise in one after another as the view arrives. */
.form-success.is-shown > * { animation: z1-success-in 520ms var(--ease-out) both; }
.form-success.is-shown > :nth-child(2) { animation-delay: 120ms; }
.form-success.is-shown > :nth-child(3) { animation-delay: 200ms; }
.form-success.is-shown > :nth-child(4) { animation-delay: 280ms; }

/* The form and title fade before they are swapped out. */
.popup__body.is-leaving .popup__title,
.popup__body.is-leaving .form {
  opacity: 0;
  translate: 0 -12px;
  transition: opacity 240ms var(--ease-out), translate 240ms var(--ease-out);
}

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

@keyframes z1-success-in {
  from { opacity: 0; translate: 0 16px; }
  to   { opacity: 1; translate: 0 0; }
}

@media (prefers-reduced-motion: reduce) {
  .form__field.has-error,
  .form__error,
  .form__status.is-entering,
  .form-success.is-shown > * { animation: none; }

  .form-success__icon circle,
  .form-success__icon path { stroke-dashoffset: 0; animation: none !important; }

  .form__status[data-state='sending'] .form__status-icon { animation-duration: 2s; }
}


/* --------------------------------------------------------------------------
   Cookie consent banner
   -------------------------------------------------------------------------- */

.consent {
  position: fixed;
  inset-block-end: 0;
  inset-inline: 0;
  z-index: var(--z-consent);
  background: var(--c-panel-deep);
  border-top: 1px solid rgb(241 248 241 / 0.15);
}

.consent[hidden] { display: none; }

.consent__inner {
  display: flex;
  align-items: center;
  gap: var(--s-4);
  max-width: var(--page-max);
  margin-inline: auto;
  padding: var(--s-3) var(--page-margin);
}

.consent__text { font-size: var(--t-body); line-height: 1.5; max-width: 80ch; }
.consent__link { text-decoration: underline; text-underline-offset: 0.2em; }
.consent__actions { margin-left: auto; display: flex; gap: var(--s-2); flex: 0 0 auto; }


/* --------------------------------------------------------------------------
   Footer
   -------------------------------------------------------------------------- */

/* --c-bg rather than --c-panel: the home page leaves it at #212121, every other
   page sets it to #181c18 through body.is-deep, and the footer follows. */
.site-footer {
  background: var(--c-bg);
  color: var(--c-text-display);
}

/* Intro band — heading left, paragraph in the right-hand columns. */
.site-footer__intro {
  display: grid;
  grid-template-columns: repeat(var(--grid-columns), minmax(0, 1fr));
  gap: var(--grid-gutter);
  padding-block: 240px 100px;
}

.site-footer__heading {
  grid-column: 1 / 10;
  max-width: 819px;
  font-size: var(--t-display);
  line-height: calc(var(--t-display-lh) / var(--t-display));
  font-weight: var(--fw-thin);
}

.site-footer__side { grid-column: 10 / -1; }

/* 6px down, so the two blocks start on the same line of ink.

   Their boxes already align — both are grid items on the same row and neither
   carries an offset. What does not align is the type inside them, because the
   space a line box leaves above its capitals is a function of the size: 80/75
   puts the cap 9.4 below the box top (half-leading -13.9, then 0.292em from the
   ascender down to the cap), while 24/23 puts it at 3.1. The 6.3 between them is
   this margin, and it is why the paragraph read as sitting high once the square
   that used to fill this column was taken out. */
.site-footer__paragraph {
  /* 10, not the 6.3 the metrics ask for. Cap tops that measure level do not read
     level when one block is 80px Thin and the other 24px Bold: the heavy small
     type carries more ink at the top of its line and sits visually high against
     a hairline. The extra 4 is that optical correction, not a measurement. */
  margin-block-start: 10px;
  font-size: var(--t-lead);
  line-height: calc(var(--t-lead-lh) / var(--t-lead));
  color: var(--c-off-white);
}

/* Main band. Offsets are measured down from the rule that opens it, which is
   the Footer symbol's own origin:

     brand  61   right-aligned to the content edge, 210px Bulb
     address / menu  66
     contact        209
     legal / credit 368
     closing rule   416

   The wordmark starts level with the address, not above it — giving it a grid
   row of its own pushed all three text rows 180px down the page and broke that
   alignment. It is out of flow instead, so its 189px box cannot set a row
   height. Decorative, and aria-hidden because the header logo already names the
   site. */
.site-footer__main {
  position: relative;
  display: grid;
  grid-template-columns: repeat(var(--grid-columns), minmax(0, 1fr));
  column-gap: var(--grid-gutter);

  /* The design's rows are a 1-line address and a 2-line contact block 143 and
     162 apart; both resolve to about 110px of clear space between blocks, which
     is what survives real copy running longer than the placeholder. */
  row-gap: 110px;

  padding-block: 66px 26px;
  margin-block-start: 239px;   /* rule 6812, marquee ends 6573 */
}

/* The rule runs the full viewport, not the content box the rest of the footer
   is measured in.

   It used to be this element's `border-top`, which is the same thing only while
   the shell is narrower than the page: past --page-max the shell stops at 1440
   and the border stopped with it, leaving the line floating with 240px of panel
   either side of it on a 1920 screen. Broken out the same way the separators
   are — 100vw pulled back by half the difference — so it reaches the edges at
   every width. It overshoots the scrollbar by a few px, as they do; `.site`
   clips it.

   Drawn in from the left with the other rules — see Rule draw below, which is
   also why the line is a gradient rather than a colour. */
.site-footer__main::before {
  content: '';
  position: absolute;
  inset-block-start: 0;
  inset-inline-start: 50%;
  width: 100vw;
  height: 1px;
  translate: -50%;
  background: linear-gradient(rgb(255 255 255 / 0.35) 0 0) no-repeat;
}

.site-footer__brand {
  position: absolute;
  inset-block-start: 61px;

  /* Not `0`: an absolute child resolves against the padding box, so 0 would sit
     the wordmark on the page margin rather than on the content edge the rest of
     the footer lines up with. */
  inset-inline-end: var(--page-margin);

  font-family: var(--font-display);

  /* 210px is the artboard's size, and at 1440 it leaves about 50px between the
     menu's longest item and the wordmark's first dot. That clearance does not
     survive a narrower page: the wordmark is pinned to the right edge at a
     fixed width, so its left edge travels inward three times faster than the
     menu column does, and by 1280 "Études de cas" was sitting on the 1. The
     expression holds the artboard's clearance instead of the artboard's size —
     it resolves to 210 at 1440 and above, where `min` caps it, and scales down
     from there. Below 1024 this is moot: the wordmark takes a grid row of its
     own (see responsive.css) and cannot collide at all. */
  font-size: min(210px, calc(20.47vw - 78px));

  line-height: 0.9;
  font-weight: 200;
  pointer-events: none;

  /* Dimmed so the dots footer-twinkle.js lights stand out against the rest.
     Alpha on the colour, not `opacity`: the canvas is a child of this box and
     opacity would dim the lit dots along with the glyphs. */
  color: rgb(241 248 241 / 0.55);   /* --c-off-white; mist read too grey */
}

/* The glint layer footer-twinkle.js lays over the wordmark. The script sets its
   offset and size from the laid-out glyphs, so it needs the wordmark as its
   containing block — absolute here, relative below 1024 (responsive.css). */
.site-footer__twinkle {
  position: absolute;
  pointer-events: none;

  /* It overhangs the wordmark by the halo margin on purpose. The base reset's
     `max-width: 100%` on canvas would squeeze it back to the wordmark's width
     and every lit dot would land a few px off its glyph dot. */
  max-width: none;
}

.site-footer__address { grid-column: 1 / 5; grid-row: 1; }
.site-footer__nav     { grid-column: 5 / 9; grid-row: 1; }
.site-footer__contact { grid-column: 1 / 5; grid-row: 2; }

.site-footer__address,
.site-footer__contact {
  font-size: var(--t-lead);
  line-height: 27px;
}

.site-footer__menu {
  display: grid;
  gap: 0;
  font-size: var(--t-lead);
  line-height: 32px;
}

/* Uppercased here rather than in the strings. "Politique de confidentialité"
   arrives already capitalised from lang/, "Préférences de témoins" does not —
   the two sat side by side in different cases. Doing it in CSS keeps the row
   consistent whatever a translation or the consent script hands over.
   (The boards set this whole row in sentence case; the site uppercases every
   mono string — tags, DÉTAILS, the marquee — so this follows the site.) */
.site-footer__legal,
.site-footer__credit {
  grid-row: 3;
  align-self: end;
  font-family: var(--font-mono);
  font-size: var(--t-tag);
  line-height: 23px;
  text-transform: uppercase;
}

.site-footer__legal {
  grid-column: 1 / 7;
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-3);
}

/* The artboard starts this line at x982 and runs it to the content edge at
   1414 — 432px for a string that sets at about 430, so columns 9-12 hold it on
   one line at 1440 and nowhere else; by 1280 it had broken onto a second.
   Anchoring it to the right edge and letting it reach back over the two empty
   columns puts its start at 984 at the design width, which is the same x982,
   and keeps it on one line down to the tablet breakpoint. */
.site-footer__credit {
  grid-column: 8 / -1;
  justify-self: end;
  text-align: right;
}

.site-footer__legal a:hover,
.site-footer__credit a:hover,
.site-footer__contact a:hover,
.site-footer__address a:hover,
.site-footer__menu a:hover { color: var(--c-accent); }


/* --------------------------------------------------------------------------
   Preloader
   Removed by preloader.js after a 750ms minimum so it never flashes.
   -------------------------------------------------------------------------- */

/* Reproduced from the original: full-bleed #212121, logo at 200px pulsing
   opacity and scale together over 1.5s, fading out in 0.5s.

   The old stylesheet also declared a .preloader-spinner ring, but the comment
   above it opened with `\*` instead of `/*`, so the CSS parser discarded the
   whole block — the spinner element rendered with no styles and was invisible.
   Not reproducing a rule that never actually applied. */

.preloader {
  position: fixed;
  inset: 0;
  z-index: var(--z-preloader);
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--c-panel);
  transition: opacity 0.5s ease-out, visibility 0s linear 0.5s;
}

.preloader.is-done { opacity: 0; visibility: hidden; pointer-events: none; }

/* Re-shown at the start of a page transition. Without this it would fade back
   in over 0.5s while `visibility` waited out its own 0.5s delay — the overlay
   would still be transparent at the moment the content swaps, which is the one
   moment it exists to cover. preloader.js drops the class a frame later so the
   fade-out is unaffected. */
.preloader.is-instant { transition: none; }

.preloader__logo {
  width: auto;
  max-width: 200px;
  animation: z1-logo-pulse 1.5s ease-in-out infinite;
}

@keyframes z1-logo-pulse {
  0%, 100% { opacity: 0.4; transform: scale(1); }
  50%      { opacity: 1;   transform: scale(1.05); }
}

body.is-locked { overflow: hidden; }


/* --------------------------------------------------------------------------
   Swup page transition

   Carried over from the old stylesheet unchanged. Without it Swup finds nothing
   matching its animationSelector, warns on every navigation ("No CSS animation
   duration defined") and swaps instantly — the container class was on #swup but
   the rules that give it a duration were never ported.

   The two states are Swup 4's, and they were the other way round. Swup 4 holds
   `is-changing` for the whole visit and `is-animating` only for the leave; it
   removes `is-animating` once the new page is in, and the transition back is
   the enter. Written inverted, the leave did nothing and the enter faded the
   *new* page out to 0 and then back to 1 — the incoming text showed, vanished
   for 300ms and returned. The preloader hid that when the fetch beat its 750ms
   minimum; on a slower response it lifts at once and the dip was in full view.
   -------------------------------------------------------------------------- */

.swup-transition-fade { transition: opacity 0.3s ease-out; }

html.is-animating .swup-transition-fade { opacity: 0; }


/* --------------------------------------------------------------------------
   Rule draw

   Every full-bleed hairline on the site draws itself in from the left as it
   reaches the trigger line, and the split-plus that three of them carry lands
   once the line has arrived under it. animations.js adds `.is-drawn` and owns
   nothing else; the timing is all here.

   background-size and not a transform or a clip, because of where the plus
   lives. On the hero and CTA rules it is the element's own ::before and ::after,
   so scaling the element would stretch the mark along with the line. And a clip
   would slice it: `inset(0 0 0 0)` clips to the border box, which for these is
   1px tall, while the plus overhangs it by 11px above and below. Painting less
   of a background touches neither. It costs a repaint of a 1920×1 strip per
   frame, which is nothing.
   -------------------------------------------------------------------------- */

.js .site-footer__main::before,
.js .hero__rule,
.js .cta__rule,
.js .separator {
  background-size: 0 100%;
  transition: background-size 1.1s cubic-bezier(0.7, 0, 0.3, 1);
}

.js .site-footer__main.is-drawn::before,
.js .hero__rule.is-drawn,
.js .cta__rule.is-drawn,
.js .separator.is-drawn { background-size: 100% 100%; }

/* The mark is already pinned by `translate: -50% -50%`, and `scale` is a
   separate property resolved about the element's own centre — so `scale: 0`
   collapses it to exactly the point it is centred on and there is nothing to
   correct for.

   The delay is the line's own duration, which is what makes one class enough:
   the mark waits for the line to reach it rather than needing a second trigger.
   The curve overshoots past 1 and settles back — the same bounce the arrow
   buttons get from their four-step timeline, written as an ease. */
.js .separator__plus,
.js .hero__rule::before,
.js .hero__rule::after,
.js .cta__rule::before,
.js .cta__rule::after {
  scale: 0;
  transition: scale 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) 1.1s;
}

.js .separator.is-drawn .separator__plus,
.js .hero__rule.is-drawn::before,
.js .hero__rule.is-drawn::after,
.js .cta__rule.is-drawn::before,
.js .cta__rule.is-drawn::after { scale: 1; }

/* animations.js returns before it adds the class on this path, so everything
   parked above has to un-park itself or it would simply never appear. */
@media (prefers-reduced-motion: reduce) {
  .js .site-footer__main::before,
  .js .hero__rule,
  .js .cta__rule,
  .js .separator { background-size: 100% 100%; transition: none; }

  .js .separator__plus,
  .js .hero__rule::before,
  .js .hero__rule::after,
  .js .cta__rule::before,
  .js .cta__rule::after { scale: 1; transition: none; }
}


/* --------------------------------------------------------------------------
   Text reveal primitives

   animations.js wraps each word in .word > .word__inner and slides the inner
   span up from 100%. The clipping happens here, not in JS.

   Every rule that hides something before animating it is gated on `.js`, which
   preloader.js sets in <head>. With JS disabled the content renders normally
   rather than staying invisible forever.
   -------------------------------------------------------------------------- */

/* The clip window is what makes the reveal read as a wipe, but it cannot hug
   the line box: the design sets 40px leading on 40px type at mobile and 75 on
   80 at desktop, and at those ratios the ascenders and descenders sit outside
   the line box. Clipped flush, every heading lost the top of its accents and
   the tail of its descenders — most visible on mobile, where "Propulsez votre
   entreprise" rendered as a band through the middle of the letters.

   The padding widens the clip; the matching negative margin keeps the layout
   box exactly where it was, so no measured offset moves. animations.js parks a
   word at 125% rather than 100% so the extra room does not let it peek. */
.word,
.nav-text {
  display: inline-block;
  overflow: hidden;
  vertical-align: top;
  padding-block: 0.16em;
  margin-block: -0.16em;
}

.word__inner,
.nav-text__inner {
  display: inline-block;
  will-change: transform;
}

/* The arrow buttons used to be parked here with a bled `clip-path` polygon and
   wiped open by initButtons() in animations.js. Both halves are gone: the
   buttons have no arrival animation at all now, so anything parking them would
   simply hide them for good. Only the hover travel above is left. */

/* Only above the drawer breakpoint. Below it the nav is not a pill that wipes
   in on load, it is the panel the burger opens, and it parks itself at
   `opacity: 0` in responsive.css until `.is-open`.

   Unscoped, the reduced-motion rule below matched that panel too — and at
   `.js [data-nav-animate]` it outranks the plain `.site-nav` that parks it, so
   anyone browsing with reduced motion on got the whole menu sitting open over
   the top of every page on a phone or a tablet. Specificity, not the JS: the
   script's matching `opacity: 1` was only the half that could be seen in a
   style attribute. */
@media (min-width: 1025px) {
  .js [data-nav-animate] { opacity: 0; }
}

@media (min-width: 1025px) and (prefers-reduced-motion: reduce) {
  .js [data-nav-animate] { opacity: 1; }
}

/* Preloader locks scroll before body exists, so the lock lives on <html> too. */
html.is-preloading { overflow: hidden; }


/* The "[ SCROLL ↴ ]" hint used to wobble on a 1s loop (z1-wobble); removed at
   the client's request. `.scramble` stays on the markup as the hook
   animations.js uses to fade the hint out on scroll-down. */
.scramble { display: inline-block; }


/* The split-plus drawn between each repetition in both marquees — the same
   glyph the small buttons carry, at the same 14px. */
.marquee__sep,
.btn-marquee__sep {
  display: inline-flex;
  align-items: center;
  flex: none;
}

.marquee__sep svg,
.btn-marquee__sep svg { width: 14px; height: 14px; }
