/* ==========================================================================
   site.css — components. Every value comes from tokens.css.
   ========================================================================== */

/* --- buttons and links ---------------------------------------------------- */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--s-2);
  min-height: 44px;
  padding: var(--s-3) var(--s-4);
  border-radius: var(--radius-btn);
  font-size: var(--t-cap);
  font-weight: 600;
  line-height: 1;
  border: var(--rule-w) solid transparent;
  cursor: pointer;
  position: relative;
  overflow: hidden;
  transition: background-color var(--dur-micro) var(--ease-out),
              border-color var(--dur-micro) var(--ease-out),
              color var(--dur-micro) var(--ease-out),
              transform var(--dur-micro) var(--ease-out);
}
.btn > span { position: relative; z-index: 1; }
.btn svg { position: relative; z-index: 1; flex: none; transition: transform var(--dur-micro) var(--ease-out); }
.btn:hover svg { transform: translateX(3px); }
.btn:active { transform: translateY(1px); }

/* A single band of light crosses the filled button on hover. Only the filled
   variants — on a white button there is nothing for white light to do. */
.btn--primary::after,
.btn--onnavy::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(100deg, transparent 32%, var(--sheen) 50%, transparent 68%);
  transform: translateX(-130%);
  transition: transform 640ms var(--ease-out);
}
.btn--primary:hover::after,
.btn--onnavy:hover::after { transform: translateX(130%); }
.btn--primary { background: var(--blue); color: var(--white); }
.btn--primary:hover { background: var(--blue-dark); color: var(--white); }
.btn--ghost { border-color: var(--rule); color: var(--ink); background: var(--white); }
.btn--ghost:hover { border-color: var(--slate); color: var(--ink); }
.btn--light { background: var(--white); color: var(--ink); }
.btn--light:hover { background: var(--tint); color: var(--blue-dark); }
.btn--onnavy { border-color: var(--navy-line); color: var(--on-navy-btn); background: transparent; }
.btn--onnavy:hover { border-color: var(--blue-light); color: var(--white); }

.link-arrow {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  min-height: 44px;
  font-size: var(--t-meta);
  font-weight: 600;
  color: var(--blue-dark);
}
.link-arrow svg { flex: none; transition: transform var(--dur-micro) var(--ease-out); }
.link-arrow:hover { color: var(--blue-dark); }
.link-arrow:hover svg { transform: translateX(3px); }
.link-arrow--onnavy { color: var(--blue-light); }
.link-arrow--onnavy:hover { color: var(--white); }

/* --- event bar ---------------------------------------------------------------
   The whole strip is the link, so the target is the full width and clears the
   44px floor by itself. Navy with the dot grid puts it in the same family as
   the bands and the footer rather than inventing a fifth surface.

   The default focus ring is --blue, which is too close to navy to read on it;
   the accent's light end is used here instead. Contrast: --on-navy-body is
   5.97:1 and --blue-light 7.92:1 on navy, so both clear at these sizes.
   -------------------------------------------------------------------------- */

.eventbar {
  display: block;
  background: var(--navy);
  background-image: var(--dots-dark);
  background-size: var(--dots-size);
  border-bottom: var(--rule-w) solid var(--rule-dark);
  color: var(--on-navy);
}
.eventbar:hover { color: var(--on-navy); }
.eventbar:focus-visible { outline-color: var(--blue-light); outline-offset: -3px; }
.eventbar__inner {
  display: flex;
  align-items: center;
  gap: var(--s-3) var(--s-4);
  min-height: 44px;
  padding-block: var(--s-2);
}
/* Says what the strip is before it says anything else. Solid accent at label
   size: white on --blue is 5.1:1, and .tag--solid already established the pair. */
.eventbar__kind {
  flex: none;
  padding: 3px var(--s-3);
  border-radius: var(--radius-chip);
  background: var(--blue);
  color: var(--white);
  font-size: var(--t-label);
  font-weight: 600;
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
}
.eventbar__when {
  flex: none;
  padding: 3px var(--s-3);
  border: var(--rule-w) solid var(--navy-line);
  border-radius: var(--radius-chip);
  font-size: var(--t-label);
  font-weight: 600;
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  color: var(--blue-light);
  position: relative;
  overflow: hidden;
}
/* A glint, not a glow: the band is clipped to the chip's own rounded box, so
   nothing blooms past the edge — which is the distinction §3 draws for the hero
   mark's sweep. White here is a luminance, not a palette choice, the same
   exception that rule already makes. It crosses in about a second and then
   rests, rather than strobing. */
.eventbar__when::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(100deg,
              transparent 32%, rgba(255, 255, 255, 0.5) 50%, transparent 68%);
  transform: translateX(-130%);
  /* linear, not --ease-out: that curve is cubic-bezier(0.22, 1, 0.36, 1) and
     covers ~85% of its travel in the first third, which turns a sweep into a
     flick. A specular highlight crosses at constant speed. 18% of 5s is a
     ~0.9s pass, then four seconds of rest. */
  animation: eventShine 5s linear 1.2s infinite;
}
@keyframes eventShine {
  0%   { transform: translateX(-130%); }
  18%  { transform: translateX(130%); }
  100% { transform: translateX(130%); }
}
.eventbar__name { font-size: var(--t-meta); font-weight: 600; flex: none; }
/* The qualifier runs as a ticker. Two identical copies translate the width of
   one, so the loop is seamless; the duplicate is aria-hidden, so the line is
   announced once. Same construction as the partner marquee.

   It travels LEFT TO RIGHT as asked — note that is against the reading
   direction, which is why tickers conventionally run the other way. Flip the
   two translate values in @keyframes eventTicker to reverse it. */
.eventbar__ticker { flex: 1 1 auto; min-width: 0; overflow: hidden; }
.eventbar__track {
  display: flex;
  width: max-content;
  animation: eventTicker 26s linear infinite;
}
.eventbar:hover .eventbar__track,
.eventbar:focus-visible .eventbar__track { animation-play-state: paused; }
.eventbar__line {
  font-size: var(--t-meta);
  color: var(--on-navy-body);
  white-space: nowrap;
  padding-right: var(--s-8);
}
@keyframes eventTicker {
  from { transform: translateX(-50%); }
  to   { transform: translateX(0); }
}
.eventbar__go {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  margin-left: auto;
  flex: none;
  font-size: var(--t-meta);
  font-weight: 600;
  color: var(--blue-light);
}
.eventbar__go svg { transition: transform var(--dur-micro) var(--ease-out); }
.eventbar:hover .eventbar__go { color: var(--on-navy); }
.eventbar:hover .eventbar__go svg { transform: translateX(3px); }
.eventbar:hover .eventbar__name { text-decoration: underline; text-underline-offset: 3px; }

@media (prefers-reduced-motion: reduce) {
  .eventbar__track { animation: none; transform: none; }
  .eventbar__line[aria-hidden="true"] { display: none; }
  .eventbar__when::after { animation: none; display: none; }
}

/* The qualifier is the first thing to go: the date, the name and the action are
   what the strip has to carry. */
@media (max-width: 900px) { .eventbar__ticker { display: none; } }
@media (max-width: 520px) {
  .eventbar__when { display: none; }
  .eventbar__kind { display: none; }
  .eventbar__inner { gap: var(--s-3); }
}

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

/* The event bar and the header pin together as one block. The wrapper does the
   sticking; .site-header stays `relative` because .site-header--open .nav is
   absolutely positioned against it and would otherwise escape to the wrapper. */
.topbar { position: sticky; top: 0; z-index: 20; }
.has-eventbar { --sticky-fb: 122px; }
.site-header {
  border-bottom: var(--rule-w) solid var(--rule);
  background: var(--paper);
  position: relative;
}
.site-header__inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-4);
  padding-block: var(--s-3);
  min-height: 68px;
}
.brand { display: flex; align-items: center; }
.brand img { width: 196px; }

.nav { display: flex; align-items: center; gap: var(--s-6); }
.nav__link {
  font-size: var(--t-meta);
  color: var(--slate);
  padding-block: var(--s-2);
  border-bottom: 2px solid transparent;
}
.nav__link:hover { color: var(--ink); }
.nav__link[aria-current="page"] { color: var(--ink); font-weight: 600; border-bottom-color: var(--blue); }

.nav-toggle {
  display: none;
  align-items: center;
  gap: var(--s-2);
  min-height: 44px;
  padding: var(--s-2) var(--s-3);
  background: var(--white);
  border: var(--rule-w) solid var(--rule);
  border-radius: var(--radius-btn);
  font-family: var(--font-ui);
  font-size: var(--t-cap);
  font-weight: 600;
  color: var(--ink);
  cursor: pointer;
}

/* --- page head ------------------------------------------------------------ */

.page-head { padding-block: var(--s-8) var(--s-6); }
.page-head .grid { align-items: end; }
.page-head h1 { margin-top: var(--s-4); }
.breadcrumb { padding-top: var(--s-5); font-size: var(--t-cap); color: var(--slate); }
.breadcrumb a { color: var(--slate); }
.breadcrumb [aria-current] { color: var(--ink); }

/* --- chips ---------------------------------------------------------------- */

.chips { display: flex; flex-wrap: wrap; gap: var(--s-2); }
.chips--scroll { flex-wrap: nowrap; overflow-x: auto; padding-bottom: var(--s-2); scrollbar-width: thin; }

/* The filter and jump rows pin under the header: on both pages the choice they
   offer stays relevant for the whole scroll, so it should stay reachable. The
   bar is full-bleed rather than wrapped so a band scrolling under it cannot
   show past a 1440px content column.

   --sticky-top is written by site.js from the header's measured height, so the
   two never drift apart; the literal is only the pre-script fallback. z-index
   sits under the header's 20 so the header always wins. */
.chipbar {
  position: sticky;
  /* JS measures the real .topbar height into --sticky-top. The fallback is only
     reached with scripts off, and has to know whether the event bar is part of
     that block — otherwise the chips slide underneath it. */
  top: var(--sticky-top, var(--sticky-fb, 76px));
  z-index: 15;
  background: var(--paper);
  padding-block: var(--s-3) var(--s-4);
  /* The rule is present but colourless at rest, so colouring it in when the bar
     pins costs no layout shift and the resting page keeps its open top.
     Written as longhands on purpose: a `border-bottom` shorthand carrying a
     var() becomes a pending-substitution value, and the .is-stuck colour below
     never lands on top of it. */
  border-bottom-width: var(--rule-w);
  border-bottom-style: solid;
  border-bottom-color: transparent;
  transition: border-color var(--dur-enter) var(--ease-out);
}
.chipbar.is-stuck { border-bottom-color: var(--rule); }
@media (prefers-reduced-motion: reduce) {
  .chipbar { transition: none; }
}

.chip {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  padding: var(--s-2) var(--s-4);
  border-radius: var(--radius-chip);
  border: var(--rule-w) solid var(--rule);
  background: var(--white);
  color: var(--body-mute);
  font-size: var(--t-cap);
  white-space: nowrap;
}
.chip:hover { border-color: var(--blue); color: var(--blue-dark); }
.chip--on { background: var(--navy); border-color: var(--navy); color: var(--white); font-weight: 600; }
.chip--on:hover { color: var(--white); border-color: var(--navy); }

.tag {
  display: inline-flex;
  align-items: center;
  padding: 5px var(--s-3);
  border-radius: var(--radius-chip);
  background: var(--tint);
  color: var(--blue-dark);
  font-size: var(--t-label);
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}
.tag--solid { background: var(--blue); color: var(--white); }
.tag--outline { background: var(--white); border: var(--rule-w) solid var(--rule); color: var(--slate); }

/* --- drawn panels (these replace photography) ----------------------------- */

.panel {
  background: var(--tint);
  border: var(--rule-w) solid var(--rule-tint);
  border-radius: var(--radius-panel);
  padding: var(--s-5);
}
.panel__label {
  font-size: var(--t-label);
  font-weight: 600;
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  color: var(--slate-lt);
  margin-bottom: var(--s-4);
}
.panel svg { display: block; width: 100%; height: auto; }

/* Wide diagrams scroll inside their own container rather than shrinking their
   labels below readable size (DESIGN.md §4). */
.figure { margin: 0; }
.figure svg { display: block; width: 100%; height: auto; }
.figure__hint {
  display: none;
  margin: var(--s-2) 0 0;
  font-size: var(--t-label);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--slate-lt);
}

/* --- service rows --------------------------------------------------------- */

.service { padding-bottom: var(--s-8); }
.service .grid { align-items: center; }
.service__num {
  display: flex; align-items: center; gap: var(--s-3);
  font-size: var(--t-cap); font-weight: 600; letter-spacing: 0.14em; color: var(--blue);
  margin-bottom: var(--s-3);
}
.service__num::after { content: ""; height: 1px; width: 40px; background: var(--rule); }
.service h2,
.service h3 { font-size: var(--t-h2s); margin-bottom: var(--s-1); }
.service .standfirst { margin-bottom: var(--s-4); }
.service .lead { margin-bottom: var(--s-5); max-width: none; }

/* odd rows put the diagram on the left — this is what stops the page
   reading as a stack of identical blocks (DESIGN.md §4) */
.service--flip .service__text { order: 2; }
.service--flip .service__diagram { order: 1; }

.checklist { display: flex; flex-direction: column; gap: var(--s-3); margin-bottom: var(--s-5); }
.checklist li { display: flex; gap: var(--s-3); align-items: flex-start; font-size: var(--t-meta); line-height: var(--lh-tight); }
.checklist svg { flex: none; margin-top: 3px; }

/* A second offer inside an existing practice, not a tenth practice: no number
   and no jump chip, an h3 under the section's h2, hung off a left rule so it
   reads as attached to the row above rather than as a band between sections.
   The first pass used a full-bleed top rule and 100px of air, which put it
   closer to the next section than to its own practice — proximity now does the
   grouping: a tight gap above, the section's full padding below. */
/* Takes the same grid column as the text above it, so the right edge lines up
   and the 28px indent is the only offset — the block reads as that column
   continuing. A max-width here instead would leave both edges unaligned, and
   would set the text narrower than everything else once the grid collapses. */
.annex {
  margin-top: var(--s-5);
  border-left: var(--rule-w) solid var(--rule);
  padding-left: var(--s-5);
}
.annex__label { margin-bottom: var(--s-3); }
/* .service .grid centres its columns against the diagram; the annex's inner
   split has no diagram to centre against, so its two columns hang from the top
   and both open on the same 11px label — which is what lines them up.
   Two class names, not one: .service .grid is (0,2,0) and would otherwise win,
   which centred the short column and knocked the two labels 79px out of line. */
.annex .grid { align-items: start; }
/* Four items across the wide column rather than one sparse stack: at ~340px a
   track each they still set on one line, and the block stops being a column of
   text with 400px of nothing beside it. */
.annex__points {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--s-3) var(--s-6);
  margin-bottom: 0;
}
.annex h3 { font-size: var(--t-h5); margin-bottom: var(--s-1); }
/* The marks are what we implement and audit for a client, not what we hold —
   plain tags, never a badge (DESIGN.md §3). */
.annex__marks {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2);
  margin-bottom: var(--s-4);
}

/* --- compact practice list ------------------------------------------------ */

.practices {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--s-6) var(--s-7);
}
.practice {
  display: flex;
  flex-direction: column;
  border-top: var(--rule-w) solid var(--rule);
  padding-top: var(--s-4);
}
/* Without this the link sits directly under its description, so Conference
   Hosting's one-line entry pulled its action 23px above its neighbours'. */
.practice__go { margin-top: auto; padding-top: var(--s-4); }
.practice__num { font-size: var(--t-cap); font-weight: 600; letter-spacing: 0.14em; color: var(--blue); }
.practice h3 { margin: var(--s-2) 0; }
.practice p { font-size: var(--t-meta); line-height: 1.65; color: var(--body-mute); }

/* --- section heads -------------------------------------------------------- */

.section-head {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: var(--s-4);
  margin-bottom: var(--s-6);
}
.section-head__meta { font-size: var(--t-cap); color: var(--slate); white-space: nowrap; }

/* --- bands ---------------------------------------------------------------- */

.band {
  background: var(--navy);
  background-image: var(--dots-dark);
  background-size: var(--dots-size);
  color: var(--on-navy);
  padding-block: var(--s-8);
}
.band h2 { color: var(--on-navy); font-size: var(--t-h4); margin-bottom: var(--s-3); }
.band p { color: var(--on-navy-mute); max-width: 520px; }
.band .grid { align-items: center; }
.band__actions { display: flex; gap: var(--s-3); justify-content: flex-end; flex-wrap: wrap; }

/* --- work cards ----------------------------------------------------------- */

.cards {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--s-5);
}

/* --- portfolio cards ---------------------------------------------------------
   Frameless (2026-09-04, replacing the navy frame): one hairline holds the
   card, the media runs edge to edge to the card's own radius, and the accent
   arrives as a rule drawn across the top on hover rather than as a colour flip
   of the whole tile. Depth is still the tint and the hairline — §3's no-shadow
   rule is what rules out the usual "modern card" answer.

   The media is aspect-ratio 16/9 on the CONTAINER, not on the image. That is
   the fix for a real bug: screenshots carried the ratio themselves while the
   drawn-diagram cards sized to the SVG's natural 3:1, so a row mixing the two
   had plates ~50px apart and every tag, title and foot rule below them sat at
   a different height.
   -------------------------------------------------------------------------- */

.card {
  position: relative;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  background: var(--white);
  border: var(--rule-w) solid var(--rule);
  border-radius: var(--radius-panel);
  /* `translate`, not `transform`: the arrival animation below owns transform,
     and an animation beats a transition on the same property — the hover lift
     would have stopped working the moment a card finished arriving. The two
     properties compose. */
  transition: border-color var(--dur-enter) var(--ease-out),
              translate var(--dur-enter) var(--ease-out);
}
/* the accent rule, drawn left to right across the top edge */
.card::after {
  content: "";
  position: absolute;
  inset: 0 0 auto 0;
  height: 2px;
  background: var(--blue);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform var(--dur-enter) var(--ease-out);
  z-index: 2;
}
.card:hover,
.card:focus-visible { border-color: var(--blue); translate: 0 -4px; }
.card:hover::after,
.card:focus-visible::after { transform: scaleX(1); }

.card__media {
  aspect-ratio: 16 / 9;
  display: grid;
  place-items: center;
  padding: var(--s-4);
  background: var(--tint);
  border-bottom: var(--rule-w) solid var(--rule-tint);
  overflow: hidden;
  transition: border-color var(--dur-enter) var(--ease-out);
}
.card__media svg {
  display: block;
  width: 100%;
  max-height: 100%;
  height: auto;
  transition: transform 520ms var(--ease-out);
}
.card__media--shot { padding: 0; }
.card__media--shot img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: top left;
  transition: transform 700ms var(--ease-out);
}
.card:hover .card__media--shot img { transform: scale(1.05); }
.card:hover .card__media,
.card:focus-visible .card__media { border-bottom-color: var(--blue); }
.card:hover .card__media svg,
.card:focus-visible .card__media svg { transform: scale(1.045); }

.card__body {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  flex-grow: 1;
  padding: var(--s-4) var(--s-5) var(--s-5);
}
/* A card can carry more than one category, so the tags run as a wrapping row
   inside the column-flow body rather than stacking. */
.card__tags {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2);
}
.card__body .tag {
  align-self: flex-start;
  transition: background-color var(--dur-enter) var(--ease-out),
              color var(--dur-enter) var(--ease-out);
}
/* the chip fills on hover — white on --blue is 5.00:1 */
.card:hover .tag { background: var(--blue); color: var(--white); }

/* Engagements with no case study behind them are <article>, not <a>. What they
   must not carry is the promise of a page: no go-arrow, no pointer cursor, and
   no travel. They keep the accent hairline so they still answer the pointer. */
.card--static { cursor: default; }
.card--static:hover,
.card--static:focus-visible { border-color: var(--blue); translate: none; }
.card--static:hover::after,
.card--static:focus-visible::after { transform: scaleX(0); }
.card h3 { margin-top: var(--s-1); }
.card h3 .to-supply { color: var(--slate-lt); }

.card p {
  font-size: var(--t-meta);
  line-height: 1.6;
  color: var(--body-mute);
  flex-grow: 1;
  text-wrap: pretty;
}
.card__foot {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: var(--s-3);
  margin-top: var(--s-4);
  padding-top: var(--s-3);
  border-top: var(--rule-w) solid var(--rule);
  font-size: var(--t-label);
  font-weight: 600;
  /* tighter than --ls-label: this slot carries the longest metadata on the
     site ("COMMUNITY INTERVENTION · [YEAR]"), which wrapped at 0.16em */
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--slate);
}

/* The grid arrives once when its section scrolls in, card by card. Same
   contract as the diagrams: gated on .js so the resting state without scripts
   is the finished one, and off entirely under reduced motion. */
.js main > section .card { opacity: 0; }
.js main > section.is-in .card {
  animation: cardIn 460ms var(--ease-out) both;
  animation-delay: var(--d, 0ms);
}
@keyframes cardIn {
  from { opacity: 0; transform: translateY(12px); }
  to   { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
  .js main > section .card { opacity: 1; }
  .js main > section.is-in .card { animation: none; }
  .card__media--shot img, .card__media svg { transition: none; }
}

/* the empty slot has no inner block, so it stays a light dashed panel */
.card--invite {
  align-items: center;
  justify-content: center;
  text-align: center;
  gap: var(--s-2);
  padding: var(--s-6) var(--s-5);
  background: var(--tint);
  border-style: dashed;
  border-color: var(--rule-tint);
}
.card--invite:hover { translate: none; background: var(--tint); border-color: var(--blue); }
.card--invite::after { content: none; }
.card--invite h3 { font-size: var(--t-h5); color: var(--blue-dark); }
.card--invite:hover h3 { color: var(--blue-dark); }
.card--invite p { flex-grow: 0; color: var(--body); }

/* --- featured case -------------------------------------------------------- */

.featured {
  border: var(--rule-w) solid var(--rule);
  border-radius: var(--radius-panel);
  background: var(--white);
  overflow: hidden;
}
.featured__grid { display: grid; grid-template-columns: var(--grid); }
.featured__text { grid-column: span 5; padding: var(--s-6); display: flex; flex-direction: column; justify-content: center; }
.featured__text h2,
.featured__text h3 { font-size: var(--t-h2); margin-bottom: var(--s-3); }
.featured__text .lead + .link-arrow { margin-top: var(--s-5); }

.featured__media {
  grid-column: span 7;
  background: var(--tint);
  border-left: var(--rule-w) solid var(--rule-tint);
  padding: var(--s-6);
  /* right-hand corners, matched to the card's inner radius (see .card__media) */
  border-radius: 0 calc(var(--radius-panel) - var(--rule-w))
                 calc(var(--radius-panel) - var(--rule-w)) 0;
}
.featured__media svg { display: block; width: 100%; height: auto; }

/* The tap/scan figure is drawn at 460 wide and capped here rather than filling
   its panel: at full featured-media width the card read as a poster. The cap is
   the smallest width that still lands its 11px uppercase labels above the §4
   floor (460 viewBox / 540 render = 1.17, so 11px draws at 12.9px). */
.nfc-figure { max-width: 540px; margin-inline: auto; }
/* The case-page panel is roughly twice the width of the featured plate, so the
   featured cap alone left it looking under-filled. */
.panel .nfc-figure { max-width: 680px; }

/* One-time reveal, gated on the section like the about diagram: the two routes
   draw outward from the card, tap first. The order is the content — the card is
   the thing that exists, the routes are what it does — so this is choreography,
   not decoration. Resting state is the finished state, so no JS means no loss. */
.nfc-path { stroke-dasharray: 5 5; }
.js main > section .nfc-path,
.js main > div .nfc-path { stroke-dashoffset: 0; }
.js main > section.is-in .nfc-path,
.js main > div.is-in .nfc-path {
  animation: nfcDraw 620ms var(--ease-out) both;
  stroke-dasharray: 150;
}
.js main > section.is-in .nfc-path--tap,
.js main > div.is-in .nfc-path--tap { animation-delay: 240ms; }
.js main > section.is-in .nfc-path--scan,
.js main > div.is-in .nfc-path--scan { animation-delay: 420ms; }
.js main > section.is-in .nfc-out,
.js main > div.is-in .nfc-out { animation: nfcArrive 420ms var(--ease-out) both; }
.js main > section.is-in .nfc-out--tap,
.js main > div.is-in .nfc-out--tap { animation-delay: 560ms; }
.js main > section.is-in .nfc-out--scan,
.js main > div.is-in .nfc-out--scan { animation-delay: 740ms; }
@keyframes nfcDraw {
  from { stroke-dashoffset: 150; opacity: 0; }
  to   { stroke-dashoffset: 0; opacity: 1; }
}
@keyframes nfcArrive {
  from { opacity: 0; transform: translateX(-8px); }
  to   { opacity: 1; transform: none; }
}
/* the printed NFC glyph pulses once as the tap route lands, then rests */
.js main > section.is-in .nfc-wave,
.js main > div.is-in .nfc-wave { animation: nfcPulse 900ms var(--ease-out) 240ms 2; }
@keyframes nfcPulse {
  0%, 100% { opacity: 1; }
  45%      { opacity: 0.35; }
}
@media (prefers-reduced-motion: reduce) {
  .js main > section.is-in .nfc-path, .js main > div.is-in .nfc-path,
  .js main > section.is-in .nfc-out, .js main > div.is-in .nfc-out,
  .js main > section.is-in .nfc-wave, .js main > div.is-in .nfc-wave {
    animation: none;
    stroke-dasharray: 5 5;
    opacity: 1;
    transform: none;
  }
}

/* --- service diagram motion --------------------------------------------------
   Added 2026-09-04 at the client's direction. DESIGN.md §3 allows a section to
   reveal once and bans per-element reveals; these are per-element inside one
   figure, which is the amendment. The test each one has to pass is §4's: it
   shows the mechanism. The winners land on the scored field, the pipeline is
   walked stage by stage, the rings wrap outward from the data. Nothing here
   fades in merely because it exists.

   Same contract as .nfc-path above: gated on .js so the resting state without
   scripts is the finished state, gated on .is-in so it plays once when the
   section arrives, and every delay is carried on --d rather than in a pile of
   nth-child rules. Two continuous animations only — the feedback loop and the
   access ring — because a loop that loops and a ring that scans are the two
   mechanisms on this page that genuinely do not stop.
   -------------------------------------------------------------------------- */

/* Hidden only where JS will bring it back; no .js class means no hiding. */
.js main > section .dia-pop, .js main > div .dia-pop,
.js main > section .dia-step, .js main > div .dia-step,
.js main > section .dia-ring, .js main > div .dia-ring { opacity: 0; }

.js main > section.is-in .dia-pop,
.js main > div.is-in .dia-pop {
  animation: diaPop 460ms var(--ease-out) both;
  animation-delay: var(--d, 0ms);
  transform-box: fill-box;
  transform-origin: center;
}
.js main > section.is-in .dia-step,
.js main > div.is-in .dia-step {
  animation: diaStep 380ms var(--ease-out) both;
  animation-delay: var(--d, 0ms);
}
.js main > section.is-in .dia-ring,
.js main > div.is-in .dia-ring {
  animation: diaRing 520ms var(--ease-out) both;
  animation-delay: var(--d, 0ms);
  transform-box: fill-box;
  transform-origin: center;
}

@keyframes diaPop {
  from { opacity: 0; transform: scale(0.94); }
  to   { opacity: 1; transform: none; }
}
@keyframes diaStep {
  from { opacity: 0; transform: translateX(-6px); }
  to   { opacity: 1; transform: none; }
}
/* Lands on the ring's own resting opacity, held in --o, not on 1. */
@keyframes diaRing {
  from { opacity: 0; transform: scale(0.82); }
  to   { opacity: var(--o, 1); transform: none; }
}

/* The two that do not stop. Slow enough to read as drift, not as a spinner. */
.js main > section.is-in .dia-loop,
.js main > div.is-in .dia-loop {
  animation: diaMarch 1.6s linear infinite;
}
@keyframes diaMarch { to { stroke-dashoffset: -18; } }

.js main > section.is-in .dia-scan,
.js main > div.is-in .dia-scan {
  animation: diaScan 26s linear infinite;
  transform-box: fill-box;
  transform-origin: center;
}
@keyframes diaScan { to { transform: rotate(360deg); } }

@media (prefers-reduced-motion: reduce) {
  .js main > section .dia-pop, .js main > div .dia-pop,
  .js main > section .dia-step, .js main > div .dia-step { opacity: 1; }
  .js main > section .dia-ring, .js main > div .dia-ring { opacity: var(--o, 1); }
  .js main > section.is-in .dia-pop, .js main > div.is-in .dia-pop,
  .js main > section.is-in .dia-step, .js main > div.is-in .dia-step,
  .js main > section.is-in .dia-ring, .js main > div.is-in .dia-ring,
  .js main > section.is-in .dia-loop, .js main > div.is-in .dia-loop,
  .js main > section.is-in .dia-scan, .js main > div.is-in .dia-scan { animation: none; }
}

/* --- key/value rows ------------------------------------------------------- */

.kv { display: flex; flex-direction: column; margin-bottom: var(--s-5); }
.kv__row {
  display: flex; justify-content: space-between; gap: var(--s-4);
  padding-block: var(--s-3);
  border-top: var(--rule-w) solid var(--rule);
  font-size: var(--t-meta);
}
.kv__row:last-child { border-bottom: var(--rule-w) solid var(--rule); }
.kv__row dt { color: var(--slate); margin: 0; }
.kv__row dd { margin: 0; font-weight: 500; text-align: right; }
/* When the left column is the name being scanned for — an integration partner,
   a standard — it takes the weight and the description gives it back. */
.kv--marks dt { color: var(--ink); font-weight: 600; }
.kv--marks dd { font-weight: 400; color: var(--body-mute); }
.to-supply { color: var(--slate-lt); }

/* --- case study ----------------------------------------------------------- */

.meta-bar {
  border-top: var(--rule-w) solid var(--rule);
  border-bottom: var(--rule-w) solid var(--rule);
  padding-block: var(--s-5);
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: var(--s-5);
}
.meta-cell dt {
  font-size: var(--t-label); font-weight: 600; letter-spacing: var(--ls-label);
  text-transform: uppercase; color: var(--slate-lt); margin-bottom: var(--s-1);
}
.meta-cell dd { margin: 0; font-size: var(--t-small); font-weight: 500; }

.case-body { display: grid; grid-template-columns: var(--grid); gap: var(--s-8); align-items: start; }
.case-body__main { grid-column: span 7; }
.case-body__aside { grid-column: span 5; position: sticky; top: 100px; }
.case-body h2 { font-size: var(--t-h4); margin-bottom: var(--s-3); }
.case-body section { margin-bottom: var(--s-6); }

.pullquote {
  border-left: 3px solid var(--blue);
  padding: var(--s-1) 0 var(--s-1) var(--s-4);
  font-family: var(--font-display);
  font-size: var(--t-lead);
  font-style: italic;
  line-height: var(--lh-tight);
  color: var(--ink);
  margin: 0;
}

.aside-card {
  border: var(--rule-w) solid var(--rule);
  border-radius: var(--radius-panel);
  background: var(--white);
  padding: var(--s-5);
  margin-bottom: var(--s-5);
}
.aside-card--tint { background: var(--tint); border-color: var(--rule-tint); }
.aside-card h3 { font-size: var(--t-h5); margin-bottom: var(--s-2); }
.aside-card p { font-size: var(--t-meta); line-height: 1.65; color: var(--body); margin-bottom: var(--s-4); }
.aside-card .kv { margin-bottom: 0; }
.aside-card h2 { font-size: var(--t-h5); margin-bottom: var(--s-2); }
.panel__label--spaced { margin-top: var(--s-5); }
.aside-contact,
.aside-links {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  font-size: var(--t-meta);
  font-style: normal;
  margin: 0;
}
.aside-contact a { color: var(--blue-dark); font-weight: 600; }
.aside-contact span:last-child { color: var(--body-mute); }

/* Case studies and product pages get the display size §2 reserves for them. */
.page-head--case h1 { font-size: var(--t-hero); }

.next-case { display: flex; justify-content: space-between; align-items: center; gap: var(--s-4); flex-wrap: wrap; }
.next-case__title { font-family: var(--font-display); font-size: var(--t-h3); color: var(--on-navy); letter-spacing: var(--ls-display); }

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

/* The one gradient on the site, now carrying the whole hero band. */
.hero {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  background: var(--grad-brand);
  color: var(--on-navy);
  min-height: var(--hero-min);
  display: flex;
  align-items: center;
  padding-block: var(--s-9);
}
.hero > .wrap { width: 100%; }
.hero__bg { position: absolute; inset: 0; z-index: -1; pointer-events: none; }
/* The moving layer. It only ever darkens or holds, never brightens past
   --blue-dark, so the hero's contrast floor survives every frame. */
.hero::after {
  content: "";
  position: absolute;
  inset: -25%;
  z-index: -2;
  background: var(--grad-hero-drift);
  opacity: 0.6;
  pointer-events: none;
  animation: heroDrift 24s ease-in-out infinite alternate;
}
/* White, not --blue-light: with the gradient in motion, placement is no longer
   a guarantee, and blue-light only reaches 2.56:1 on the bright stop. */
.eyebrow--hero { color: var(--on-navy); }
.hero__bg svg { display: block; width: 100%; height: 100%; }
.hero .grid { align-items: start; }
.hero .eyebrow { color: var(--blue-light); }
.hero h1 { color: var(--on-navy); margin-top: var(--s-4); margin-bottom: var(--s-5); }
.hero__title { animation: heroTitle var(--dur-hero) var(--ease-out) 120ms both; }
.hero__actions { display: flex; gap: var(--s-3); flex-wrap: wrap; margin-top: var(--s-5); }
/* White, not the muted tone: it clears 5:1 even over the gradient's blue end. */
.hero__text .lead { max-width: var(--measure-long); margin-bottom: var(--s-5); color: var(--on-navy); }

/* --- three of the nine ------------------------------------------------------
   Not icon cards (DESIGN.md §3): the numeral is the graphic, and depth comes
   from the tint-to-white fill shift and the hairline, never a shadow.
   -------------------------------------------------------------------------- */

/* Two featured practices across, the third taking the full row beneath them. */
/* --- the shared card action --------------------------------------------------
   Used by .card. Always a span: the card itself is the anchor, so a
   nested button would be both invalid and a second tab stop.
   -------------------------------------------------------------------------- */

.card-go {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: none;
  width: 44px;
  height: 44px;
  /* transparent at rest: a white chip on the tint plate read as six identical
     buttons competing with the cards they sit on. The hairline still defines
     the target, and the fill is saved for hover. */
  background: transparent;
  border: var(--rule-w) solid var(--rule);
  border-radius: var(--radius-btn);
  color: var(--blue-dark);
  transition: background-color var(--dur-enter) var(--ease-out),
              border-color var(--dur-enter) var(--ease-out),
              color var(--dur-enter) var(--ease-out);
}
.card-go svg { transition: transform var(--dur-micro) var(--ease-out); }
.card:hover .card-go,  .card:focus-visible .card-go {
  background: var(--blue);
  border-color: var(--blue);
  color: var(--white);
}
.card:hover .card-go svg,  .card:focus-visible .card-go svg { transform: translateX(3px); }

/* Values sit inside the about column, not across the page. */
.about-values {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--s-4);
  margin-top: var(--s-6);
}
/* Vision and mission are statements of intent, not values, so they run two-up
   above the set and carry the extra width that gives them. Same card, wider
   measure — the three values then read as one group beneath. */
.vm-cards {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--s-4);
  margin-top: var(--s-6);
}
/* Two cards across a full-width section would otherwise set these sentences on
   one ~84-character line; cap the measure so they wrap like the values do. */
.vm-cards .value p { max-width: 52ch; }
/* The label that opens the values group: a real <h3> styled down to an eyebrow,
   so it introduces the set without competing with the section h2. Because it is
   a heading, the display serif arrives from the base rule — override it back. */
.values-head { font-family: var(--font-ui); margin: var(--s-6) 0 0; }
.values-head + .about-values { margin-top: var(--s-3); }
/* On the about page the block opens its own section, so the section head
   already supplies the space the card grid would otherwise add. */
.section-head + .vm-cards { margin-top: 0; }
.value {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  overflow: hidden;
  padding: var(--s-4);
  background: var(--white);
  border: var(--rule-w) solid var(--rule);
  border-radius: var(--radius-panel);
  transition: border-color var(--dur-enter) var(--ease-out),
              transform var(--dur-enter) var(--ease-out);
}
/* the accent arrives on hover, so three cards at rest stay quiet */
.value::before {
  content: "";
  position: absolute;
  inset: 0 0 auto 0;
  height: 2px;
  background: var(--blue);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform var(--dur-enter) var(--ease-out);
}
.value:hover { border-color: var(--blue); transform: translateY(-3px); }
.value:hover::before { transform: scaleX(1); }
.value__icon {
  display: flex;
  color: var(--slate);
  margin-bottom: var(--s-1);
  transition: color var(--dur-enter) var(--ease-out),
              transform var(--dur-enter) var(--ease-out);
}
.value:hover .value__icon { color: var(--blue); transform: scale(1.1); }
.value h3,
.value h4 {
  font-size: var(--t-h5);
  line-height: var(--lh-head);
  letter-spacing: var(--ls-display);
  color: var(--ink);
  margin: 0;
}
.value p {
  margin: 0;
  font-size: var(--t-meta);
  line-height: 1.65;
  color: var(--body-mute);
  text-wrap: pretty;
}

/* --- values on navy ----------------------------------------------------------
   The about page runs its vision, mission and values as a full-bleed band, so
   the same .value card is restated in reverse tones rather than duplicated.

   The cards are filled with the band's own navy rather than left transparent:
   §4 puts the dot grid on navy bands but never behind body copy, and a filled
   card blocks the dots inside itself while staying the same colour as the band.
   What separates card from band is the hairline, which is how depth is built
   everywhere else on this site.
   -------------------------------------------------------------------------- */
.band--values { padding-block: var(--s-9); }
.js .band--values .value { opacity: 0; }
.js .band--values.is-in .value {
  animation: diaPop 480ms var(--ease-out) both;
  animation-delay: var(--d, 0ms);
}
@media (prefers-reduced-motion: reduce) {
  .js .band--values .value { opacity: 1; }
  .js .band--values.is-in .value { animation: none; }
}
.band--values .section-head__meta { color: var(--on-navy-lbl); }
.band--values .values-head { color: var(--on-navy-lbl); }
.band--values .value {
  background: var(--navy);
  border-color: var(--rule-dark);
}
.band--values .value:hover { border-color: var(--blue-light); }
.band--values .value::before { background: var(--blue-light); }
.band--values .value__icon { color: var(--on-navy-lbl); }
.band--values .value:hover .value__icon { color: var(--blue-light); }
.band--values .value h3,
.band--values .value h4 { color: var(--on-navy); }
/* --on-navy-body clears 5.97:1 on navy; --on-navy-mute does not at this size.
   max-width is reset because .band p caps every paragraph in a band at 520px,
   which is meant for the CTA sentence, not for a card. */
.band--values .value p { color: var(--on-navy-body); max-width: none; }
.band--values .vm-cards .value p { max-width: 52ch; }

/* The about panel runs dark, so the diagram is drawn in reverse tones. */
.panel--dark { background: var(--navy); border-color: var(--rule-dark); }
/* In the About aside it stacks above the CTA card, so it needs that card's gap. */
.case-body__aside .panel--dark { margin-bottom: var(--s-5); }
/* Once the aside drops under the prose it spans the full wrap, and a 400px
   figure marooned in the middle of it reads as lost. Let it take more of the
   panel — the wider it draws, the further the 11px labels clear §4's floor. */
@media (max-width: 1080px) {
  .case-body__aside .about-figure { max-width: 520px; }
}
.panel--dark .panel__label { color: var(--on-navy-lbl); }
.about-figure { max-width: 400px; margin-inline: auto; }

/* --- compliance --------------------------------------------------------------
   Dashed until real: a certification claim that cannot be verified damages the
   exact argument this section exists to make (DESIGN.md §3, no fabricated proof).
   -------------------------------------------------------------------------- */

.cert-note { max-width: var(--measure-long); margin-bottom: var(--s-6); }

/* --- compliance scroller ----------------------------------------------------
   Four cards in view; the rest scroll. --per is the only place the visible
   count lives, so the breakpoints below are the whole responsive story and no
   rule ever hardcodes how many entries exist. */

.certs__nav { display: flex; align-items: center; gap: var(--s-4); }
.certs__count { font-size: var(--t-cap); color: var(--slate); white-space: nowrap; }

.certs { position: relative; }

.certs__track {
  --per: 4;
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: calc((100% - (var(--per) - 1) * var(--s-4)) / var(--per));
  gap: var(--s-4);
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  scrollbar-width: none;             /* the rail below is the scrollbar */
  padding: 4px 4px var(--s-2);       /* room for the hover lift and focus ring */
  margin: -4px -4px 0;
}
.certs__track::-webkit-scrollbar { display: none; }

/* Cue three of three: the right edge fades into the page, so a card is visibly
   continuing rather than ending. Driven by classes from site.js, and both
   edges resolve to --paper because that is what the section sits on. */
.certs::before, .certs::after {
  content: "";
  position: absolute;
  inset-block: 0;
  width: 68px;
  pointer-events: none;
  opacity: 0;
  transition: opacity var(--dur-enter) var(--ease-out);
}
.certs::before { left: 0;  background: linear-gradient(to right, var(--paper), transparent); }
.certs::after  { right: 0; background: linear-gradient(to left,  var(--paper), transparent); }
.certs.can-prev::before, .certs.can-next::after { opacity: 1; }

/* Cue two: how much of the set is on screen, and where you are in it. */
.certs__rail {
  position: relative;
  height: 3px;
  margin-top: var(--s-4);
  background: var(--rule);
  border-radius: var(--radius-chip);
  overflow: hidden;
}
.certs__bar {
  position: absolute;
  inset-block: 0;
  left: 0;
  width: 100%;
  background: var(--navy);
  border-radius: var(--radius-chip);
  transition: left var(--dur-micro) linear;
}
/* Nothing to scroll — four or fewer entries — so the controls remove
   themselves. The nav sits in .section-head, a PREVIOUS sibling of .certs, and
   CSS cannot select backwards, so site.js marks it directly. */
.certs.is-static .certs__rail { visibility: hidden; }
.certs__nav.is-static { display: none; }

.cert {
  position: relative;
  display: flex;
  flex-direction: column;
  scroll-snap-align: start;
  padding: var(--s-5) var(--s-4);
  background: var(--white);
  border: var(--rule-w) solid var(--rule);
  border-radius: var(--radius-panel);
  overflow: hidden;
  transition: transform var(--dur-enter) var(--ease-out),
              border-color var(--dur-enter) var(--ease-out);
}
/* Engine-turned corner. The pattern a certificate actually carries, at an
   opacity where it reads as paper rather than as ornament. */
.cert::after {
  content: "";
  position: absolute;
  top: 0;
  right: 0;
  width: 108px;
  height: 108px;
  background: var(--guilloche);
  opacity: 0.05;
  z-index: 0;            /* ::after paints after the children — keep it under */
  pointer-events: none;
  transition: opacity var(--dur-enter) var(--ease-out);
}
/* A navy cap edge that wipes across on hover, left to right */
.cert::before {
  content: "";
  position: absolute;
  inset-inline: 0;
  top: 0;
  height: 2px;
  background: linear-gradient(to right, var(--navy), var(--blue));
  transform: scaleX(0);
  transform-origin: left;
  transition: transform var(--dur-enter) var(--ease-out);
}
.cert:hover { transform: translateY(-4px); border-color: var(--rule-tint); }
.cert:hover::before { transform: scaleX(1); }
.cert:hover::after  { opacity: 0.14; }

/* dashed only while we do not know which standard or report is held */
.cert--pending { background: var(--tint); border-style: dashed; border-color: var(--rule-tint); }
.cert--pending::after { opacity: 0.04; }

.cert__kind {
  position: relative;
  z-index: 1;
  align-self: flex-start;
  margin: 0 0 var(--s-4);
  padding: 5px var(--s-2);
  background: var(--tint);
  border-radius: var(--radius-chip);
  font-size: var(--t-label);
  font-weight: 600;
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  color: var(--navy);
}
.cert--pending .cert__kind { background: var(--white); color: var(--slate); }

/* The acronym is the mark, so it takes the UI face with letterspacing rather
   than the display serif — a serif on a four-letter acronym reads as a title,
   not a seal. Marks are kept to one line (see CERTS in build.py), which is what
   lets every body start on the same baseline without reserving a second line. */
.cert__mark {
  position: relative;
  z-index: 1;
  margin: 0 0 var(--s-3);
  font-family: var(--font-ui);
  font-size: var(--t-h4);
  font-weight: 600;
  line-height: 1.05;
  letter-spacing: 0.03em;
  color: var(--ink);
  text-wrap: balance;
}
.cert--pending .cert__mark { color: var(--slate); }

.cert__body {
  position: relative;
  z-index: 1;
  margin: 0;
  font-size: var(--t-meta);
  line-height: 1.6;
  color: var(--body-mute);
  text-wrap: pretty;
}

@media (max-width: 1080px) { .certs__track { --per: 3; } }
@media (max-width: 820px)  { .certs__track { --per: 2; } }
@media (max-width: 560px)  { .certs__track { --per: 1; } }

/* --- trusted clients --------------------------------------------------------
   A marquee, paused on hover and on focus-within so a keyboard user can read
   it. Two identical sets translate -50%, which is exactly one set width, so
   the loop is seamless. Under reduced motion it stops being a slider and
   becomes a wrapped row, with the duplicate set hidden.
   Bays stay dashed placeholders: §3 forbids a fake logo wall, and permission
   to publish each logo is still outstanding (§5).
   -------------------------------------------------------------------------- */

.logos { overflow: hidden; }
.logos__track {
  display: flex;
  width: max-content;
  animation: marquee 36s linear infinite;
}
.logos:hover .logos__track,
.logos:focus-within .logos__track { animation-play-state: paused; }
.logos__set {
  display: flex;
  align-items: center;
  gap: var(--s-5);
  padding-right: var(--s-5);
}
.logos__bay {
  display: flex;
  align-items: center;
  justify-content: center;
  flex: none;
  width: 156px;
  height: 62px;
  padding: var(--s-2);
  background: var(--tint);
  border: var(--rule-w) dashed var(--rule-tint);
  border-radius: var(--radius-btn);
  font-size: var(--t-label);
  letter-spacing: 0.12em;
  color: var(--body-mute);
  text-align: center;
}
/* A filled bay is no longer a placeholder, so it loses the dash that says so. */
.logos__bay--filled { border-style: solid; border-color: var(--rule); background: var(--white); }
.logos__bay img { width: auto; height: auto; max-width: 84%; max-height: 72%; }

@keyframes marquee {
  from { transform: translateX(0); }
  to   { transform: translateX(-50%); }
}
@media (prefers-reduced-motion: reduce) {
  .logos__track {
    animation: none;
    width: 100%;
    flex-wrap: wrap;
    justify-content: center;
  }
  .logos__set { flex-wrap: wrap; justify-content: center; padding-right: 0; }
  .logos__set[aria-hidden="true"] { display: none; }
}

/* --- hero motif --------------------------------------------------------------
   The one gradient on the site, and it is built from two existing tokens.
   DESIGN.md §3 still bans purple/pink/teal.
   -------------------------------------------------------------------------- */

/* The chevron draws, the triangle lands, the tick finishes it — then the mark
   keeps moving. Path lengths are measured from the real mark geometry.

   Resting opacities are declared here, not left to the SVG presentation
   attributes, so hover has a defined value to transition from. The attributes
   stay in the markup as the no-CSS fallback. */
.mark-a {
  stroke-opacity: 0.22;
  stroke-dasharray: 210;
  stroke-dashoffset: 210;
  animation: draw 1000ms var(--ease-out) 260ms forwards;
  transition: stroke-opacity var(--dur-enter) var(--ease-out);
}
.mark-tick {
  stroke-opacity: 0.55;
  stroke-dasharray: 14;
  stroke-dashoffset: 14;
  animation: draw 280ms var(--ease-out) 1260ms forwards;
  transition: stroke-opacity var(--dur-enter) var(--ease-out);
}
.mark-tri {
  opacity: 0;
  fill-opacity: 0.5;
  stroke-opacity: 0.5;
  transform-box: fill-box;
  transform-origin: center;
  animation: markPop 460ms var(--ease-out) 1060ms forwards;
  transition: fill-opacity var(--dur-enter) var(--ease-out),
              stroke-opacity var(--dur-enter) var(--ease-out);
}

/* Two drift periods, not one. The chevron and the apex separate and rejoin as
   they move, which is what reads as depth — a single shared drift just floats
   the whole logo up and down. Prime-ish periods so they do not visibly loop. */
.mark-drift  { animation: markDrift  9s  ease-in-out infinite alternate; }
.mark-drift2 { animation: markDrift2 13s ease-in-out infinite alternate; }

/* A very slow tilt over the top of the drift. Long enough that it is felt
   rather than watched. Its own group, so it never fights the drift transform. */
.mark-sway {
  transform-box: fill-box;
  transform-origin: center;
  animation: markSway 21s ease-in-out infinite alternate;
}

/* Hover target. site.js decides when the pointer is actually over the mark and
   puts .is-mark-hot on the hero, so nothing has to capture pointer events over
   the hero text to make this work. */
.mark-zoom {
  transform-box: fill-box;
  transform-origin: center;
  transition: transform 620ms var(--ease-out);
}
.hero.is-mark-hot .mark-zoom { transform: scale(1.05); }
.hero.is-mark-hot .mark-a    { stroke-opacity: 0.38; }
.hero.is-mark-hot .mark-tri  { fill-opacity: 0.8; stroke-opacity: 0.8; }
.hero.is-mark-hot .mark-tick { stroke-opacity: 0.9; }
/* light crosses more often while you are on it */
.hero.is-mark-hot .mark-sweep { animation-duration: 2.4s; }

/* A light band, masked to the mark itself, travels the chevron and rests.
   Not a glow (§3): nothing blooms past the stroke edge — the band is clipped
   to the geometry, so it reads as light crossing a surface. */
.mark-sheen { opacity: 0; animation: fade 500ms var(--ease-out) 1600ms forwards; }
.mark-sweep { animation: markSweep 5.2s var(--ease-out) 1800ms infinite; }

/* Pointer parallax is set inline by site.js; this only smooths the follow. */
.mark-parallax { transition: transform 320ms var(--ease-out); }

@keyframes heroDrift {
  from { transform: translate3d(-3%, 2%, 0) scale(1); }
  to   { transform: translate3d(6%, -4%, 0) scale(1.14); }
}
@keyframes markSweep {
  0%   { transform: translateX(-160px); }
  42%  { transform: translateX(600px); }
  100% { transform: translateX(600px); }
}

@keyframes markPop {
  from { opacity: 0; transform: scale(0.72); }
  to   { opacity: 1; transform: scale(1); }
}
@keyframes markDrift {
  from { transform: translateY(-8px); }
  to   { transform: translateY(8px); }
}
/* Opposite phase and a longer period, so the apex lags the chevron. Amplitude
   is deliberately small: past about 12px the two shapes stop reading as depth
   and start reading as a logo printed out of register. */
@keyframes markDrift2 {
  from { transform: translateY(6px); }
  to   { transform: translateY(-5px); }
}
/* Felt rather than watched. A brand mark visibly off-axis reads as sloppy, so
   this stays under a degree. */
@keyframes markSway {
  from { transform: rotate(-0.9deg); }
  to   { transform: rotate(0.9deg); }
}


/* --- motion -----------------------------------------------------------------
   One choreography, on load, above the fold only. DESIGN.md §3 rules out
   animation that fires on every element as it scrolls into view, so nothing
   below the fold arrives — the cards answer hover and focus instead.
   -------------------------------------------------------------------------- */

/* Typed eyebrow. Every character is in the DOM from the first frame at
   opacity 0, so the line reserves its full width and nothing shifts as it
   fills — and the caret is absolutely positioned off the character being
   typed, so it costs no layout either. site.js also leaves a visually-hidden
   copy of the string in place, which is what a screen reader reads. */
/* site.js loads at the end of the body, so without this gate the untyped
   string paints first and then disappears as the script rebuilds it. Gated on
   .js, so with scripts off the line simply reads as written. */
.js [data-typewriter] { visibility: hidden; }
.js [data-typewriter].tw-ready { visibility: visible; }

.tw__c {
  position: relative;
  opacity: 0;
  transition: opacity 90ms linear;
}
.tw__c.is-in { opacity: 1; }
.tw__c.is-cursor::after {
  content: "";
  position: absolute;
  left: 100%;
  top: 0.02em;
  width: 2px;
  height: 1em;
  background: currentColor;
  animation: caret 820ms steps(1) infinite;
}
@keyframes caret { 0%, 49% { opacity: 1; } 50%, 100% { opacity: 0; } }

@keyframes heroTitle {
  from { opacity: 0; transform: translateY(14px); clip-path: inset(0 0 108% 0); }
  to   { opacity: 1; transform: none;             clip-path: inset(0 0 -14% 0); }
}
@keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
@keyframes draw { to { stroke-dashoffset: 0; } }

.anim   { animation: rise var(--dur-hero) var(--ease-out) both; }
.anim-1 { animation-delay:  40ms; }
.anim-2 { animation-delay: 120ms; }
.anim-3 { animation-delay: 200ms; }
.anim-4 { animation-delay: 300ms; }
.anim-5 { animation-delay: 380ms; }



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

.site-footer {
  background: var(--navy);
  background-image: var(--dots-dark);
  background-size: var(--dots-size);
  color: var(--on-navy-mute);
  padding-block: var(--s-8) var(--s-6);
  margin-top: auto;
}
.site-footer__grid {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: var(--s-6);
  padding-bottom: var(--s-6);
  border-bottom: var(--rule-w) solid var(--rule-dark);
}
.site-footer img { width: 178px; }
.site-footer p { font-size: var(--t-cap); line-height: 1.7; color: var(--on-navy-body); max-width: 240px; margin-top: var(--s-4); }
.site-footer h2 {
  font-family: var(--font-ui); font-size: var(--t-label); font-weight: 600;
  letter-spacing: var(--ls-label); text-transform: uppercase;
  color: var(--on-navy-lbl); margin-bottom: var(--s-3);
}
.site-footer li a, .site-footer address a { color: var(--on-navy-mute); font-size: var(--t-cap); line-height: 2.1; }
.site-footer li a:hover, .site-footer address a:hover { color: var(--white); }
.site-footer address { font-style: normal; display: flex; flex-direction: column; }
.site-footer address span { font-size: var(--t-cap); line-height: 2.1; }
.site-footer__legal {
  display: flex; justify-content: space-between; gap: var(--s-4); flex-wrap: wrap;
  padding-top: var(--s-4); font-size: 12px; color: var(--on-navy-lbl);
}

/* --- section rhythm ------------------------------------------------------- */

.section { padding-bottom: var(--s-9); }
/* Rows carry their own bottom padding, so the section must not add a second. */
.section--rows { padding-bottom: 0; }
/* Sections carry bottom padding only, so the first one after the hero would sit
   flush against the band. Give it a top edge to breathe against. Any full-bleed
   band has the same problem: it ends on its own edge rather than on content, so
   whatever follows would butt straight up against the colour. */
.hero + .section,
.band + .section { padding-top: var(--s-9); }
.section--tight { padding-bottom: var(--s-6); }
.page { display: flex; flex-direction: column; min-height: 100vh; }
/* Cleared by site.js to header + pinned bar + a margin; the literal is the
   pre-script fallback for pages with no sticky bar. */
[id] { scroll-margin-top: var(--jump-offset, 90px); }

/* ==========================================================================
   Responsive. Checked at 1440, 1024, 768 and 390.
   ========================================================================== */

@media (max-width: 1080px) {
  /* collapse every 12-column grid to one track: 11 gaps of 44px alone are
     wider than a phone, so the tracks must go, not just the spans */
  .featured__grid { grid-template-columns: minmax(0, 1fr); }
  .case-body { grid-template-columns: minmax(0, 1fr); }
  .practices { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .featured__text, .featured__media { grid-column: 1 / -1; }
  .featured__media {
    border-left: 0;
    border-top: var(--rule-w) solid var(--rule-tint);
    border-radius: 0 0 calc(var(--radius-panel) - var(--rule-w))
                   calc(var(--radius-panel) - var(--rule-w));
  }
  .case-body__main, .case-body__aside { grid-column: 1 / -1; }
  .case-body__aside { position: static; }
  .case-body { gap: var(--s-6); }
}

@media (max-width: 900px) {
  .grid { grid-template-columns: minmax(0, 1fr); }
  .nav { display: none; }
  .nav-toggle { display: inline-flex; }
  .site-header--open .nav {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: 0;
    position: absolute;
    left: 0; right: 0; top: 100%;
    background: var(--paper);
    border-bottom: var(--rule-w) solid var(--rule);
    padding: var(--s-3) var(--gutter) var(--s-5);
  }
  .site-header--open .nav__link {
    border-bottom: var(--rule-w) solid var(--rule);
    border-left: 3px solid transparent;
    padding: var(--s-3) var(--s-3);
    font-size: var(--t-body);
  }
  .site-header--open .nav__link[aria-current="page"] { border-left-color: var(--blue); }
  .site-header--open .nav .btn { margin-top: var(--s-3); }


  .service--flip .service__text, .service--flip .service__diagram { order: 0; }
  .service .grid { gap: var(--s-5); }
  .service__diagram { order: 2; }
  .service__text { order: 1; }

  .band__actions { justify-content: flex-start; }
  .site-footer__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .section-head { flex-wrap: wrap; }
  .section-head__meta { white-space: normal; }
}

@media (max-width: 820px) {
  .meta-bar { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .figure {
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: thin;
  }
  .figure svg { min-width: 520px; }
  .about-figure svg { min-width: 0; }
}

/* The hint is only true once 520px of diagram no longer fits — below ~640px.
   Between 820 and 640 the figure still scrolls if it needs to, silently. */
@media (max-width: 640px) {
  .figure__hint { display: block; }
}

@media (max-width: 700px) {
  /* Nine jump chips wrap to five rows on a phone, and pinning that would take
     most of the screen. One scrolling row instead — the behaviour DESIGN.md §4
     already specified for mobile jump chips. */
  .chipbar .chips {
    flex-wrap: nowrap;
    overflow-x: auto;
    scrollbar-width: thin;
    -webkit-overflow-scrolling: touch;
  }
  .practices { grid-template-columns: minmax(0, 1fr); }
  .annex__points { grid-template-columns: minmax(0, 1fr); }
  .about-values { grid-template-columns: minmax(0, 1fr); }
  .vm-cards { grid-template-columns: minmax(0, 1fr); }
  .cards { grid-template-columns: minmax(0, 1fr); }
  .page-head { padding-block: var(--s-6) var(--s-5); }
  .hero { padding-block: var(--s-7) var(--s-6); min-height: auto; }
  /* the mark is decorative; at this width it would crop to a sliver */
  .hero__bg { display: none; }
  .section { padding-bottom: var(--s-7); }
  .hero + .section { padding-top: var(--s-7); }
  .brand img { width: 148px; }
  .featured__text, .featured__media { padding: var(--s-5); }
  .site-footer__grid { grid-template-columns: minmax(0, 1fr); gap: var(--s-5); }
  .site-footer p { max-width: none; }
}

@media (max-width: 420px) {
  .meta-bar { grid-template-columns: minmax(0, 1fr); gap: var(--s-3); }
  .kv__row { flex-direction: column; gap: var(--s-1); }
  .kv__row dd { text-align: left; }
}

/* --- print ---------------------------------------------------------------- */

@media print {
  .site-header, .band, .nav-toggle { position: static; }
  body { background: var(--white); }
  /* Reveal-gated elements rest hidden until their section scrolls into view.
     Printing does not scroll, so without this a page printed from the top
     loses the diagram content entirely. */
  .js main > section .dia-pop, .js main > div .dia-pop,
  .js main > section .dia-step, .js main > div .dia-step { opacity: 1; }
  .js main > section .dia-ring, .js main > div .dia-ring { opacity: var(--o, 1); }
  .js main > section .nfc-path,
  .js main > div .nfc-path { stroke-dashoffset: 0; }
  .js main > section .dia-pop, .js main > div .dia-pop,
  .js main > section .dia-step, .js main > div .dia-step,
  .js main > section .dia-ring, .js main > div .dia-ring,
  .js main > section .dia-loop, .js main > div .dia-loop,
  .js main > section .dia-scan, .js main > div .dia-scan { animation: none !important; }
}

/* --- image gallery -----------------------------------------------------------
   Scroll-snap track plus two buttons. Frames stay dashed placeholders until
   real photography lands (DESIGN.md §1 — no stock, no drawn stand-ins).
   -------------------------------------------------------------------------- */

.slider { display: flex; flex-direction: column; gap: var(--s-3); max-width: 900px; margin-inline: auto; }
.slider__track {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 100%;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  scrollbar-width: none;
  border-radius: var(--radius-panel);
}
.slider__track::-webkit-scrollbar { display: none; }
.slide { margin: 0; scroll-snap-align: start; }
/* Screenshots are letterboxed on a tint mat, never cropped — these are
   interfaces, and a crop destroys the thing they are evidence of. */
.slide__frame {
  aspect-ratio: 16 / 9;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--s-3);
  background: var(--tint);
  border: var(--rule-w) solid var(--rule-tint);
  border-radius: var(--radius-panel);
  overflow: hidden;
}
.slide__frame img {
  display: block;
  max-width: 100%;
  max-height: 100%;
  width: auto;
  height: auto;
  border: var(--rule-w) solid var(--rule-tint);
  border-radius: var(--radius-node);
}
.slide__frame--pending {
  border-style: dashed;
  padding: var(--s-6);
  color: var(--body-mute);
  font-size: var(--t-meta);
  text-align: center;
  text-wrap: pretty;
}

/* Card thumbnails do crop — they are a glance, not the evidence. Top-left
   keeps the product's own header and nav in frame. */
.card__media--shot {
  padding: 0;
  aspect-ratio: 16 / 9;
  overflow: hidden;
}
.card__media--shot img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: top left;
}
.slide__cap { margin: var(--s-3) 0 0; font-size: var(--t-meta); color: var(--body-mute); text-wrap: pretty; }
.slider__bar { display: flex; align-items: center; justify-content: space-between; gap: var(--s-4); }
.slider__count { margin: 0; font-size: var(--t-cap); color: var(--slate); }
.navbtns { display: flex; gap: var(--s-2); }
.navbtn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  background: var(--white);
  border: var(--rule-w) solid var(--rule);
  border-radius: var(--radius-btn);
  color: var(--ink);
  cursor: pointer;
  transition: border-color var(--dur-micro) var(--ease-out), color var(--dur-micro) var(--ease-out);
}
.navbtn:hover:not(:disabled) { border-color: var(--blue); color: var(--blue-dark); }
.navbtn:disabled { color: var(--slate-lt); cursor: not-allowed; }

.slider__thumbs { display: flex; flex-wrap: wrap; gap: var(--s-2); }
.slider__thumb {
  width: 74px;
  aspect-ratio: 16 / 10;
  padding: 0;
  overflow: hidden;
  background: var(--tint);
  border: var(--rule-w) solid var(--rule);
  border-radius: var(--radius-node);
  cursor: pointer;
  opacity: 0.55;
  transition: opacity var(--dur-micro) var(--ease-out), border-color var(--dur-micro) var(--ease-out);
}
.slider__thumb img { display: block; width: 100%; height: 100%; object-fit: cover; object-position: top left; }
.slider__thumb:hover { opacity: 0.85; }
.slider__thumb[aria-current="true"] { opacity: 1; border-color: var(--blue); }
.slider__thumb--pending {
  display: flex; align-items: center; justify-content: center;
  border-style: dashed; font-size: var(--t-label); color: var(--body-mute);
}

/* --- the About model: assembles once, when its section arrives --------------
   §3 rules out animation firing on every element as it scrolls into view. This
   is the amended case (2026-09-02): ONE diagram choreographing its OWN parts,
   once, gated on its section's reveal. The order is the content — an engagement
   runs from contract to handover — so the assembly explains the diagram rather
   than decorating it.

   Everything is scoped under .js: with scripts off the section never gets
   .is-in, so the resting state must be, and is, the finished one.
   -------------------------------------------------------------------------- */

.js .about-figure .dm-rail {
  stroke-dasharray: 234;      /* the real rail length, 292 - 58 */
  stroke-dashoffset: 234;
}
.js .about-figure .dm-row { opacity: 0; }

.js main > section.is-in .dm-rail {
  animation: draw 900ms var(--ease-out) 140ms forwards;
}
/* each step lands as the rail reaches it */
.js main > section.is-in .dm-row { animation: dmRow 440ms var(--ease-out) both; }
.js main > section.is-in .dm-row--1 { animation-delay: 300ms; }
.js main > section.is-in .dm-row--2 { animation-delay: 520ms; }
.js main > section.is-in .dm-row--3 { animation-delay: 740ms; }
.js main > section.is-in .dm-row--4 { animation-delay: 960ms; }
/* the handover is the end of the line, so it arrives with a little weight */
.js main > section.is-in .dm-dot--end {
  transform-box: fill-box;
  transform-origin: center;
  animation: dmEnd 520ms var(--ease-out) 980ms backwards;
}

@keyframes dmRow { from { opacity: 0; transform: translateX(-10px); } to { opacity: 1; transform: none; } }
@keyframes dmEnd { 0% { transform: scale(0.4); } 62% { transform: scale(1.22); } 100% { transform: scale(1); } }

/* Alive on hover: the rail lifts out of the background and the steps swell
   very slightly. No bloom — §3's no-glow rule covers this panel too. */
.dm-dot {
  transform-box: fill-box;
  transform-origin: center;
  transition: transform var(--dur-enter) var(--ease-out);
}
.about-figure .dm-rail { transition: stroke var(--dur-enter) var(--ease-out); }
.about-figure:hover .dm-rail { stroke: var(--on-navy-lbl); }
.about-figure:hover .dm-dot { transform: scale(1.18); }

/* The bracket draws before the pairs land inside it: the enclosure is the
   claim, and the three re-labellings are the evidence for it. */
.js main > div .ab-bracket { stroke-dasharray: 218; stroke-dashoffset: 218; }
.js main > div.is-in .ab-bracket { animation: draw 760ms var(--ease-out) 120ms forwards; }
.ab-turn { transition: stroke var(--dur-enter) var(--ease-out); }
.about-figure:hover .ab-turn { stroke: var(--on-navy); }
@media (prefers-reduced-motion: reduce) {
  .js main > div .ab-bracket { stroke-dashoffset: 0; }
  .js main > div.is-in .ab-bracket { animation: none; }
}

/* --- section reveal ----------------------------------------------------------
   DESIGN.md §3 amended 2026-09-01: reveal is allowed once, on whole section
   blocks — never per element, never repeating. The clause exists to stop the
   everything-fades-up-forever cliché, not to ban entrance motion outright.
   Gated on .js, so with scripts off the page is simply visible.
   -------------------------------------------------------------------------- */

/* .chipbar is exempt on both counts: it is a persistent control rather than a
   content block, so it should not arrive; and a transform on a sticky element
   shifts where it pins, which pushed the bar 14px below the header. */
.js main > section:not(.hero),
.js main > div:not(.chipbar) {
  opacity: 0;
  transform: translateY(14px);
  transition: opacity 620ms var(--ease-out), transform 620ms var(--ease-out);
}
.js main > section.is-in,
.js main > div.is-in { opacity: 1; transform: none; }

@media (prefers-reduced-motion: reduce) {
  .js main > section:not(.hero),
  .js main > div:not(.chipbar) { opacity: 1; transform: none; transition: none; }
}

@media (prefers-reduced-motion: reduce) {
  .slider__track { scroll-behavior: auto; }
}

/* --- forms ---------------------------------------------------------------- */

.form { display: flex; flex-direction: column; gap: var(--s-4); }
.form__row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--s-4); }
.field { display: flex; flex-direction: column; gap: var(--s-1); }
.field label { font-size: var(--t-cap); font-weight: 600; color: var(--ink); }
.field .hint { font-size: 12px; color: var(--body-mute); }
.field input, .field select, .field textarea {
  font-family: var(--font-ui);
  font-size: var(--t-body);
  color: var(--ink);
  background: var(--white);
  border: var(--rule-w) solid var(--rule);
  border-radius: var(--radius-btn);
  padding: var(--s-3);
  min-height: 48px;
  width: 100%;
}
.field textarea { min-height: 150px; resize: vertical; line-height: 1.6; }
.field input:focus, .field select:focus, .field textarea:focus {
  outline: none; border-color: var(--blue); box-shadow: var(--focus);
}
.form__note { font-size: var(--t-cap); color: var(--body-mute); }

.steps { counter-reset: step; display: flex; flex-direction: column; gap: var(--s-3); }
.steps li { display: flex; gap: var(--s-3); align-items: flex-start; font-size: var(--t-meta); line-height: var(--lh-tight); }
.steps li::before {
  counter-increment: step;
  content: counter(step, decimal-leading-zero);
  flex: none;
  font-size: var(--t-cap);
  font-weight: 600;
  color: var(--blue);
  padding-top: 1px;
}

.people { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--s-5); }
.person {
  border: var(--rule-w) dashed var(--rule-tint);
  border-radius: var(--radius-panel);
  background: var(--tint);
  padding: var(--s-5);
}
.person h3 { font-size: var(--t-h5); margin-bottom: var(--s-1); }
.person p { font-size: var(--t-meta); line-height: 1.6; color: var(--body-mute); }

@media (max-width: 900px) {
  .form__row { grid-template-columns: minmax(0, 1fr); }
  .people { grid-template-columns: minmax(0, 1fr); }
}
