/* --- CONTENTS --------------------------------------------------------------
   Generated by tools/build-index.py, checked by check.sh. Do not hand edit.
   
   NIRM - PORTFOLIO DESIGN SYSTEM (merged) ......................... line 48
   SECTION EDGES, a diagnostic aid ................................. line 1408
   ADDITIONS (this thread) ......................................... line 1501
   ND CONTACT  (mailto + copy, full pill + compact dark footer... .. line 1729
   CARD METRICS, CHIPS AND META ROWS ............................... line 1814
   HERO CARD: wide, twin-square layout above the breakpoint ........ line 1843
   PAGE SCAFFOLDING GRID. General-purpose layout for a builder ..... line 1933
   PAGE GRID: controls card sizing, column splits, and... .......... line 2137
   HOTSPOTS (.hs-field) ............................................ line 2521
   SITE FOOTER: the full name as a full-width lockup ............... line 2701
   FILTER PANEL: the left rail on an index page .................... line 2902
   SITE HEADER: wordmark plus primary navigation ................... line 3045
   TABS: one row of triggers, one panel visible at a time .......... line 3845
   ACCORDION: one open at a time ................................... line 3912
   MARQUEE: a slow, seamless, constant-speed scrolling track ....... line 4012
   CLIENT BUBBLE: an alternative to the tile turn .................. line 4177
   CASE STUDY SECTIONS: the storytelling spine of a project... ..... line 4347
   CLIENT LOGO GRID: categorised, responsive, tiles turn to... ..... line 4683
   HEADER STRAPLINE: cycling text, cross-fade (lighter blur),... ... line 4863
   HEADER STRAPLINE: TYPEWRITER variant ............................ line 4901
   TESTIMONIALS - floating bubble field (drifting rows) +... ....... line 4944
   QUOTE ROTATOR: homepage cherry-picked lines, one slide at a... .. line 5135
   FLAT TESTIMONIAL CARDS: full quote always visible, no... ........ line 5262
   BEFORE / AFTER SLIDER: drag or tap to reveal, auto-detects... ... line 5360
   ANNOTATED IMAGE: numbered dots positioned on a photo,... ........ line 5491
   VARIANT 4: CAROUSEL / GUIDED TOUR: step through each dot... ..... line 5592
   TILT-3D - standalone pointer-tilt, usable on ANY element... ..... line 5640
   REVEAL - fade + ease upward as an element enters the... ......... line 5668
   FREEFORM LAYOUT: percentage-positioned items that collapse... ... line 5716
   GLASS: a reusable translucent/blurred surface, add .glass... .... line 5770
   TOOLTIP - hover (or tap, on touch) an element with .tooltip... .. line 5837
   CUSTOM CURSOR: while the mouse is over a .cursor-zone, the... ... line 5920
   CURSOR TRAIL: a single continuous, elastic ribbon following... .. line 5955
   SCROLL PROGRESS BAR: a thin bar on one edge of the viewport... .. line 5975
   SCROLL PROGRESS: SEGMENTED variant .............................. line 6032
   PAGE-NAV - V1 / V2 SWITCH. Two indicator styles for the... ...... line 6170
   PAGE-NAV V2: refinements ........................................ line 6252
   PASSWORD LOCK SCREEN. .nd-lock replaces WordPress's default... .. line 6404
   ERROR PAGE (404) ................................................ line 6674
   TYPE SCALE. The single place type SIZE is set ................... line 6707
*/

@import url('https://fonts.googleapis.com/css2?family=Metal&display=swap');

/* ============================================================================
   NIRM - PORTFOLIO DESIGN SYSTEM (merged)
   One token layer for all three components: project cards, page scrollspy,
   and the copy-email contact. Everything is namespaced --nd-* so it can't
   clash with the WordPress theme's own variables. Change the palette in ONE
   place (the :root block). Pair with the existing JS files (unchanged).
   ============================================================================ */
 
:root {
  /* type */
  /* Three faces, each with a job.
       --nd-font         chrome and instruments: the client and project labels,
                         the live readouts, anything that should read as UI
       --nd-font-display headings
       --nd-font-body    running copy
     Times has a noticeably smaller x-height than Inter, so serif text needs a
     size bump to hold the same apparent weight: --nd-serif-scale is that
     compensation, applied wherever --nd-font-body is. */
  --nd-font: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  --nd-font-display: "Metal", "Inter", system-ui, sans-serif;
  --nd-font-body: "Times New Roman", Times, serif;
  --nd-serif-scale: 1.12;

  /* TYPE SCALE. ONE MASTER KNOB drives every text size in the system.

     --nd-fs is the single number to adjust. Every role token below is a
     multiple of it, so changing --nd-fs alone scales the whole site up or down
     while keeping the hierarchy (a title stays proportionally larger than a
     label). Nothing anywhere is a hardcoded px: the TYPE SCALE block near the
     end of the file maps every text class to one of these tokens, so this is
     the only place sizes live.

     To make everything bigger or smaller: change --nd-fs (e.g. 1.05rem, 1.1rem).
     To retune ONE role relative to the rest: change its multiplier below. */
  --nd-fs: 1.6rem;                                 /* THE KNOB: base size everything derives from */
  --nd-fs-micro:      calc(var(--nd-fs) * 0.75);   /* eyebrows, dots, tiny meta */
  --nd-fs-2xs:        calc(var(--nd-fs) * 0.69);   /* compact pill chrome, ~1.1rem at base, e.g. testimonial bubbles */
  --nd-fs-label:      calc(var(--nd-fs) * 0.85);   /* meta, chips, captions, small labels */
  --nd-fs-instrument: calc(var(--nd-fs) * 0.9);    /* card readouts: client/project/time/angle */
  --nd-fs-ui:         calc(var(--nd-fs) * 0.95);   /* nav, buttons, tabs, form controls */
  --nd-fs-body:       calc(var(--nd-fs) * var(--nd-serif-scale)); /* running copy: bumped by the serif x-height correction so Times reads level with Inter */
  --nd-fs-lead:       calc(var(--nd-fs) * 1.25);   /* leads, subheads, cl-title, acc trigger */
  --nd-fs-title:      calc(var(--nd-fs) * 1.6);    /* headings, hooks, logotype, big quotes */
  --nd-fs-display:    calc(var(--nd-fs) * 2.2);    /* big stat numbers, decorative marks */
  --nd-fs-mega:       calc(var(--nd-fs) * 3.6);    /* the largest decorative quote marks */

  /* Header chrome sizes derive from the knob too, so it reaches the site header.
     Kept near their original 17px/16px so the optically measured baseline drops
     (--sh-nav-drop / --sh-lede-drop) still land true. If --nd-fs is changed a
     lot, re-measure those two per the note in the site-header rules. */
  --sh-nav-size:  calc(var(--nd-fs) * 1.05);       /* primary nav links (~17px at base) */
  --sh-lede-size: var(--nd-fs);                    /* the "Product Designer" subtitle (~16px) */

  /* LINE HEIGHT, rem-based and anchored to the same scale. Each leading is a
     multiple of its role's OWN font token, so it resolves to rem, scales with
     the knob exactly as the sizes do, and stays proportional to the text it
     sets (a title never gets a body's leading). Tighter roles (numbers, marks)
     carry less; running copy carries more. Change a ratio here to retune a
     role's rhythm; change --nd-fs to move everything together. */
  --nd-lh-mega:       var(--nd-fs-mega);                     /* 1.0  decorative marks */
  --nd-lh-display:    calc(var(--nd-fs-display)    * 1.05);  /* big stat numbers */
  --nd-lh-title:      calc(var(--nd-fs-title)      * 1.15);  /* headings */
  --nd-lh-lead:       calc(var(--nd-fs-lead)       * 1.3);   /* leads, subheads */
  --nd-lh-body:       calc(var(--nd-fs-body)       * 1.5);   /* running copy */
  --nd-lh-ui:         calc(var(--nd-fs-ui)         * 1.3);   /* nav, buttons, controls */
  --nd-lh-label:      calc(var(--nd-fs-label)      * 1.3);   /* meta, chips, captions */
  --nd-lh-micro:      calc(var(--nd-fs-micro)      * 1.3);   /* eyebrows, tiny meta */
  --nd-lh-instrument: calc(var(--nd-fs-instrument) * 1.05);  /* card readouts and metrics, kept tight */

  /* PAGE SHELL. The page runs FULL WIDTH with a shared left/right margin,
     --nd-page-pad (4%), that the header content, the footer content and every
     section align to, so all three sit on the same two lines edge to edge rather
     than in a narrower centred column. No max-width: content extends across the
     page. Backgrounds and JS full-bleed rows (the marquees) span the whole
     viewport regardless.

     --nd-card-inset-r (3%) is an EXTRA right margin on card galleries only, so
     the cards stop short of the edge and clear the fixed in-page nav that sits
     in the right margin; short left-aligned content (headings) does not reach
     that far, so it needs no inset.

     --nd-shell-inset / -r stay as the names the header, footer and lede already
     read; both now resolve to the page margin (symmetric). --nd-shell-max is
     kept only for the standalone .nd-shell utility. */
  --nd-page-pad:      4%;
  --nd-card-inset-r:  3%;
  --nd-shell-max:     1440px;
  --nd-shell-pad:     2rem;
  --nd-shell-inset:   var(--nd-page-pad);
  --nd-shell-inset-r: var(--nd-page-pad);

  /* Case-study measures as GLOBAL defaults, so a .cs-section works outside a
     .cs-page too, e.g. dropped straight into a page via a shortcode or Bricks.
     .cs-page still restates these (it can tune them per case study), so those
     pages are unchanged; this only stops --cs-wide resolving to nothing (which
     collapsed .cs-inner to full width) when the section has no .cs-page parent. */
  --cs-measure: 68ch;
  --cs-narrow:  52ch;
  --cs-wide:    100%;   /* wide sections fill the content area (the page minus its 4% margins) */
  --cs-gutter:  0px;    /* the 4% page margin does the insetting now, so no extra gutter, or content would sit 24px in from the header */
 
  /* ink + neutrals */
  --nd-ink: #1c1d24;          /* primary text */
  --nd-muted: #5f6472;        /* secondary text */
  --nd-faint: #8b909e;        /* meta / labels */
 
  /* surfaces */
  --nd-page-bg: #ccc;         /* the page behind the content, shows in the margins */
  --nd-surface: #ffffff;      /* card / nav / control surface */
  --nd-surface-2: #efece7;    /* media / image placeholder */
  --nd-hover: #f4f2ee;        /* subtle hover / active fill */
  --nd-lemon: #fff44f;        /* pcard frame on hover */
 
  /* lines (translucent so they sit on any surface) */
  --nd-line: rgba(22, 24, 40, .12);
  --nd-line-2: rgba(22, 24, 40, .18);
 
  /* accent (terracotta) */
  --nd-accent: #e8623d;       /* fills: buttons, active dot, metric tint */
  --nd-blue: royalblue;       /* shared interactive blue: link/nav hover + active */
  /* Position indicators, the carousel dots and the scroll progress fill. Split
     from --nd-blue because those two read as STATE ("you are here") rather than
     as something to click, and black says that more plainly than a colour that
     also means link. Links keep the blue. */
  --nd-indicator: #000;
  --nd-blue-rgb: 65, 105, 225;   /* same colour as channels, for rgba() tints */
  --nd-accent-hover: var(--nd-blue);
  --nd-accent-active: #1874cc;
  --nd-accent-ink: #b34a23;   /* accent as TEXT (contrast on white) */
  --nd-metric-soft: rgba(147, 197, 114, .18);  /* pistachio tint behind the impact
                                                 boxes: the old terracotta tint
                                                 read as a warning, not a win */
  --nd-on-accent: #ffffff;
  --nd-graphite: #33353c;    /* text on an accent fill */
 
  /* shape */
  --nd-radius: 8px;           /* cards */
  --nd-radius-md: 12px;       /* nav */
  --nd-radius-sm: 10px;       /* small buttons */
  --nd-radius-pill: 999px;    /* pills / dots */
 
  /* motion */
  --nd-ease: cubic-bezier(.22, .61, .36, 1);
  --nd-soft: cubic-bezier(.33, .1, .25, 1);
 
  /* shadow */
  --nd-shadow: 0 18px 40px -26px rgba(28, 29, 50, .28), 0 2px 6px -2px rgba(28, 29, 50, .07);
  --nd-shadow-lg: 0 40px 72px -34px rgba(28, 29, 50, .34);
  --nd-shadow-sm: 0 6px 24px -6px rgba(28, 29, 50, .12);
 
  /* card sizing */
  --nd-card-text: var(--nd-fs);       /* ONE shared size for every card text element,
                                 front state and mini case alike */
  --nd-card-w: 330px;
  --nd-media-sq: 330px;
}
 
/* ---------- globals (scoped; won't touch the rest of the site) ---------- */
.card, .card *,
.page-nav, .page-nav *,
.nd-contact, .nd-contact * { box-sizing: border-box; }
 
@media (prefers-reduced-motion: no-preference) {
  html { scroll-behavior: smooth; }
}
 
/* scroll offset for jumped-to sections. [id] is broad. If it interferes with
   anything else on the page, narrow it to your section class, e.g.
   .page-section[id] { scroll-margin-top: 2rem; } */
[id] { scroll-margin-top: 2rem; }
 
.card,
.card * {
  box-sizing: border-box
}
 
/* ---------- shell ---------- */
.card {
  width: var(--nd-card-w);
  position: relative;
  perspective: 1500px;
  font-family: var(--nd-font);
  color: var(--nd-ink)
}
 
.card-shell {
  background: var(--nd-surface);
  border: 1px solid var(--nd-line);
  overflow: hidden;
  display: flex;
  flex-direction: column
}
 
/* ---------- media area ---------- */
.card-media,
.card-stage > div:first-child {
  position: relative;
  width: 100%;
  aspect-ratio: 1 / 1;      /* square by default; scales with the card width */
  background: var(--nd-surface-2);
}
 
.card-lottie {
  width: 200px;
  height: 200px;
  cursor: pointer
}
 
.card-hover-hint {
  position: absolute;
  left: 14px;
  bottom: 12px;
  font-size: var(--nd-card-text);
  letter-spacing: .04em;
  color: #fff;
  background: rgba(22, 24, 40, .55);
  padding: 3px 8px;
  border-radius: 999px
}
 
/* ---------- front text ---------- */
.card-front-body {
  padding: 15px 18px 16px;
  display: flex;
  flex-direction: column;
  gap: 10px;
  flex: 1
}
 
.card-client {
  font-size: var(--nd-card-text);
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--nd-ink);
  font-weight: 600;
}
 
/* the one deliberate exception to --nd-card-text: the hook is the card's
   lead line, so it steps up while everything else stays at the shared size */
.card-hook {
  margin: 0;
  /* Its own scale, times the serif correction: Times at a flat 1.5rem reads a
     size smaller than Inter did. */
  line-height: 1.25;
  font-weight: 400
}
 
/* text link + button (shared).
   text-decoration: none matters because this class is used on BOTH <button>
   and <a>. Without it an anchor draws the browser's own underline on top of
   the rule below, giving the link two stacked underlines.
   The rule itself is the static 2px baseline; ::after is a second bar of the
   same thickness sitting exactly on top of it, scaled to 0 until hover, when
   it wipes in from the left. */
.card-link {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  background: none;
  border: 0;
  color: var(--nd-ink);
  font: inherit;
  font-size: var(--nd-card-text);
  font-weight: 600;
  cursor: pointer;
  text-decoration: none;
  padding: 6px 0 2px
}
 
/* The underline lives on the LABEL span, not the link, so it stops at the
   end of the text instead of running under the trailing arrow. Wrap the
   label in a <span> for this to apply; a link with no span simply has no
   underline. */
.card-link > span {
  position: relative;
  border-bottom: 2px solid var(--nd-line-2);
  padding-bottom: 2px;
}
 
.card-link > span::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: -2px;            /* sits directly over the 2px border below */
  height: 2px;
  background: var(--nd-blue);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform .35s var(--nd-ease);
}
 
.card-link:hover > span::after {
  transform: scaleX(1);
}

/* The padlock marks a case study as password protected. It is a status marker
   rather than part of the link text, so it opts out of the underline and the
   hover wipe that .card-link > span applies to every direct span child.
   Optional per card: include the span only where the case really is gated. */
.card-link > span.card-lock {
  border-bottom: 0;
  padding-bottom: 0;
}
.card-link > span.card-lock::after { content: none; }
 
/* the open/close trigger reads as plain text; the case-study CTA keeps the
   heavier weight so it stands out as the primary action */
.card-open {
  font-weight: 400;
}
 
/* the border colour deliberately does NOT change on hover: the ::after wipe
   is what reads as the hover state, and recolouring the border would just
   flip the whole underline instantly and hide the animation */
.card-link:hover {
  color: var(--nd-blue);
}
 
.card-link svg {
  transition: transform .3s var(--nd-ease)
}
 
.card-link:hover svg {
  transform: translateX(3px)
}
 
.card-link.card-is-back:hover svg {
  transform: translateX(-3px)
}

/* text-link call to action for the OPEN (mini case) state, e.g. "Case study".
   Hidden while the card is closed, and pushed to the far end of the
   footer so it sits opposite the Back / Quick read trigger. Pair it with
   .card-link for the look. Covers every pattern's open-state class, so this
   works on a fade, overlay, flip or spot card without extra wiring. */
.card-case-link {
  display: none;
  margin-left: auto;
}
.card-is-open .card-case-link,
.card-is-flipped .card-case-link,
.card-is-expanded .card-case-link {
  display: inline-flex;
}

/* The case-study slot has three authoring states. Pick one per project:
     1. no case study yet   -> omit the element entirely, nothing shows
     2. case being written  -> <span class="card-case-link card-case-wait">Case coming soon</span>
     3. case is live        -> <a class="card-link card-case-link" href="...">
                                 <span>Case study</span></a>
   Append <span class="card-lock" role="img" aria-label="Password protected">
   with a padlock to state 3 where the case study is gated, which most are.
   .card-case-wait deliberately does NOT take .card-link: it's a status, not
   an action, so it gets no underline, no hover and no focus stop, matching
   how .card-cta-wait behaves for the filled CTA. */
.card-case-wait {
  font-size: var(--nd-card-text);
  font-weight: 400;
  color: var(--nd-muted);
  padding: 6px 0 2px;
  cursor: default;
}
 
.card-cta {
  background: var(--nd-graphite);
  color: #fff;
  border: 0;
  border-radius: 2px;
  padding: 9px 15px;
  font: inherit;
  font-weight: 600;
  font-size: var(--nd-card-text);
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  gap: 5px;
  transition: background .2s ease, box-shadow .2s ease
}
 
.card-cta:hover {
  background: var(--nd-accent-hover);
  box-shadow: 0 9px 20px -8px rgba(232, 98, 61, .5)
}
 
.card-cta:active {
  background: var(--nd-accent-active)
}
 
/* ---------- detail (back face / panel / reveal) ---------- */
.card-detail {
  display: flex;
  flex-direction: column;
  height: 100%;
  overflow-y: auto
}
 
.card-detail-body {
  padding: 16px 18px 18px;
  display: flex;
  flex-direction: column;
  gap: 10px
}
 
.card-detail-body p {
  margin: 0;
  font-size: var(--nd-card-text);
  line-height: 1.42;
  color: var(--nd-ink)
}
 
.card-detail-body p.card-need {
  color: var(--nd-muted)
}
 
/* The mini case reads as a handful of chunks: client/description, the four
   sentences, the impact boxes, the chips, the meta rows. Wrapping the
   sentences keeps them reading as ONE chunk so the distribution below
   spreads the chunks apart rather than the individual sentences. */
.card-sentences {
  display: flex;
  flex-direction: column;
  gap: 10px
}
 
/* Spread those chunks down the full height of the panel. Gated on
   :has(.card-sentences) so it's opt-in: without the wrapper, space-between
   would prise the four sentences apart, so any card markup that hasn't been
   grouped keeps its current stacked layout untouched.
   flex: 1 0 auto fills the panel when the content is short, but never
   shrinks below content height, so a long mini case still scrolls. */
.card-detail-body:has(.card-sentences) {
  flex: 1 0 auto;
  justify-content: space-between
}
 
.card-metrics {
  display: flex;
  gap: 10px
}
 
/* Impact boxes use a hard chamfer rather than a radius: the corners are
   sliced off at --nd-chamfer, giving an octagon.

   The 2px rule is NOT a border, for the reason this comment used to give as
   why there was none: clip-path cuts a border off along the diagonals while
   leaving it on the straights, which reads as a broken outline. It is drawn
   the same way the card frame draws its own, as a coloured layer with an inset
   fill on top, here a ::before rather than a wrapper since the box has two
   children and no single element to inset. The children are lifted above it. */
.card-metric {
  --nd-chamfer: 0.5rem;
  --nd-metric-rule: 2px;
  --nd-metric-rule-ink: #aaa;
  position: relative;
  flex: 1;
  background: var(--nd-metric-rule-ink);
  border: 0;
  border-radius: 0;
  padding: calc(10px + var(--nd-metric-rule)) calc(12px + var(--nd-metric-rule));
  clip-path: polygon(
    var(--nd-chamfer) 0,
    calc(100% - var(--nd-chamfer)) 0,
    100% var(--nd-chamfer),
    100% calc(100% - var(--nd-chamfer)),
    calc(100% - var(--nd-chamfer)) 100%,
    var(--nd-chamfer) 100%,
    0 calc(100% - var(--nd-chamfer)),
    0 var(--nd-chamfer)
  )
}
.card-metric::before {
  content: '';
  position: absolute;
  inset: var(--nd-metric-rule);
  background: var(--nd-surface);
  clip-path: polygon(
    calc(var(--nd-chamfer) - var(--nd-metric-rule)) 0,
    calc(100% - (var(--nd-chamfer) - var(--nd-metric-rule))) 0,
    100% calc(var(--nd-chamfer) - var(--nd-metric-rule)),
    100% calc(100% - (var(--nd-chamfer) - var(--nd-metric-rule))),
    calc(100% - (var(--nd-chamfer) - var(--nd-metric-rule))) 100%,
    calc(var(--nd-chamfer) - var(--nd-metric-rule)) 100%,
    0 calc(100% - (var(--nd-chamfer) - var(--nd-metric-rule))),
    0 calc(var(--nd-chamfer) - var(--nd-metric-rule))
  );
}
.card-metric b,
.card-metric span { position: relative; }
 
.card-metric b {
  display: block;
  font-size: var(--nd-card-text);
  font-weight: 700;
  letter-spacing: -.02em;
  line-height: 1.2;
  color: var(--nd-ink)
}
 
.card-metric span {
  font-size: var(--nd-card-text);
  color: var(--nd-muted)
}
 
.card-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 6px
}
 
.card-chip {
  font-size: var(--nd-card-text);   /* 1rem = 16px, exactly on the 4px grid */
  color: var(--nd-muted);
  border: 1px solid var(--nd-line-2);
  border-radius: 999px;
  padding: 3px 9px
}
 
/* The role line. Sits directly under the client/project header, so it is the
   first thing read after "who was this for". Written as one sentence rather
   than a label/value row: the role leads in bold because it is the fact that
   frames everything below it, and the team follows in muted text because the
   shape of the team is context, not a headline. This replaces what used to be
   two separate meta rows, Role and Team. */
/* .card-detail-body p sets colour at (0,1,1), so a bare .card-role at (0,1,0)
   loses to it on specificity and the team text comes out full-strength ink.
   The parent is in the selector purely to win that, not for scoping. */
.card-detail-body p.card-role {
  margin: 0;
  font-size: var(--nd-card-text);
  line-height: 1.42;
  color: var(--nd-muted);
  text-align: center;   /* one short line, centred, so it reads as a caption
                           under the client name rather than a first body line */
}

.card-detail-body p.card-role b {
  color: var(--nd-ink);
  font-weight: 600;
}

.card-meta {
  display: flex;
  flex-direction: column;
  gap: 3px
}
 
.card-meta-row {
  display: flex;
  justify-content: space-between;
  font-size: var(--nd-card-text);
  color: var(--nd-faint)
}
 
.card-meta-row b {
  color: var(--nd-ink);
  font-weight: 500
}
 
/* ---------- shared control bar (overlay / fade) ---------- */
.card-footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 10px;
  min-height: 54px;
  padding: 10px 18px;
  border-top: 1px solid var(--nd-line);
  background: var(--nd-surface)
}
 
.card-footer .card-link {
  padding: 4px 0
}
 
/* Full case blur-fades in on the open state */
.card-fade .card-footer .card-cta {
  opacity: 0;
  filter: blur(8px);
  transform: translateY(3px);
  pointer-events: none;
  visibility: hidden;
  transition: opacity .4s ease, filter .4s ease, transform .4s ease, background .2s ease, box-shadow .2s ease, visibility 0s linear .4s
}
 
.card-fade.card-is-open .card-footer .card-cta {
  opacity: 1;
  filter: blur(0);
  transform: none;
  pointer-events: auto;
  visibility: visible;
  transition: opacity .4s ease .28s, filter .4s ease .28s, transform .4s ease .28s, background .2s ease, box-shadow .2s ease, visibility 0s
}
 
/* ---------- stage: the positioning context the panel fills ---------- */
.card-stage {
  position: relative;
  overflow: hidden
}
 
.card-fade .card-panel {
  position: absolute;
  inset: 0;
  background: var(--nd-surface);
  z-index: 6;
  display: flex;
  flex-direction: column
}
 
/* ---------- 4 · fade + blur in place ---------- */
.card-fade .card-panel {
  opacity: 0;
  filter: blur(18px);
  transform: scale(1.012);
  pointer-events: none;
  transition: opacity .5s ease, filter .55s ease, transform .55s var(--nd-soft)
}
 
.card-fade.card-is-open .card-panel {
  opacity: 1;
  filter: blur(0);
  transform: scale(1);
  pointer-events: auto
}
 
/* ---------- 1b · pcard-panel (default: auto-rotate, plus hover/touch-drag control) ----------
   The classic card-flip trick: perspective, preserve-3d, backface-visibility:
   hidden, a face pre-rotated 180deg, but instead of a click-toggled 0/180
   state, -inner spins one direction 0deg -> 360deg on an infinite loop, at a
   constant linear rate (no ease, no holds) so the rotation reads as one
   continuous motion with no discernible pause when either face is front-on.
   Since the pre-rotated face is always exactly 180deg from whichever face is
   currently front-on, the two faces swap every time the spin passes edge-on
   (90deg/270deg), and by 180deg the second face reads correctly (not
   mirrored) for the same reason a completed half-turn does. Rotation and
   blur are kept on separate elements (-inner vs -stage), since browsers
   can render blur oddly when combined
   with backface-visibility on the same 3D-transformed layer. Both share the
   same duration/timing so they stay in lockstep, whether autoplaying or
   under the manual control below.
   .pcard-stage is an optional white surround: wrap .pcard-panel in it to
   frame the rotating card on a plain white ground; it's also the hover/
   touch-drag target handled in combined.js: for a mouse, hovering alone
   takes control (no click needed): entering pauses autoplay, moving
   left/right rotates the card, leaving resumes; touch has no hover, so
   actual contact (a normal touch-drag) is the equivalent trigger there.
   Manual control works by scrubbing the SAME two CSS animations' currentTime
   in step with pointer movement rather than driving a separate transform,
   which is what keeps blur perfectly synced to rotation angle either way.
   touch-action: pan-y leaves vertical page scrolling free and reserves
   horizontal drags for the component.
   object-fit: contain (rather than cover) is what makes the two faces
   robust to different image proportions: portrait, square or landscape
   all sit uncropped and centred inside the fixed card box, with
   .pcard-face's own background showing through as letterboxing. */
.pcard-stage {
  background: var(--nd-surface);
  /* Chamfer rather than a radius, matching .card-metric. Inset inside the rule
     drawn by .pcard-media, so the cut is one rule-width smaller: insetting a
     45deg chamfer by d shortens its leg by d(sqrt2 - 1), which at 2px is under
     a pixel, so subtracting the rule width outright is close enough and keeps
     the arithmetic legible. */
  border-radius: 0;
  --pcard-chamfer-inner: calc(var(--pcard-chamfer) - var(--pcard-frame-rule));
  clip-path: polygon(
    var(--pcard-chamfer-inner) 0,
    calc(100% - var(--pcard-chamfer-inner)) 0,
    100% var(--pcard-chamfer-inner),
    100% calc(100% - var(--pcard-chamfer-inner)),
    calc(100% - var(--pcard-chamfer-inner)) 100%,
    var(--pcard-chamfer-inner) 100%,
    0 calc(100% - var(--pcard-chamfer-inner)),
    0 var(--pcard-chamfer-inner)
  );
  padding: var(--pcard-frame-pad, 16px);
  display: flex;
  flex-direction: column;    /* label row on top, rotating card beneath it */
  align-items: center;       /* frame fills the available width; this keeps the card centred within it */
  gap: 14px;
  position: relative;        /* positioning context for .pcard-finder */
  -webkit-tap-highlight-color: transparent;
  touch-action: pan-y;
  cursor: ew-resize;   /* hovering alone drives the rotation (no click needed), so this hints "move left/right", not "grab" */
  transition: background .25s var(--nd-ease);
}

/* Hovering the frame is already the gesture that takes over the rotation, so
   the colour change doubles as the affordance for it: the card is under your
   control while it is lemon. :focus-within keeps that visible for keyboard
   users, who reach the dots without ever hovering. */
.pcard-stage:hover,
.pcard-stage:focus-within {
  background: var(--pcard-hover);
}

/* Everything sitting ON the frame follows the paired ink, so a dark brand
   colour stays readable. The viewfinder brackets and ring are drawn with
   borders, hence border-color rather than color. */
.pcard-stage:hover .pcard-client,
.pcard-stage:focus-within .pcard-client,
.pcard-stage:hover .pcard-project,
.pcard-stage:focus-within .pcard-project,
.pcard-stage:hover .pcard-time,
.pcard-stage:focus-within .pcard-time,
.pcard-stage:hover .pcard-angle,
.pcard-stage:focus-within .pcard-angle {
  color: var(--pcard-hover-ink);
}
.pcard-stage:hover .pcard-finder-corner,
.pcard-stage:focus-within .pcard-finder-corner,
.pcard-stage:hover .pcard-finder-ring,
.pcard-stage:focus-within .pcard-finder-ring {
  border-color: var(--pcard-hover-ink);
}

/* The dots sit on the frame too, so they follow the ink for the same reason:
   muted grey and the blue active state both disappear against a dark brand
   colour. The active dot keeps its full opacity and extra width, so it still
   reads as the current one without needing a second colour. */
.pcard-stage:hover .pcard-dot,
.pcard-stage:focus-within .pcard-dot {
  background: var(--pcard-hover-ink);
}

.pcard-stage:hover .pcard-dot[aria-current="true"],
.pcard-stage:focus-within .pcard-dot[aria-current="true"] {
  background: var(--pcard-hover-ink);
  opacity: 1;
}
/* Panel proportion. The tokens sit on .pcard, not on .pcard-panel, so a
   modifier on the card can override them; custom properties inherit, so the
   panel and the JS (which reads --pcard-duration off .pcard-panel) both still
   resolve them. Landscape is the default, so .pcard--landscape is only an
   explicit alias for when you want the intent written down.

   The faces letterbox any image proportion inside whichever box is chosen, so
   a portrait card still accepts a landscape image and vice versa. The white
   frame is content-sized, so it grows taller on its own for portrait. */
.pcard {
  --pcard-w: 400px;          /* the panel's MAXIMUM width, not a fixed one */
  --pcard-h: 300px;
  --pcard-ratio: 4 / 3;
  --pcard-duration: 11.5s;   /* one full 360deg rotation */
  /* Inset for the frame's CHROME, not for the panel. It was never actually
     declared before, only ever read as var(--pcard-frame-pad, 16px) in six
     places, so 16px was the de facto value and 16px is what it stays.

     What it moves is the client and project labels, the angle and elapsed
     readouts and the dots row, all of which sit this far from the frame edge.
     It does NOT set the panel's side space: the panel is capped at --pcard-w
     and centred, so its clear space is (card width - --pcard-w)/2, and the
     card's width comes from --pg-col in a gallery or --pcard-card-max
     elsewhere. Keeping this small while the card stays wide is exactly what
     lets the chrome hug the edge and the rotating panel float inside it. */
  --pcard-frame-pad: 16px;
  /* Space above and below the rotating panel specifically, on top of the
     frame's own 14px flex gap. The viewfinder brackets are calculated from
     this too, so they keep their spacing from the panel when it moves. */
  --pcard-panel-gap: 1rem;
  /* Corner cut. Two values because the frame is much the larger box and wants
     the larger slice; one figure for both makes the panel look over-cut.
     Set either to 0 for a square corner. Going back to ROUNDED is not a token
     change, since the two shapes use different properties: restore
     border-radius and delete the clip-path. */
  --pcard-chamfer: 12px;
  --pcard-chamfer-panel: 12px;
  /* The frame's rule. It cannot be a `border`: clip-path cuts a border away on
     the four DIAGONALS while leaving it on the straights, which reads as a
     broken outline. Drawn instead as a coloured layer behind an inset white
     one, see .pcard-media. */
  --pcard-frame-rule: 2px;
  --pcard-frame-rule-ink: #bbb;
  /* The mini case sits over the front, so its rule is a step darker: the same
     grey would read as lighter against a full panel of text than it does
     around a picture. */
  --pcard-frame-rule-ink-open: #999;
}
.pcard--landscape {
  --pcard-w: 400px;
  --pcard-h: 300px;
  --pcard-ratio: 4 / 3;
}
.pcard--portrait {
  --pcard-w: 300px;
  --pcard-h: 400px;
  --pcard-ratio: 3 / 4;
}
/* Width is capped, not fixed, and the height follows from the ratio. A fixed
   width made the card's minimum 432px (400 panel + 32 frame padding), so two
   per row needed 896px and any narrower column overflowed, which is what put a
   horizontal scrollbar on the reference page. Capping lets the panel shrink
   with its column while keeping its proportion exactly. */
.pcard-panel {
  width: min(var(--pcard-w), 100%);
  aspect-ratio: var(--pcard-ratio);
  height: auto;
  /* the frame's flex gap already separates the three rows; this adds a little
     more around the rotating panel specifically, so it is not tight against
     the label row above or the dots row below */
  margin-block: var(--pcard-panel-gap, 0.5rem);
}
.pcard-panel-inner {
  position: relative;
  width: 100%;
  height: 100%;
  perspective: 1500px;
  animation: pcardBlur var(--pcard-duration) linear infinite
}
.pcard-panel-spin {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  animation: pcardSpin var(--pcard-duration) linear infinite
}
.pcard-face {
  position: absolute;
  inset: 0;
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
  overflow: hidden;
  /* Chamfered to match the frame. */
  border-radius: 0;
  clip-path: polygon(
    var(--pcard-chamfer-panel) 0,
    calc(100% - var(--pcard-chamfer-panel)) 0,
    100% var(--pcard-chamfer-panel),
    100% calc(100% - var(--pcard-chamfer-panel)),
    calc(100% - var(--pcard-chamfer-panel)) 100%,
    var(--pcard-chamfer-panel) 100%,
    0 calc(100% - var(--pcard-chamfer-panel)),
    0 var(--pcard-chamfer-panel)
  );
  /* Solid, so the opposite face cannot show through at all. This used to be a
     translucent tint over a backdrop-filter blur; both the transparency and
     the blur are gone, since an opaque background covers the blurred backdrop
     completely and the filter was then pure per-frame cost for no pixels.

     Note for anyone reinstating a blur here: backdrop-filter only affects what
     sits BEHIND this element, never its own children, so it cannot soften the
     <img>, whatever the format. Blurring the picture itself was tried and
     rejected; it reads as a fault, not an effect. */
  background: rgb(200, 200, 200);
  /* The face is a visible tinted panel now, so the shadow traces IT rather
     than the picture. It has to be a box-shadow on the face, not a
     drop-shadow on the <img>: the img is exactly face-sized, so its shadow
     falls outside the face and gets cut by the overflow:hidden above,
     which is why a full-bleed image showed no shadow at all, while a
     letterboxed one still showed the side edges. An element's own
     box-shadow is not clipped by its own overflow. */
  /* The shadow has to be a FILTER here, not the box-shadow it used to be.
     clip-path cuts a box-shadow away with the background, so the chamfered
     panel lost its shadow entirely and read flat. filter: drop-shadow traces
     the CLIPPED silhouette instead, so the shadow follows the octagon and the
     panel floats again as it turns.

     It is applied on the face itself, which is safe: the face is a leaf in the
     3D tree, so the filter does not flatten anything, and backface-visibility
     keeps working. The ancestors could NOT have taken it, since
     .pcard-panel-spin must keep preserve-3d and .pcard-panel-inner's filter is
     already driven by the pcardBlur keyframe.

     Values are --nd-shadow restated for drop-shadow, which has no spread
     parameter: the -26px spread that tightened the box-shadow is folded into a
     shorter offset and blur instead. */
  filter: drop-shadow(0 8px 14px rgba(28, 29, 50, .30))
          drop-shadow(0 1px 2px rgba(28, 29, 50, .10))
}
/* With the face transparent, a box-shadow on the face would trace the full
   square even where the image doesn't reach, a shadow floating around
   nothing. drop-shadow follows the PAINTED pixels instead, so it hugs the
   real edges of a non-square image. */
.pcard-face img {
  width: 100%;
  height: 100%;
  object-fit: contain;   /* undistorted, uncropped; object-position default (center) keeps it centred both axes */
  display: block;
  -webkit-user-drag: none;   /* stop the browser's native "drag this image" gesture fighting our pointer drag */
  user-select: none
}
.pcard-face--b {
  transform: rotateY(180deg)
}
/* Viewfinder overlay: four L-shaped corner brackets plus a centre ring,
   shown on hover (press-and-hold on touch) over the white frame, matching
   the JS-driven pause in combined.js. Pure CSS, unlike the pause ramp,
   this fade doesn't need to stay in sync with the rotation, so no JS
   involvement is needed.
   Each bracket is one box carrying only TWO borders, so the corner it forms
   points outward at its own corner of the frame. The overlay sits on the
   frame, not the rotating panel, so it stays square-on while the image
   turns behind it.
   --pcard-mark-top clears a TWO-LINE client name: frame padding + two
   lines of 1rem/1.2 label + 0.5rem. A name that wraps to three lines on a
   narrow screen needs this retuned, which is why it's a variable. */
.pcard-finder {
  --pcard-mark: 50px;      /* arm length */
  --pcard-mark-w: 5px;     /* line thickness */
  --pcard-dial: 100px;     /* centre ring diameter */
  --pcard-mark-top: calc(var(--pcard-frame-pad, 16px) + 2.4rem + var(--pcard-panel-gap, 0.5rem));
  /* mirrors the top: clears the dots row now that it's inside the frame */
  --pcard-mark-bottom: calc(var(--pcard-frame-pad, 16px) + 1.5rem + var(--pcard-panel-gap, 0.5rem));
  position: absolute;
  inset: 0;
  pointer-events: none;
  opacity: 0;
  transition: opacity .3s var(--nd-ease)
}
.pcard-stage:hover .pcard-finder,
.pcard-stage:active .pcard-finder {
  opacity: 1;
  /* Blink 0.5s on, 0.5s off, like a viewfinder indicator. steps(1) makes each
     change instant rather than a fade, so it reads as a lamp not a pulse.

     NAMING IS LOAD-BEARING: this keyframe must NOT start with "pcard". The
     drag and hover-pause logic in combined.js collects every animation whose
     name starts with that prefix and scrubs its playbackRate and currentTime,
     so a pcard-prefixed blink would be ramped to a standstill the moment you
     hover, which is exactly when it is supposed to run. */
  animation: ndFinderBlink 1s steps(1) infinite;
}

@keyframes ndFinderBlink {
  0%, 50%      { opacity: 1 }
  50.01%, 100% { opacity: 0 }
}
.pcard-finder-corner {
  position: absolute;
  width: var(--pcard-mark);
  height: var(--pcard-mark);
  opacity: .4
}
.pcard-finder-corner--tl,
.pcard-finder-corner--tr {
  top: var(--pcard-mark-top)
}
.pcard-finder-corner--bl,
.pcard-finder-corner--br {
  bottom: var(--pcard-mark-bottom)
}
.pcard-finder-corner--tl,
.pcard-finder-corner--bl {
  left: var(--pcard-frame-pad, 16px)
}
.pcard-finder-corner--tr,
.pcard-finder-corner--br {
  right: var(--pcard-frame-pad, 16px)
}
.pcard-finder-corner--tl {
  border-top: var(--pcard-mark-w) solid #000;
  border-left: var(--pcard-mark-w) solid #000
}
.pcard-finder-corner--tr {
  border-top: var(--pcard-mark-w) solid #000;
  border-right: var(--pcard-mark-w) solid #000
}
.pcard-finder-corner--bl {
  border-bottom: var(--pcard-mark-w) solid #000;
  border-left: var(--pcard-mark-w) solid #000
}
.pcard-finder-corner--br {
  border-bottom: var(--pcard-mark-w) solid #000;
  border-right: var(--pcard-mark-w) solid #000
}
/* Centred between the TOP and BOTTOM brackets rather than on the frame.
   The label row occupies the top of the frame, which pushes the top
   brackets down, so a frame-centred ring sits visibly high of the band the
   brackets describe. 100% here is the frame height, since .pcard-finder
   is inset: 0 on it. */
.pcard-finder-ring {
  position: absolute;
  top: calc(var(--pcard-mark-top) + (100% - var(--pcard-mark-top) - var(--pcard-mark-bottom)) / 2);
  left: 50%;
  width: var(--pcard-dial);
  height: var(--pcard-dial);
  transform: translate(-50%, -50%);
  border: var(--pcard-mark-w) solid #000;
  border-radius: 50%;
  opacity: .4
}
@keyframes pcardSpin {
  from { transform: rotateY(0deg) }
  to   { transform: rotateY(360deg) }
}
@keyframes pcardBlur {
  0%     { filter: blur(0) }
  23.4%  { filter: blur(0) }
  25%    { filter: blur(6px) }   /* edge-on, face A -> face B: window sized so blur only kicks in once the card has narrowed to ~30px (of its 300px full width), not during the primary face-on viewing angles */
  26.6%  { filter: blur(0) }
  73.4%  { filter: blur(0) }
  75%    { filter: blur(6px) }   /* edge-on, face B -> face A */
  76.6%  { filter: blur(0) }
  100%   { filter: blur(0) }
}

/* ---------- 1d · pcard-dots (auto-advancing multi-image sequence) ----------
   Optional indicator row that sits in normal document flow BELOW
   .pcard-stage (rather than absolutely, over the photo).
   Wrap the frame and this row together in .pcard-media. Each dot is
   a <button class="pcard-dot"> holding one data-only <img> (src/alt read
   by combined.js, never rendered: display:none, aria-hidden so screen
   readers don't announce it). combined.js reads every dot present to build
   the full image sequence (A, B, C, D, ...), then advances it automatically
   as the card rotates: every time a face passes edge-on and becomes hidden,
   its image is silently swapped to the next one in the sequence before it
   rotates back into view, so the visible image simply advances
   (A -> B -> C -> D -> A -> ...) using the exact same 2-face rotation
   already in place, with no pause, jump, or masking needed. The active dot
   updates in lockstep, exactly when the visible face changes.
   Four dots (two image pairs) is the sensible default, but the row and the
   script both iterate every .pcard-dot present, so a fifth, sixth, etc.
   is just another dot with one <img> inside. Nothing else changes, and the
   sequence works for any count, not just multiples of two.
   The dots are controls as well as indicators: clicking one FLIPS to that
   image rather than cutting to it, so the motion still reads like autoplay.
   They're <button>s, not spans, so they're keyboard-reachable too.
   The dots row shares the frame's white background and stacks flush against
   it (no gap) so the two read as one continuous card rather than a white box
   floating above a separate row: .pcard-stage only squares off its
   bottom corners when a .pcard-dots row actually follows it, so a frame
   used on its own (no dots) stays fully rounded. */
/* Wraps the white frame, and draws its rule. A chamfered box cannot use
   `border`, because clip-path cuts the border off along the diagonals: this is
   the same reason .card-metric carries no border. So the rule is a coloured
   layer, chamfered, with the white frame inset on top of it by the rule's
   width and chamfered slightly less. The two diagonals then sit parallel and
   the mitre comes out right. */
.pcard-media {
  display: flex;
  flex-direction: column;
  align-items: stretch;   /* lets .pcard-stage fill the available width, same as when used standalone */
  background: var(--pcard-frame-rule-ink);
  padding: var(--pcard-frame-rule);
  /* Must be a filter, not a box-shadow: this element is clipped just below, and
     clip-path cuts a box-shadow away with the background. drop-shadow traces
     the clipped octagon instead. Safe here despite the 3D subtree inside:
     verified that .pcard-panel-spin keeps preserve-3d and backface-visibility
     keeps working with this applied. */
  filter: drop-shadow(0 6px 14px rgba(28, 29, 50, .16))
          drop-shadow(0 1px 2px rgba(28, 29, 50, .08));
  clip-path: polygon(
    var(--pcard-chamfer) 0,
    calc(100% - var(--pcard-chamfer)) 0,
    100% var(--pcard-chamfer),
    100% calc(100% - var(--pcard-chamfer)),
    calc(100% - var(--pcard-chamfer)) 100%,
    var(--pcard-chamfer) 100%,
    0 calc(100% - var(--pcard-chamfer)),
    0 var(--pcard-chamfer)
  );
}
/* The dots row sits INSIDE .pcard-stage as its last row, so the card is
   one continuous white surface. It used to be an opaque white sibling
   underneath, which painted over the rotating panel's shadow and cut it
   off. Transparent and unpadded here: the frame's own padding and gap do
   the spacing, and the shadow falls on white with room to breathe. */
.pcard-dots {
  background: none;
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 7px;
  width: 100%;
  min-height: 1.5rem;
  position: relative;   /* anchors the readouts without shifting the dots */
}

/* Live instrument readouts, a nod to the data panel in an SLR viewfinder,
   sitting at either end of the dots row: elapsed time on the left, angle on
   the right. Both are absolutely positioned so the dots stay optically
   centred no matter how wide the numbers get.
   combined.js fills them from the spin animation's own currentTime, so they
   track whatever drives the rotation: counting on during autoplay, holding
   still while hover pauses it, and running backwards if you drag left.
   tabular-nums plus zero-padding keeps the width fixed. Without both, the
   digits jitter as they change and a live counter looks broken. */
.pcard-angle,
.pcard-time {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  font-size: var(--nd-card-text);
  letter-spacing: .08em;
  font-variant-numeric: tabular-nums;
  color: var(--nd-muted);
  opacity: .55;
  pointer-events: none;
  transition: opacity .3s var(--nd-ease)
}
/* Anchored to the dots row's own edges, NOT re-inset by the frame padding:
   the row is already a full-width child of the padded frame, so its edges
   coincide with the label row's above. Adding the padding again here would
   push each readout 16px further in than the name it lines up with. */
.pcard-time {
  left: 0
}
.pcard-angle {
  right: 0
}
/* brought forward while the card is held, since that's when you're reading them */
.pcard-media:hover .pcard-angle,
.pcard-media:hover .pcard-time {
  opacity: 1
}
.pcard-dot {
  display: block;
  width: 1rem;
  height: 1rem;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--nd-muted);
  opacity: 0.5;
  cursor: pointer;
  overflow: hidden;   /* the data-only <img> inside never shows */
  transition: opacity .2s ease, background .2s ease, width .2s ease, border-radius .2s ease
}
.pcard-dot:hover {
  opacity: 0.8;
}
.pcard-dot img {
  display: none
}
.pcard-dot[aria-current="true"] {
  background: var(--nd-indicator);
  opacity: 1;
  width: 2.25rem;
  border-radius: 1rem
}

/* ---------- 1e · pcard (pcard-panel gallery as a two-state .card) ----------
   Melds pcard-media with the .card system: the gallery becomes the
   card's front state, carrying the same primary elements a .card-fade front
   shows: client name and project/deliverable name pinned over the top
   corners of the white frame (like .card-client-pin elsewhere), a hook
   sentence below the dots row, and a "Quick read" link below that.

   Second state: put .card .card-fade on the wrapper alongside
   .pcard and the EXISTING card engine drives it, no new JS. The
   fade+blur .card-panel and its .card-open trigger behave exactly as they
   do on any other .card-fade, so the mini case reuses .card-detail,
   .card-metrics, .card-tags and .card-meta unchanged. Everything the
   panel covers (gallery + hook) goes inside .card-stage; the footer with
   the trigger stays outside it so it's visible in both states, matching
   how .card-fade is structured elsewhere. Repeating .pcard-client /
   .pcard-project inside the panel keeps those two labels in exactly
   the same spot across both states: the panel is inset:0 on the stage and
   the frame is the stage's first child, so panel top == frame top, and the
   same top:12px offset lands identically either way. They sit as direct
   children of .card-panel, NOT inside .card-detail, so they stay pinned
   while the detail scrolls under them.

   Unlike a standard .card (fixed --nd-card-w), this fills its container up
   to --pcard-card-max, so it can sit in a page-grid cell of any width. */
.pcard {
  --pcard-card-max: 500px;
  /* Frame fill on hover, and the ink that has to stay legible on it. Set
     both per card to give each project its own brand colour:
       <div class="card card-fade pcard" style="--pcard-hover:#0019a5; --pcard-hover-ink:#fff">
     The ink token exists because the labels, the live readouts and the
     viewfinder brackets are all dark by default, tuned for a white frame.
     A dark brand colour swallows every one of them, so the pairing has to
     move together. */
  --pcard-hover: var(--nd-lemon);
  --pcard-hover-ink: var(--nd-ink);
  margin-bottom: 2rem;
  width: 100%;
  max-width: var(--pcard-card-max);
  display: flex;
  flex-direction: column;
  gap: 10px;
  font-family: var(--nd-font);
  color: var(--nd-ink);
}
/* the panel covers the gallery AND the hook, so the stage has to stack them
   the same way .pcard itself would have */
.pcard .card-stage {
  display: flex;
  flex-direction: column;
  gap: 10px;
}
/* .card-stage's first child is normally .card-media, which the shared rule
   forces to a square, filled box. Here that child is the gallery, which
   sizes itself from the rotating card inside it, so undo the square.

   The background is NOT set back to none any more: .pcard-media now draws the
   frame's rule, and this selector is (0,3,0) against that rule's (0,1,0), so
   `none` here would silently win and the rule would never appear. Restate the
   token instead of clearing it. */
.pcard .card-stage > .pcard-media {
  aspect-ratio: auto;
  background: var(--pcard-frame-rule-ink);
}
/* The mini case is given the same treatment as the front frame, and it happens
   to have the identical two-layer structure to work with: .card-panel wraps
   .card-detail, which fills it exactly, and both have zero padding. So the
   panel becomes the rule layer and the detail the white one, exactly as
   .pcard-media and .pcard-stage do on the front. */
.pcard .card-panel {
  border-radius: 0;
  background: var(--pcard-frame-rule-ink-open);
  padding: var(--pcard-frame-rule);
  clip-path: polygon(
    var(--pcard-chamfer) 0,
    calc(100% - var(--pcard-chamfer)) 0,
    100% var(--pcard-chamfer),
    100% calc(100% - var(--pcard-chamfer)),
    calc(100% - var(--pcard-chamfer)) 100%,
    var(--pcard-chamfer) 100%,
    0 calc(100% - var(--pcard-chamfer)),
    0 var(--pcard-chamfer)
  );
}
.pcard .card-detail {
  border-radius: 0;
  background: var(--nd-surface);
  --pcard-chamfer-inner: calc(var(--pcard-chamfer) - var(--pcard-frame-rule));
  clip-path: polygon(
    var(--pcard-chamfer-inner) 0,
    calc(100% - var(--pcard-chamfer-inner)) 0,
    100% var(--pcard-chamfer-inner),
    100% calc(100% - var(--pcard-chamfer-inner)),
    calc(100% - var(--pcard-chamfer-inner)) 100%,
    var(--pcard-chamfer-inner) 100%,
    0 calc(100% - var(--pcard-chamfer-inner)),
    0 var(--pcard-chamfer-inner)
  );
}

/* The front stack stays painted behind the open mini case, at full opacity, so
   whatever sits under the panel's chamfered corners shows through the cut. Hide
   it once the fade has landed.

   EVERY front child is hidden, not just .pcard-media. .card-hook is a sibling
   inside .card-stage, and since the panel is 96px taller than the frame, the
   hook sits directly behind the panel's BOTTOM corners: its third line was
   visible through the cut. Padding would not have helped, because padding moves
   the panel's own content inward while the cut still exposes whatever is
   behind. Selecting everything except the panel also covers anything added to
   the stage later.

   The delay is the point. Hiding immediately would blank the front while the
   panel is still fading in, which reads as a flash; `visibility` is not
   interpolable, so a 0s transition DELAYED by the fade duration flips it only
   at the end. On close the delay is removed, so the front reappears at once and
   the fading panel reveals it. */
.pcard.card-is-open .card-stage > :not(.card-panel) {
  visibility: hidden;
  transition: visibility 0s linear .5s;
}
.pcard .card-stage > :not(.card-panel) {
  transition: visibility 0s linear 0s;
}

/* The panel's shadow has to JOIN the existing filter rather than be its own
   declaration: .card-fade .card-panel already animates filter: blur() to drive
   the fade, and a second filter property would simply replace it and kill the
   open/close transition. Both states below therefore carry the drop-shadow
   alongside the blur, and the transition interpolates the pair. */
.pcard.card-fade .card-panel {
  filter: blur(18px) drop-shadow(0 6px 14px rgba(28, 29, 50, .16));
}
.pcard.card-fade.card-is-open .card-panel {
  filter: blur(0) drop-shadow(0 6px 14px rgba(28, 29, 50, .16));
}

/* Land the client/project row in the SAME spot in both states. The front
   state's row sits inside .pcard-stage, which pads a uniform
   --pcard-frame-pad; the mini case's row sits inside .card-detail-body,
   which normally pads 16px/18px/18px with a 12px top. Reusing the frame's
   own padding value here keeps the two rows pixel-aligned, and keeps them
   aligned if that value is ever retuned.
   Needs .card-fade in the selector to outrank the generic
   `.card-fade .card-panel .card-detail-body { padding-top: 12px }` that sits
   further down the file at equal specificity. */
.pcard.card-fade .card-panel .card-detail-body {
  padding: var(--pcard-frame-pad, 16px);
}
/* Client and project sit in a REAL FLOW row rather than being absolutely
   positioned. Absolute labels can't push anything down, so a long name
   wrapping to two lines would silently overlap whatever sits beneath it,
   which is exactly what happens on a narrow screen with something like
   "Universal Music Group". In flow, a wrap grows the row and everything
   below moves down instead.
   Each label is capped at half the row, so the two can never collide: worst
   case each wraps inside its own half. min-width: 0 lets that wrapping
   actually happen, since a flex item otherwise refuses to shrink below its
   longest word. The same row is used in both states, which is what keeps
   the two labels aligned across the flip. */
.pcard-labels {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  width: 100%;
}
.pcard-client,
.pcard-project {
  flex: 0 1 50%;
  min-width: 0;
  margin: 0;
  font-size: var(--nd-card-text);
  text-transform: uppercase;
  overflow-wrap: break-word;
}
.pcard-client {
  letter-spacing: .14em;
  font-weight: 600;
  color: #000;
}
.pcard-project {
  letter-spacing: .08em;
  font-weight: 500;
  color: rgba(0, 0, 0, .55);
  text-align: right;
}
/* inside the frame the labels sit on the drag surface, so stop a drag
   turning into a text selection */
.pcard-stage .pcard-labels {
  user-select: none;
}
.pcard-footer {
  display: flex;
  justify-content: flex-start;
}

/* ---------- reduced motion ---------- */
@media (prefers-reduced-motion: reduce) {
  .card-fade .card-panel {
    transition: opacity .3s ease;
    transform: none;
    filter: none;
    opacity: 0;
    pointer-events: none
  }
 
  .card-fade.card-is-open .card-panel {
    opacity: 1;
    pointer-events: auto
  }
 
  .card-fade .card-footer .card-cta {
    filter: none;
    transform: none
  }
 
  .pcard-panel-inner,
  .pcard-panel-spin {
    animation: none
  }

  .pcard-face--b {
    display: none
  }

  .pcard-stage:hover .pcard-finder,
  .pcard-stage:active .pcard-finder {
    animation: none;
    opacity: 1
  }
}
 
/* ============================================================================
   SECTION EDGES, a diagnostic aid. Draws a dashed rule at the foot of every
   section so you can see where one ends and the next begins, which is what
   the page-nav segments are sized from. Turn it on by adding .nd-section-edges
   to the body (or any ancestor); off by default, so pasting this into Fluent
   Snippets adds nothing visible to the live site.
   ============================================================================ */
.nd-section-edges section[id],
.nd-guide section[id] {
  /* A full border rather than a foot rule, and alternating colours, because on
     a long page a single rule between two tall sections does not tell you which
     one you are looking at. Alternating removes the ambiguity: the boundary is
     wherever the colour changes.

     nth-of-type is safe here: it counts sections among their own siblings, and
     the nested .cl-section blocks carry no id so they are not matched at all. */
  border: 5px dashed var(--nd-line-2);
  /* No top or left padding: the left edge is the one everything on the page
     aligns to, including the header's lede, so a 2rem inset there would put the
     content off the line the border draws. The right and bottom keep theirs,
     since nothing aligns to those. */
  padding: 0 2rem 2rem 0;
}
.nd-section-edges section[id]:nth-of-type(even),
.nd-guide section[id]:nth-of-type(even) {
  border-color: var(--nd-ink);
}

/* .nd-guide is the friendly one-word toggle: add it to the BODY in Bricks (Style,
   CSS classes) and it shows the page shell's magenta edges plus every section's
   box at once. Works on the live site as well as local, since it is pure CSS.
   Remove the class to ship. .nd-shell-edges and .nd-section-edges remain as the
   individual toggles. */

/* ----------------------------------------------------------------------------
   TYPEFACE ASSIGNMENT
   Headings use the display face, running copy the serif, and the card's
   instruments stay on the sans so they keep reading as UI rather than prose.
   Kept in one block so the split is visible in one place rather than scattered
   through every component.
   ---------------------------------------------------------------------------- */
h1, h2, h3,
.cs-title {
  font-family: var(--nd-font-display);
  font-weight: 400;   /* Metal ships one weight; asking for 700 would synthesise */
  letter-spacing: 0;  /* the display face is already tight; Inter's -0.01em over-tightens it */
}

/* Running copy, on the case study page and in the card's mini case. FACE ONLY:
   several of these set their own size, and overriding it here shrank them. The
   hook in particular went from 1.5rem to 1rem, which is the opposite of what a
   serif needs. */
.cs-lead, .cs-body, .cs-bullets li, .cs-caption, .cs-stat dt,
.card-hook,
.card-detail-body p,
.card-sentences, .card-sentences li,
.card-meta, .card-meta-row, .card-meta-row b,
.card-chip,
.card-metric b, .card-metric span {
  font-family: var(--nd-font-body);
}

/* The serif compensation is applied only where the size comes from
   --nd-card-text, so each element keeps its own scale and simply gains the
   x-height correction. */
.card-detail-body p,
.card-sentences, .card-sentences li,
.card-meta, .card-meta-row, .card-meta-row b,
.card-chip,
.card-metric b, .card-metric span {
}

/* The instruments stay on the sans: these are readouts and labels, not prose,
   and a serif at 12px with letter-spacing reads as a mistake. */
.pcard-client, .pcard-project, .pcard-time, .pcard-angle, .pcard-dot,
.cs-eyebrow, .cs-stat dd {
  font-family: var(--nd-font);
}

/* ---------- focus (scoped to the card) ---------- */
.card :focus-visible {
  outline: 2px solid var(--nd-accent);
  outline-offset: 2px;
  border-radius: 6px
}
 
/* ----------------------------------------------------------------------------
   OPTIONAL: fluid width for builder columns.
   Uncomment to let the card fill its column and keep the image square.
   ----------------------------------------------------------------------------
.card-fade{width:100%;max-width:420px}
.card-fade .card-media{height:auto;aspect-ratio:1/1;flex:0 0 auto}
*/
/* ============================================================================
   ADDITIONS (this thread): media fill, persistent client, "case coming" CTA,
   and the fade + blur media slider.
   ============================================================================ */
 
/* ---------- persistent client label: one place, shown in both states ---------- */
.card-client-pin {
  position: absolute;
  top: 12px;
  left: 18px;
  z-index: 5;
  margin: 0;
  padding: 0;
  background: none;
  border: 0;
  color: #000;
  text-shadow: 0 1px 2px rgba(255, 255, 255, .45);  /* keeps it legible over photos; remove if unwanted */
  pointer-events: none;
}

/* deliverable: a second line under the client name, e.g. "Software" */
.card-deliverable {
  display: block;
  font-size: var(--nd-card-text);
  font-weight: 500;
  color: rgba(0, 0, 0, 0.55);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  margin-top: 5px;
  text-shadow: 0 1px 2px rgba(255, 255, 255, .45);
}

/* the SAME client + deliverable info, repeated as real, scrollable content,
   sits at the top of the mini case detail once the panel opens. This is what
   replaces the old fixed/pinned label: it now scrolls with everything else. */
.card-detail-header { margin-bottom: 2px; }
.card-detail-header p.card-detail-client {
  font-size: var(--nd-card-text);
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--nd-ink);
  margin: 0;
}
.card-detail-header p.card-detail-deliverable {
  font-size: var(--nd-card-text);
  color: rgba(0, 0, 0, 0.55);
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  margin: 5px 0 0;
}
 
/* top padding matches the pin's top:12px exactly, so the client/deliverable
   text sits in the identical position whether pinned (closed) or scrollable
   (open). Switching states shouldn't visibly move this text at all */
.card-fade .card-panel .card-detail-body {
  padding-top: 12px;
}
 
/* ---------- "Case is coming" CTA (status, not a link) ---------- */
.card-cta-wait {
  background: transparent;
  color: var(--nd-muted);
  border: 1px solid var(--nd-line-2);
  border-radius: 10px;
  padding: 9px 15px;
  font: inherit;
  font-weight: 600;
  font-size: var(--nd-card-text);
  display: inline-flex;
  align-items: center;
  gap: 6px;
  cursor: default;
}
 
/* same blur-fade-in entrance as .card-cta on the fade card */
.card-fade .card-footer .card-cta-wait {
  opacity: 0;
  filter: blur(8px);
  transform: translateY(3px);
  visibility: hidden;
  transition: opacity .4s ease, filter .4s ease, transform .4s ease, visibility 0s linear .4s;
}
 
.card-fade.card-is-open .card-footer .card-cta-wait {
  opacity: 1;
  filter: blur(0);
  transform: none;
  visibility: visible;
  transition: opacity .4s ease .28s, filter .4s ease .28s, transform .4s ease .28s, visibility 0s;
}
 


/* ============================================================================
   PAGE SUB-NAV + SCROLLSPY  (JS toggles .is-active; this styles it)
   ============================================================================ */
.page-nav {
  position: fixed;
  top: 2rem;
  left: 2rem;
  z-index: 10;
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  padding: 0.75rem;
  font-family: var(--nd-font);
  /* background: var(--nd-surface);
  border: 1px solid var(--nd-line-2);
  border-radius: var(--nd-radius-md);
  box-shadow: var(--nd-shadow-sm); */
}

.page-nav-link {
  position: relative;
  padding: 0.4rem 0.75rem 0.4rem 2rem;
  font-size: var(--nd-fs-ui);
  text-decoration: none;
  color: var(--nd-muted);
  border-radius: 8px;
  transition: color 0.2s ease, background 0.2s ease;
}
.page-nav-link > a {
   color: black !important;
   text-decoration: none !important;
   /* border-top: 1px solid orange; */
   padding-left: 0rem;
   transition: background 0.2s ease, transform 0.2s ease;
 }
.page-nav-link:hover > a {
   color: var(--nd-blue) !important;
   text-decoration: underline;
 }
.page-nav-link::before {
  content: "";
  position: absolute;
  left: 0.4rem;
  top: 50%;
  width: 1rem;
  height: 3px;
  margin-top: -1.5px;
  /* border-radius: 50%; */
  background: var(--nd-line-2);
  transition: background 0.2s ease, transform 0.2s ease;
}
.page-nav-link:hover::before { background: var(--nd-blue); }

.page-nav-link:hover { color: var(--nd-ink); }

.page-nav-link.is-active {
  color: var(--nd-ink);
  /* background: var(--nd-hover); */
  font-weight: 600;
}
/* The active state, driven by the IntersectionObserver */
 .page-nav-link.is-active > a {
   color: var(--nd-blue) !important;
   /* text-decoration-line: line-through; */
   font-weight: 600;
 }

.page-nav-link.is-active::before {
  background: var(--nd-accent);
  background: var(--nd-blue);
  left: 0.4rem;
  top: 1rem;
  width: 1rem;
  height: 0.6rem;
  margin-top: -3px;
  transform: scale(1.35);
}
/* .page-nav-link.is-active::before ,*/
 .page-nav-link.is-active > a::before {
   content: "";
   position: relative;
   left: -0.5rem;
   top: 0rem;
   width: 1.5rem !important;
   height: 0.9rem;
   /* margin-top: -1px; */
   border-radius: 5%;
   background-color: var(--nd-blue);
   transition: background 0.2s ease, transform 0.2s ease;
   /* transform: scale(1.35); */
 }

/* smaller screens: collapse to indicator-only: hides the text label, keeps
   the little line/mark. The <a> itself is expanded to a full 44px tap target
   (not shrunk to fit the thin marker), so both active and inactive indicators
   stay easy to hit with a finger even though they look thin. */
@media (max-width: 720px) {
  .page-nav-link {
    position: relative;
    min-width: 2.75rem;     /* ~44px, comfortable touch target */
    min-height: 2.75rem;
    padding: 0;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .page-nav-link > a {
    position: absolute;
    inset: 0;                /* fills the whole 44px box: this is the real tap area */
    display: block;
  }
  .page-nav-link > a span {
    /* visually hidden, still reachable by screen readers: only the SPAN
       is hidden here, not the <a>, so the tap target stays full-size */
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
  }
}








/* ============================================================================
   ND CONTACT  (mailto + copy, full pill + compact dark footer variant)
   ============================================================================ */
.nd-contact {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  font-family: var(--nd-font);
}

.nd-contact__btn {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 10px 16px;
  border: 0;
  border-radius: var(--nd-radius-pill);
  background: var(--nd-accent);
  color: var(--nd-on-accent);
  font: inherit;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.2s ease, box-shadow 0.2s ease;
}
.nd-contact__btn:hover { background: var(--nd-accent-hover); box-shadow: 0 8px 18px -8px rgba(var(--nd-blue-rgb), 0.45); }
.nd-contact__btn svg { width: 16px; height: 16px; }

.nd-contact__copy {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  border: 1px solid var(--nd-line);
  border-radius: var(--nd-radius-sm);
  background: var(--nd-surface);
  color: var(--nd-muted);
  cursor: pointer;
  transition: color 0.2s ease, border-color 0.2s ease;
}
.nd-contact__copy:hover { color: var(--nd-ink); border-color: var(--nd-ink); }
.nd-contact__copy svg { width: 16px; height: 16px; }

.nd-contact__copy .nd-check { display: none; color: var(--nd-accent); }
.nd-contact__copy.is-copied .nd-copy { display: none; }
.nd-contact__copy.is-copied .nd-check { display: block; }

.nd-contact__toast {
  position: absolute;
  left: 0;
  bottom: calc(100% + 8px);
  padding: 6px 10px;
  border-radius: 8px;
  background: var(--nd-ink);
  color: #fff;
  white-space: nowrap;
  opacity: 0;
  transform: translateY(4px);
  pointer-events: none;
  transition: opacity 0.2s ease, transform 0.2s ease;
}
.nd-contact__toast.is-visible { opacity: 1; transform: translateY(0); }

/* compact footer variant: icon-only circles */
.nd-contact--compact .nd-contact__btn {
  width: 40px;
  height: 40px;
  padding: 0;
  border-radius: 50%;
  justify-content: center;
}
.nd-contact--compact .nd-contact__btn span { display: none; }
.nd-contact--compact .nd-contact__copy { border-radius: 50%; }

/* dark-footer tone: light-on-dark, scoped to this subtree only */
.nd-contact[data-tone="dark"] {
  --nd-ink: #f4f4f6;
  --nd-muted: #b9bccb;
  --nd-line: rgba(255, 255, 255, 0.22);
  --nd-surface: rgba(255, 255, 255, 0.06);
}
.nd-contact[data-tone="dark"] .nd-contact__toast { background: #f4f4f6; color: #1c1d24; }

/* ============================================================================
   CARD METRICS, CHIPS AND META ROWS
   ============================================================================ */

/* --- optional: transparent card, fades to white 90% on hover (comment out to disable) --- */
.card-shell { background: transparent; transition: background 0.35s ease; }
.card-footer { background: transparent; }
.card:hover .card-shell { background: rgba(255, 255, 255, 0.9); }


/* long meta values (e.g. Team) wrap tidily on the right */
.card-meta-row { gap: 12px; align-items: baseline; }
.card-meta-row b { text-align: right; }

/* --- optional: card rounded corners + shadow (comment out this block for flat, square cards) --- */
.card-shell { border-radius: var(--nd-radius); box-shadow: var(--nd-shadow); }


/* case-study button: arrow slides on hover, like the text link */
.card-cta svg { transition: transform 0.3s var(--nd-ease); }
.card-cta:hover svg { transform: translateX(3px); }








/* ============================================================================
   HERO CARD: wide, twin-square layout above the breakpoint.
   Add .card--hero to the outer .card. Structure identical to every other card;
   only CSS changes the layout above 840px. Below that, all hero rules turn off
   and the card behaves exactly like a standard card.
   ============================================================================ */

@media (min-width: 840px) {

  /* Card size: two squares side by side. Media keeps its 1:1 square, so a
     400px square media pairs with a 400px-tall text column = 800px wide card. */
  .card.card--hero { width: 800px; max-width: 100%; }

  /* Stage becomes a two-column grid: LEFT text | RIGHT media (square). */
  .card--hero .card-stage {
    display: grid;
    grid-template-columns: 1fr 1fr;
    grid-template-areas:
      "client media"
      "hook   media";
    align-items: stretch;
  }

  /* Media stays SQUARE: it governs the card height. */
  .card--hero .card-media {
    grid-area: media;
    aspect-ratio: 1 / 1;
    height: auto;
    align-self: stretch;
  }

  /* Client uses the same persistent top-left position as standard cards
     (see .card-client-pin base rule), no special override needed here now
     that the panel simply covers it once opened. */
  .card--hero .card-front-body { padding-top: 44px; }   /* leaves room for the pinned client in the closed state */

  /* Hook (front-body) fills the rest of the left column. */
  .card--hero .card-front-body {
    grid-area: hook;
    padding: 8px 18px 18px;
    align-self: start;
  }
  /* PANEL: constrained to the LEFT half only, so the mini case fades over
     the text column while the image stays visible on the right. */
  .card--hero .card-panel {
    left: 0;
    right: 50%;
    top: 0;
    bottom: 0;
  }
  .card--hero .card-panel .card-detail-body {
    padding: 12px 18px 18px;
    gap: 12px;
  }
  /* Footer spans the full width beneath both columns. */
  .card--hero .card-footer { padding: 10px 18px; }
}


/* panel keeps the shell's rounded corners so it doesn't square off the top-left
   (or the top-right on non-hero cards) when the mini case is open */
.card-panel {
  border-top-left-radius: var(--nd-radius);
  border-top-right-radius: var(--nd-radius);
}
/* hero corner overrides only apply while the hero layout is active */
@media (min-width: 840px) {
  .card--hero .card-panel {
    border-top-right-radius: 0;      /* hero panel only covers the left half */
    border-bottom-left-radius: 0;
  }
  .card--hero .card-detail { border-top-right-radius: 0; }
}


/* mini-case: tighter spacing between the four sentences (leaves metric/tag/meta gaps alone) */
.card-detail-body p + p { margin-top: -2px; }
.card--hero .card-panel .card-detail-body p + p { margin-top: -2px; }


/* the panel clips to its own rounded corners so scroll content never
   bleeds into the top-left/top-right radii (fixes messy corner on the
   hero card when it reverts to the standard layout at small widths) */
.card-panel { overflow: hidden; }
.card-detail {
  border-top-left-radius: var(--nd-radius);
  border-top-right-radius: var(--nd-radius);
}


/* ============================================================================
   PAGE SCAFFOLDING GRID. General-purpose layout for a builder: lay blocks out
   in halves, thirds and quarters by applying class names, so a Bricks layout
   takes the system's spacing and columns rather than each element setting its
   own. This is the crude grid for roughing out pages and case studies; the
   .pg-* system below is the card gallery specifically.

   In Bricks: apply .nd-grid to a container block, then a fraction class to each
   child block (Bricks' "CSS classes" field takes them):

     .nd-col-1-2  half         .nd-col-1-3  third      .nd-col-1-4  quarter
     .nd-col-2-3  two-thirds   .nd-col-3-4  three-quarters   .nd-col-full  all

   They share one 12-column track, so mix freely: a .nd-col-1-3 beside a
   .nd-col-2-3, two .nd-col-1-2, four .nd-col-1-4, and so on. Below 720px every
   column stacks to full width. Blocks with no fraction class flow normally.

   SECTIONS AND THE IN-PAGE NAV: give each Bricks Section an id and a
   data-nav-label attribute; [nirmtronik_page_nav] then builds its links,
   scrollspy and progress bar from them, and the header-clearance rule already
   keeps a jump to that id from landing under the fixed header. .nd-section is
   an optional even vertical rhythm if you don't want to set section padding in
   Bricks by hand.
   ============================================================================ */
/* The shared content container. Put it on a section's inner wrapper (or set
   Bricks' global content width to --nd-shell-max) and its edges line up with the
   header and footer content. Centred, capped, with the min gutter on narrow
   screens. The 12-col .nd-grid is meant to sit INSIDE one of these. */
.nd-shell {
  width: 100%;
  max-width: var(--nd-shell-max);
  margin-inline: auto;
  padding-inline: var(--nd-shell-pad);
  box-sizing: border-box;
}

/* AUTO-SHELL for Bricks-rendered pages, so sections align to the header and
   footer without hand-adding a class to each one. A Bricks Section's own
   background stays full width; its direct content container (.brxe-container,
   the "section's first child") caps to the shell and centres. Scoped to the
   TOP-LEVEL container of a section or the content root, so nested containers
   inside a section keep their own layout. Our own .cs-section / .cl-section
   wrappers already cap themselves through --cs-wide and are left alone. */
.brx-body #brx-content > .brxe-container,
.brx-body #brx-content > .brxe-section > .brxe-container,
.brx-body #brx-content > section > .brxe-container {
  max-width: none;   /* full width; the content root's 4% margin does the insetting */
  margin-inline: 0;
  padding-inline: 0;
  box-sizing: border-box;
}

/* Reserve the nav gutter on the content root, so every section centres in the
   LEFT content zone and the fixed in-page nav in the right gutter never overlays
   it. Marquees pin to the viewport in JS (.nd-bleed), so they still bleed full
   width through this padding. Zeroed below 720px, where the nav collapses to a
   thin left-edge strip and the content wants the full width. */
/* The page margin on the content root, so every section sits inside the same 4%
   left/right margin as the header and footer. Marquees pin to the viewport in JS
   (.nd-bleed), so they bleed full width through this padding. */
.brx-body #brx-content,
.nd-main {
  padding-inline: var(--nd-page-pad);
}

/* Diagnostic: draw the shell's two edges as magenta lines over the whole page,
   so you can SEE whether the header, sections and footer all align to them and
   where the in-page nav sits relative to the right edge. Add .nd-shell-edges to
   the body (in Bricks too), remove it to ship. Fixed and pointer-transparent, so
   it never affects layout or clicks. Pairs with .nd-grid--guide (the column
   blocks) and .nd-section-edges. */
.nd-shell-edges::before,
.nd-guide::before {
  content: '';
  position: fixed;
  top: 0;
  bottom: 0;
  left: var(--nd-page-pad);    /* the page's 4% margins */
  right: var(--nd-page-pad);
  border-inline: 2px dashed #ff2ea6;
  pointer-events: none;
  z-index: 3000;
}
.nd-shell-edges::after,
.nd-guide::after {
  content: 'page margin';
  position: fixed;
  top: 6px;
  left: var(--nd-page-pad);
  transform: translateX(4px);
  font: 600 11px/1 ui-monospace, Menlo, monospace;
  color: #ff2ea6;
  pointer-events: none;
  z-index: 3000;
}

/* CONFIGURABLE GRID. Four independent knobs, each a custom property so a
   single instance can override any of them (inline style, or a Bricks control
   writing the same property) without a modifier class:
     --nd-grid-cols    how many columns (default 12)
     --nd-grid-gap     the GUTTER, the gap between columns (default 32px)
     --nd-grid-margin  the grid's OWN left/right inset from ITS box edge,
                       distinct from the gutter. 0 by default, so a grid sitting
                       inside a normal section just inherits whatever margin the
                       page/section already provides; set it explicitly when the
                       grid stands alone at 100% width (see .nd-bleed below).
     --nd-grid-row-h   row height. auto by default (rows size to content); set a
                       length to make every row that height, which combined with
                       .nd-row-span-* is what turns this into a true 2D grid. */
.nd-grid {
  --nd-grid-cols: 12;
  --nd-grid-gap: 32px;
  --nd-grid-margin: 0px;
  --nd-grid-row-h: auto;
  display: grid;
  grid-template-columns: repeat(var(--nd-grid-cols), 1fr);
  grid-auto-rows: var(--nd-grid-row-h);
  gap: var(--nd-grid-gap);
  padding-inline: var(--nd-grid-margin);
  align-items: start;
  box-sizing: border-box;
}
/* A 1fr track floors at its content's min width, so a wide image or long word
   would blow the fractions out; this lets a column shrink to its share. Same
   guard .pg-row--auto and .cl-grid already carry. */
.nd-grid > * { min-width: 0; }

/* Named fractions, for the common 12-column case; kept as the simple everyday
   API. .nd-col-span-N below is the general form, for any --nd-grid-cols. */
.nd-col-1-2  { grid-column: span 6; }
.nd-col-1-3  { grid-column: span 4; }
.nd-col-2-3  { grid-column: span 8; }
.nd-col-1-4  { grid-column: span 3; }
.nd-col-3-4  { grid-column: span 9; }
.nd-col-full { grid-column: span 12; }

/* GENERAL column span, 1 to 12: works whatever --nd-grid-cols is set to, since
   "span N" means N tracks wide regardless of how many tracks exist. Pick
   whichever reads clearer in the markup, the fraction or the number. */
.nd-col-span-1  { grid-column: span 1; }
.nd-col-span-2  { grid-column: span 2; }
.nd-col-span-3  { grid-column: span 3; }
.nd-col-span-4  { grid-column: span 4; }
.nd-col-span-5  { grid-column: span 5; }
.nd-col-span-6  { grid-column: span 6; }
.nd-col-span-7  { grid-column: span 7; }
.nd-col-span-8  { grid-column: span 8; }
.nd-col-span-9  { grid-column: span 9; }
.nd-col-span-10 { grid-column: span 10; }
.nd-col-span-11 { grid-column: span 11; }
.nd-col-span-12 { grid-column: span 12; }

/* ROW span: how many rows tall an item is. Only does something once the grid
   has real rows to span, i.e. --nd-grid-row-h is set to a length rather than
   auto (or the item's own content is short enough that auto-sized rows still
   read as a single height). This is the piece that makes the grid genuinely
   two-dimensional rather than just column splits. */
.nd-row-span-1 { grid-row: span 1; }
.nd-row-span-2 { grid-row: span 2; }
.nd-row-span-3 { grid-row: span 3; }
.nd-row-span-4 { grid-row: span 4; }

@media (max-width: 720px) {
  .nd-grid > [class*="nd-col-"] { grid-column: span var(--nd-grid-cols); }
  .nd-grid > [class*="nd-row-"] { grid-row: span 1; }
}

/* Grid GUIDE: a 1px magenta outline on each column so the layout is visible
   while building, each carrying its own column-span class as a small label so
   the class to paste into Bricks is visible on the block itself, not just in a
   legend elsewhere. A diagnostic modifier, so add .nd-grid--guide alongside
   .nd-grid (in Bricks too) to see the columns, and remove it to ship. Outline
   not border, so it never changes the box size or shifts the grid; the faint
   fill and min-height make an empty column visible. It follows the same
   responsive stacking as the grid, since it just decorates the same columns. */
.nd-grid--guide > * {
  position: relative;
  outline: 1px solid #ff2ea6;
  outline-offset: -1px;
  background: rgba(255, 46, 166, 0.06);
  min-height: 3rem;
}
.nd-grid--guide > *[class*="nd-col-"]::after {
  content: attr(class);
  position: absolute;
  top: 2px;
  left: 4px;
  font: 600 10px/1.3 ui-monospace, Menlo, monospace;
  color: #ff2ea6;
  pointer-events: none;
}

/* FULL-WIDTH grid: add .nd-bleed (the shared, scrollbar-safe full-bleed utility
   the JS measures) to a .nd-grid and it spans the whole viewport edge to edge,
   escaping the page measure, for a band that runs the full width. --nd-grid-margin
   is what keeps the outer columns off the very edge in that case, since the grid
   is no longer sitting inside a section that provides one; it defaults to the
   gutter's own width so an unset .nd-bleed grid looks exactly as it did before
   this margin control existed. */
.nd-grid.nd-bleed { --nd-grid-margin: var(--nd-grid-gap, 32px); }

.nd-section { padding-block: var(--nd-section-pad, 88px); }


/* ============================================================================
   PAGE GRID: controls card sizing, column splits, and staggered offsets.
   Sits OUTSIDE the card components; cards don't know about it.
   Add these classes to wrapper divs in your builder or Code blocks.

   .pg-grid          - the full-width container (centers, sets max-width + gap)
   .pg-row           - a row of cards; defaults to equal columns
   .pg-row--66-33    - 2:1 split (large left, small right)
   .pg-row--33-66    - 1:2 split (small left, large right)
   .pg-row--50-50    - even halves
   .pg-row--33-33-33 - three equal columns
   .pg-row--auto     - reflows 1 up / 2 up / 3 up on its own, no breakpoints
   .pg-row--40-60    - 40/60 split
   .pg-row--60-40    - 60/40 split
   .pg-offset-sm     - nudge a card DOWN by 40px (staggered baseline)
   .pg-offset-md     - nudge a card DOWN by 80px
   .pg-offset-lg     - nudge a card DOWN by 120px
   .pg-tall           - override media to portrait (3:4)
   .pg-wide           - override media to landscape (16:10)
   .pg-sq             - override media to square (1:1, the default)

   Below 840px everything collapses to a single column, offsets turn off,
   and media reverts to square, orthodox and grid-like, as requested.
   ============================================================================ */

/* The gallery caps at the shared page shell, so a card gallery lines up with the
   section headings and everything else on the page rather than running wider.
   At 1440 the reflowing row fits three full-size cards; it used to spend a
   1984px ceiling for a fourth, which read as inconsistent once the shell gave
   the rest of the page one measure. The 24px padding matches .cs-inner's gutter,
   so the gallery's edges sit under the heading's. */
.pg-grid {
  max-width: none;
  /* 0 top, an inset right so the gallery clears the fixed nav, an inset bottom,
     0 left so it starts on the page margin like the headings. */
  margin: 0 var(--nd-card-inset-r) var(--nd-card-inset-r) 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 48px;
}

.pg-row {
  display: grid;
  gap: 32px;
  align-items: start;               /* staggered offsets work because items don't stretch */
}

/* Fixed-ratio rows keep the 1200px measure they were designed around. Only the
   reflowing gallery spends the grid's wider ceiling, or a 50-50 row would put
   two 400px cards in 896px cells and read as mostly whitespace. */
.pg-row:not(.pg-row--auto) {
  max-width: var(--nd-shell-max);
  margin-inline: auto;
  width: 100%;
}

/* A 1fr track is minmax(auto, 1fr), so its floor is the item's min-content
   width. Cards with a longer client name or hook therefore claimed a wider
   column and the "equal" splits came out visibly unequal once the row got
   tight. min-width: 0 lowers that floor so the declared ratios hold at every
   width, and the panel inside is capped rather than fixed so it follows. */
.pg-row > * {
  min-width: 0;
}

/* column splits: desktop */
@media (min-width: 840px) {
  .pg-row--66-33  { grid-template-columns: 2fr 1fr; }
  .pg-row--33-66  { grid-template-columns: 1fr 2fr; }
  .pg-row--50-50  { grid-template-columns: 1fr 1fr; }
  .pg-row--33-33-33 { grid-template-columns: 1fr 1fr 1fr; }
  .pg-row--40-60  { grid-template-columns: 2fr 3fr; }
  .pg-row--60-40  { grid-template-columns: 3fr 2fr; }

  /* staggered baseline offsets, only at desktop */
  .pg-offset-sm { margin-top: 40px; }
  .pg-offset-md { margin-top: 80px; }
  .pg-offset-lg { margin-top: 120px; }

  /* media aspect overrides, only at desktop; mobile reverts to square */
  .pg-tall .card-media,
  .pg-tall .card-stage > div:first-child { aspect-ratio: 3 / 4; }
  .pg-wide .card-media,
  .pg-wide .card-stage > div:first-child { aspect-ratio: 16 / 10; }
  .pg-sq .card-media,
  .pg-sq .card-stage > div:first-child   { aspect-ratio: 1 / 1; }
}

/* mobile: single column, no offsets, square media */
@media (max-width: 839px) {
  .pg-row { grid-template-columns: 1fr; }
  .pg-offset-sm,
  .pg-offset-md,
  .pg-offset-lg { margin-top: 0; }
}

/* Self-reflowing row: 1 up, then 2, then 3 as the width allows, with no
   breakpoint of its own. Declared AFTER the mobile block above on purpose,
   since that block sets .pg-row to one column at the same specificity and
   would otherwise win on source order.

   How many fit is a MINIMUM now, not an exact size: n columns need
   n*340 + (n-1)*64, so 744px for two, 1148px for three and 1552px for four.
   Past each threshold the tracks share the surplus rather than a new column
   appearing, until the next one. .pg-grid's 1984px ceiling still holds four,
   at 448px each. The fixed-ratio rows stay pinned to 1200px below.

   min(100%, --pg-col-min) rather than the bare value, because a fixed track larger
   than its container overflows it: a 432px track in a 320px viewport would
   push a horizontal scrollbar instead of dropping to one column. */
.pg-row--auto {
  /* A FLOOR, not a fixed width. Tracks flex between this and whatever the row
     has spare, while the card caps itself at --pcard-card-max (500px), so a
     card ranges from 340px to 500px and the panel follows through
     width: min(--pcard-w, 100%).

     Fixed 448px tracks were tried first and proved too rigid: two across needed
     a 960px row, which behind this page's 20% margin meant a viewport near
     1263px, so a 1280px laptop only just managed two. Flexing lets the same
     laptop hold two comfortably and wider screens take three or four.

     The earlier failure to avoid is a low floor with no cap on the card, which
     stretched the tracks and squeezed the panels. The cap is what makes this
     safe: past 500px the card stops growing and simply centres in its track,
     which is also what lets portrait and landscape mix in one gallery. */
  --pg-col-min: 360px;   /* the floor that, inside the shell minus the reserved nav
                            gutter, gives two cards at ~1440 and three from ~1600 */
  /* The COLUMN gutter is wider than the row gap on purpose, so that the two
     read as equal. .pcard carries its own 32px bottom margin, so vertically
     the cards already sit 32 + 32 = 64px apart while horizontally they had
     only the 32px gap: the columns were half as open as the rows, which is
     what made a grid of busy cards feel crowded. Matching 64px here evens it
     up. Change this and the column-count arithmetic below changes with it. */
  --pg-gutter: 64px;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--pg-col-min)), 1fr));
  column-gap: var(--pg-gutter);
  justify-content: center;   /* inert while the tracks are 1fr; kept for if they
                                ever go back to fixed widths */
  justify-items: center;     /* and centre a narrower card within its track */
}

/* hero row: the hero card sits alone, full width of the grid */
.pg-hero {
  display: flex;
  justify-content: center;
}


/* ---------- bounding-box selection handles (Figma/AE style) ---------- */
/* Add .bbox to any element for corner handles + connecting line.
   Grey at rest (deselected), royal blue on hover (selected).
   .bbox--white  - white handles instead (for dark backgrounds) */
.bbox {
  position: relative;
  padding: 1rem;
}

/* once .bbox provides the frame, the card's own border/shadow/padding become
   redundant. Strip them here so this ONLY affects bbox-decorated cards,
   leaving ordinary cards' default appearance untouched */
.bbox .card-shell {
  border: 0;
  box-shadow: none;
  padding: 0;
}
.bbox::before {
  content: "";
  position: absolute;
  inset: -6px;
  z-index: 10;
  pointer-events: none;
  border: 1px solid #b0b0b0;
  background:
    linear-gradient(#b0b0b0, #b0b0b0)   0    0   / 8px 8px no-repeat,
    linear-gradient(#b0b0b0, #b0b0b0) 100%   0   / 8px 8px no-repeat,
    linear-gradient(#b0b0b0, #b0b0b0)   0  100%  / 8px 8px no-repeat,
    linear-gradient(#b0b0b0, #b0b0b0) 100% 100%  / 8px 8px no-repeat;
  transition: border-color 0.2s ease, background 0.2s ease;
}
.bbox:hover::before {
  border-color: var(--nd-blue);
  background:
    linear-gradient(var(--nd-blue), var(--nd-blue))   0    0   / 8px 8px no-repeat,
    linear-gradient(var(--nd-blue), var(--nd-blue)) 100%   0   / 8px 8px no-repeat,
    linear-gradient(var(--nd-blue), var(--nd-blue))   0  100%  / 8px 8px no-repeat,
    linear-gradient(var(--nd-blue), var(--nd-blue)) 100% 100%  / 8px 8px no-repeat;
}

/* hover-only variant (invisible at rest, blue on hover) */
.bbox--hover::before { opacity: 0; transition: opacity 0.2s ease, border-color 0.2s ease, background 0.2s ease; }
.bbox--hover:hover::before { opacity: 1; }

/* white variant */
.bbox--white::before {
  border-color: rgba(255, 255, 255, 0.7);
  background:
    linear-gradient(#fff, #fff)   0    0   / 8px 8px no-repeat,
    linear-gradient(#fff, #fff) 100%   0   / 8px 8px no-repeat,
    linear-gradient(#fff, #fff)   0  100%  / 8px 8px no-repeat,
    linear-gradient(#fff, #fff) 100% 100%  / 8px 8px no-repeat;
}


/* ---------- mobile navigation ----------
   THIS REVERSES an earlier decision in this file, which said no hamburger
   because four links wrap onto a second line perfectly well. That was true of
   four links. It is not true now: the nav is five items plus the contact
   mechanism, and measured at 375px the bar needs 526px before it stops
   overflowing. The reasoning changed because the content did.

   THE LOGO NEVER MOVES, and the way that is achieved matters. The panel does not
   contain a copy of the wordmark: the header stays fixed and above the overlay,
   so the mark on screen is the SAME ELEMENT throughout. Duplicating it would
   also have meant a second gradient id, since a document-wide id resolves to the
   first match and both marks would read one gradient.

   THE NAV IS ALSO NOT DUPLICATED. .site-menu is `display: contents` above the
   breakpoint, so .site-nav lays out as a direct flex child of the header exactly
   as before; below it, the same wrapper becomes the overlay. One nav in the DOM,
   two presentations, nothing to keep in step. */

.site-burger {
  display: none;              /* desktop: the links are already visible */
  position: relative;
  z-index: 2;
  appearance: none;
  background: none;
  border: 0;
  padding: 10px;              /* pads the 24px box out to a 44px target */
  margin: -10px;              /* without shifting the layout */
  color: var(--nd-ink);
  cursor: pointer;
}
.site-burger-box {
  position: relative;
  display: block;
  width: 24px;
  height: 16px;
}
.site-burger-bar {
  position: absolute;
  left: 0;
  width: 100%;
  height: 2px;
  background: currentColor;
  border-radius: 2px;
  /* Rotation has to be about each bar's own centre, which absolute positioning
     plus a 50% offset gives; laying them out in a flex column would rotate them
     about the box instead and swing them apart. */
  transition: transform .34s var(--nd-ease), opacity .18s ease;
}
.site-burger-bar:nth-child(1) { top: 0; }
.site-burger-bar:nth-child(2) { top: 7px; }
.site-burger-bar:nth-child(3) { top: 14px; }

/* The two outer bars travel to the middle AND rotate, in one transform, so they
   cross at the centre rather than pivoting where they already sit. The middle
   bar fades on a shorter duration so it is gone before the X forms. */
.site-burger[aria-expanded="true"] .site-burger-bar:nth-child(1) { transform: translateY(7px) rotate(45deg); }
.site-burger[aria-expanded="true"] .site-burger-bar:nth-child(2) { opacity: 0; transform: scaleX(.4); }
.site-burger[aria-expanded="true"] .site-burger-bar:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }

/* display: contents makes the wrapper vanish from layout, so the nav inside it
   is treated as a direct child of the header. That is what lets one element be
   an inline nav at desktop and an overlay at mobile. */
.site-menu { display: contents; }
.site-menu-extra { display: none; }

@media (max-width: 839px) {
  .site-burger { display: block; }

  /* The wordmark has to be raised above the panel, or it is covered by it: the
     panel is z-index 1 inside the header's stacking context and .site-mark is
     unpositioned, so it paints underneath. This is what keeps the logo in the
     same place with the menu open, and it is the SAME element throughout rather
     than a copy inside the panel. */
  .site-header--fixed .site-mark { position: relative; z-index: 2; }

  .site-menu {
    display: block;
    position: fixed;
    inset: 0;
    z-index: 1;
    /* Starts below the bar, so the wordmark and the burger stay visible and in
       place while the panel is open. */
    padding: calc(var(--sh-height, 4.5rem) + 2rem) var(--sh-pad-x, 2rem) 2rem;
    overflow-y: auto;
    overscroll-behavior: contain;   /* scrolling the panel must not scroll the page */

    /* Glassy, matching the bar above it. The panel is a transient full-screen
       surface rather than something painted every scroll frame, so the cost the
       header worries about does not apply here. */
    background: rgba(255, 255, 255, .82);
    -webkit-backdrop-filter: blur(22px) saturate(1.5);
    backdrop-filter: blur(22px) saturate(1.5);

    opacity: 0;
    visibility: hidden;
    transform: translateY(-8px);
    /* visibility is not interpolable, so it is a 0s change DELAYED until the
       fade has finished. Without the delay the panel would blank instantly and
       the fade would never be seen. Same technique the pcard's front state uses. */
    transition: opacity .28s var(--nd-ease), transform .28s var(--nd-ease),
                visibility 0s linear .28s;
  }
  .site-menu.is-open {
    opacity: 1;
    visibility: visible;
    transform: none;
    transition-delay: 0s, 0s, 0s;   /* appear at once, fade out on a delay */
  }

  /* The nav becomes a stacked list at a size worth tapping. */
  .site-header--fixed .site-nav {
    flex-direction: column;
    align-items: flex-start;
    gap: 4px;
    transform: none;                /* the desktop baseline nudge does not apply */
  }
  .site-header--fixed .site-nav a,
  .site-header--fixed .site-nav-contact button {
    font-weight: 600;
    line-height: 1.35;
    padding: 6px 0;                 /* comfortably past a 44px target */
  }
  /* Every item is already at full weight in the panel, so weight cannot carry
     the current state here as it does in the bar. The marker moves to the left
     edge instead, where a stacked list is read from, and the underline is
     dropped: a rule under 34px type reads as a heading rule, not a state. */
  .site-header--fixed .site-nav a[aria-current] { padding-left: 18px; }
  .site-header--fixed .site-nav a[aria-current]::after {
    left: 0; right: auto; top: 50%; bottom: auto;
    width: 8px; height: 8px; margin-top: -4px;
    border: 0; border-radius: 50%;
    background: var(--nd-indicator);
    opacity: 1;
  }
  .site-header--fixed .site-nav a[aria-current="true"]::after {
    background: none;
    border: 2px solid var(--nd-indicator);
    opacity: .55;
  }
  .site-nav-contact { gap: 14px; }
  .site-nav-toast { top: auto; bottom: 100%; right: auto; left: 0; margin: 0 0 6px; }

  /* The lede would collide with the burger, and it is repeated in the panel's
     own context anyway. */
  .site-header--fixed .site-lede { display: none; }

  .site-menu-extra { display: block; margin-top: 2.5rem; }
  .site-menu-extra .filters { width: auto; }
}

@media (prefers-reduced-motion: reduce) {
  .site-menu, .site-burger-bar { transition: none; }
}

/* ---------- .nd-conic, the rotational gradient ----------
   conic-gradient sweeps the hue around a centre point rather than along a line,
   which is the "rotational" or "angular" gradient a page builder offers. Two
   things it needs that a linear one does not:

     - a hard STOP at both ends, or the sweep meets itself at 0deg with a visible
       seam. Repeating the first colour at 100% is what closes the ring.
     - --nd-conic-from to aim it, since the default starts at 12 o'clock

   As a background it tints a whole box. To use it on TEXT or on a border, pair
   it with background-clip, which is how the same effect reaches a headline. */
.nd-conic {
  --nd-conic-from: 0deg;
  --nd-conic-at: 50% 50%;
  background-image: conic-gradient(from var(--nd-conic-from) at var(--nd-conic-at),
    var(--nd-conic-1, #001eff), var(--nd-conic-2, #ff00c8),
    var(--nd-conic-3, #00fff2), var(--nd-conic-4, #15ff00),
    var(--nd-conic-1, #001eff));
}
.nd-conic--text {
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}


/* ============================================================================
   HOTSPOTS (.hs-field): irregular regions on an image that either NAVIGATE or
   REVEAL. One component, because the two are the same mechanism with a
   different payload.

     .hs-field        the picture plus its map
     .hs-map          an <svg> overlay, one <path>/<polygon> per region
     .hs-region       one region, an <a> to navigate or a <button> to reveal
     .hs-label        the floating label, ONE per field
     .hs-panel        the revealed content

   WHY AN SVG OVERLAY rather than more .ai-dot style buttons: a dot answers
   "look here", and these have to answer "this shape is a thing". A stage, a
   doorway or a record sleeve in a photograph is not a circle, and an absolutely
   positioned box cannot follow its outline. An SVG path can, and it brings
   accurate hit-testing with it for nothing, since the browser tests the path
   itself rather than its bounding box.

   The viewBox must match the image's intrinsic pixel size, and
   preserveAspectRatio="none" makes the map stretch with the picture exactly as
   the picture does. Percentage coordinates are then unnecessary: the viewBox IS
   the percentage system, which is why this does not repeat the --ai-x / --ai-y
   approach that .annotated-image uses for its dots.
   ============================================================================ */

.hs-field {
  --hs-fill: rgba(255, 255, 255, 0);
  --hs-fill-hover: rgba(255, 255, 255, .26);
  --hs-stroke: rgba(255, 255, 255, 0);
  --hs-stroke-hover: rgba(255, 255, 255, .9);
  --hs-stroke-w: 2px;
  --hs-ease: .22s;
  position: relative;
  display: block;
  overflow: hidden;
}
.hs-field > img { display: block; width: 100%; height: auto; }

.hs-map {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  /* The SVG box covers the whole picture, so it would swallow every pointer
     event including those meant for the image. Only the regions take them. */
  pointer-events: none;
}
.hs-region { pointer-events: auto; cursor: pointer; outline: none; }
.hs-region > * {
  fill: var(--hs-fill);
  stroke: var(--hs-stroke);
  stroke-width: var(--hs-stroke-w);
  vector-effect: non-scaling-stroke;   /* or the stretch thickens one axis */
  transition: fill var(--hs-ease) ease, stroke var(--hs-ease) ease;
}
.hs-region:hover > *,
.hs-region:focus-visible > *,
.hs-region.is-active > * {
  fill: var(--hs-fill-hover);
  stroke: var(--hs-stroke-hover);
}

/* Keyboard users get the same highlight, plus a real focus ring, since the
   fill change alone is a colour-only cue. */
.hs-region:focus-visible > * { stroke-dasharray: 6 4; }

/* ---------- the label ----------
   ONE per field, not one per region, for the same reason .cl-bubble is one per
   page: with a dozen regions the per-element version is a dozen nodes and twice
   as many listeners for something only ever visible once. The text is read out
   of the region's own data-hs-label.

   Two placements, and both are wanted. --cursor follows the pointer, which suits
   a photograph where the regions are large and the label should not cover the
   thing it names. --pinned drops the label at the region's centroid, which is
   what a map or a diagram wants, and is the behaviour a Khruangbin-style room
   uses: the name sits in the space it belongs to. */
.hs-label {
  --hs-label-lift: 16px;
  --lx: 0px;
  --ly: 0px;
  position: absolute;
  top: 0;
  left: 0;
  z-index: 3;
  padding: 7px 13px;
  border-radius: var(--nd-radius-pill);
  background: var(--nd-ink);
  color: #fff;
  font-family: var(--nd-font);
  font-weight: 500;
  letter-spacing: .01em;
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  /* Composed in ONE transform, because an element has only one: writing
     transform from the JS would destroy the centring. Same trap .cl-bubble and
     .reveal both hit. */
  transform: translate(var(--lx), var(--ly))
             translate(-50%, calc(-100% - var(--hs-label-lift)))
             scale(.92);
  transition: opacity .18s ease, transform .18s var(--nd-ease), filter .24s ease;
  filter: blur(5px);
}
.hs-label.is-visible { opacity: 1; filter: blur(0); }
.hs-label.is-visible { transform: translate(var(--lx), var(--ly))
                                  translate(-50%, calc(-100% - var(--hs-label-lift)))
                                  scale(1); }

/* Pinned labels sit still at the region's centroid and never follow, so the
   lift is removed: it exists to clear a pointer that is not there. */
.hs-field--pinned .hs-label { --hs-label-lift: 0px; }

/* ---------- .hs-panel, the revealed content ----------
   The other payload. A region (or an inline .hs-trigger in running text) shows
   a panel rather than navigating.

   It is CLOSED by default in the markup with hidden, not merely transparent, so
   a reader with no JS gets nothing rather than an invisible block of text
   sitting over the page. The JS removes hidden and the class drives the fade. */
.hs-panel {
  position: absolute;
  z-index: 4;
  width: min(320px, 80%);
  padding: 18px 20px;
  background: #fff;
  border: 1px solid var(--nd-line-2);
  border-radius: var(--nd-radius, 8px);
  box-shadow: var(--nd-shadow, 0 18px 40px rgba(28,29,50,.18));
  font-family: var(--nd-font);
  line-height: 1.5;
  opacity: 0;
  transform: translateY(6px) scale(.98);
  transition: opacity .2s ease, transform .2s var(--nd-ease);
  pointer-events: none;
}
.hs-panel.is-open { opacity: 1; transform: none; pointer-events: auto; }
.hs-panel[hidden] { display: none; }
.hs-panel p { margin: 0 0 8px; }
.hs-panel p:last-child { margin-bottom: 0; }

/* The pin is what makes a hover panel usable: without it the panel closes the
   moment the pointer leaves the trigger, so its own links can never be reached.
   Pinned, it stays until dismissed. */
.hs-panel-pin {
  position: absolute;
  top: 8px;
  right: 8px;
  appearance: none;
  background: none;
  border: 0;
  padding: 4px;
  line-height: 0;
  color: var(--nd-faint);
  cursor: pointer;
}
.hs-panel-pin:hover { color: var(--nd-ink); }
.hs-panel.is-pinned .hs-panel-pin { color: var(--nd-blue); }

/* Inline trigger in running text, the Readymag case: a word that reveals
   something. Dotted rather than solid, so it is legible as "this does
   something" without reading as a link to another page. */
.hs-trigger {
  border-bottom: 1px dotted currentColor;
  cursor: help;
}
.hs-trigger.is-open { border-bottom-style: solid; }

/* A field whose regions navigate wants the picture to recede slightly while one
   is picked out, which is what makes a hotspot read as selectable rather than
   decorative. Kept subtle: this runs on hover across a whole photograph. */
.hs-field--dim > img { transition: filter .3s ease; }
.hs-field--dim.is-hovering > img { filter: brightness(.82) saturate(.9); }

@media (prefers-reduced-motion: reduce) {
  .hs-region > *, .hs-label, .hs-panel, .hs-field--dim > img { transition: none; }
  .hs-label { filter: none; }
}


/* ============================================================================
   SITE FOOTER: the full name as a full-width lockup.

     .site-footer        the band
     .site-namemark      the two-word lockup, NIRMAL then DHIMAN
     .site-footer-meta   copyright, and later the link lists

   THE SHARED 1435-UNIT viewBox IS THE ALIGNMENT DEVICE, and it is load-bearing
   rather than dead space to be trimmed. Both words are exported in a box of the
   same width, so giving the two SVGs the same rendered width renders them at
   exactly the same SCALE, whatever that width is. That is what makes the pair
   work as one lockup:

     side by side   each at 50%, so both scale identically and NIRMAL's own
                    trailing space (155 units, the box minus its artwork)
                    becomes the word gap for nothing
     stacked        each at 100%, so both scale identically and left-align

   Tightening either viewBox to its artwork would break both, since the two
   words are different lengths and would then render at different sizes. An
   earlier note in this file recommended exactly that, and it was wrong.

   The gap is therefore not a token: it is NIRMAL's trailing space, and moves
   with the artwork. To widen it, widen that word's viewBox.
   ============================================================================ */

.site-footer {
  /* Horizontal padding centres the footer content on the shell (left inset) and
     reserves the nav gutter on the right, so its edges sit under the header's and
     the sections'. Full-bleed border. */
  --sf-pad-y: 3rem;
  padding: var(--sf-pad-y) var(--nd-shell-inset-r) var(--sf-pad-y) var(--nd-shell-inset);
  border-top: 1px solid var(--nd-line-2);
  font-family: var(--nd-font);
}

/* STICKY FOOTER. On a short page the footer sits at the bottom of the viewport;
   on a long one it falls below the content as normal. The page is a min-height
   flex column and the main content grows to fill, so the footer is pushed down.
   Covers both the nirmtronik theme (body > .nd-main > .site-footer) and Bricks
   (.brx-body > #brx-content > #brx-footer). The fixed header is out of flow, so
   it does not affect the column. */
body { min-height: 100vh; display: flex; flex-direction: column; background: var(--nd-page-bg); }
.brx-body { background: var(--nd-page-bg); }
.nd-main,
.brx-body #brx-content { flex: 1 0 auto; }
.site-footer,
#brx-footer { flex-shrink: 0; }

.site-namemark {
  display: flex;
  align-items: flex-start;
  gap: 0;                 /* the word space comes from NIRMAL's own box */
  width: 100%;
}
.site-namemark a,
.site-namemark > span { display: block; flex: 1 1 0; min-width: 0; }

/* width 100% of an equal flex track, height auto: the aspect ratio does the
   sizing, so the lockup is fluid and never needs a breakpoint of its own. */
.site-namemark svg {
  display: block;
  width: 100%;
  height: auto;
  overflow: visible;
}
.site-namemark svg path,
.site-namemark svg rect { fill: var(--nd-mark-fill, currentColor); }

/* ---- the footer name follows the pointer ----------------------------------
   Each letter carries --f, how strongly it is affected: 1 under the pointer,
   falling away through its neighbours to 0. ONE NUMBER DRIVES BOTH the lift and
   the colour, so they cannot drift apart, and the falloff is set in the JS
   because it is a curve rather than a state.

   The letters are grouped for this. The artwork is loose strokes and each M's
   top bar spans all three stems, so a lift applied to the raw children would
   move a bar away from the stems it caps.

   NEITHER PROPERTY NEEDS --f REGISTERED with @property. An unregistered custom
   property does not interpolate, but transform and fill do, and both are
   transitioned here: changing --f changes their target and the transition
   animates to it. Registering it would animate the number as well, which is the
   same result reached twice.
   -------------------------------------------------------------------------- */
.site-namemark > span {
  --nd-name-lift: -10%;
  --nd-name-rise: .18s;    /* tracking the pointer: has to feel immediate */
  --nd-name-fall: .42s;    /* letting go: slower, so the word settles */
  --nd-name-hold: .08s;    /* before a letter left behind starts down */
  display: block;
}

.site-namemark .nd-name-letter {
  --f: 0;
  transform-box: fill-box;
  transform-origin: center;
  transform: translateY(calc(var(--nd-name-lift) * var(--f)));
  /* The resting state is the FALLING one, so this timing governs a letter the
     pointer has moved away from. */
  transition: transform var(--nd-name-fall) cubic-bezier(.4, 0, .2, 1) var(--nd-name-hold);
}

/* Rising tracks the pointer, so it is quick and undelayed. Overshoot on the way
   up gives the lift some mass; there is deliberately none on the way down,
   since a name that bounces is a toy. */
.site-namemark .nd-name-letter.is-lifted {
  transition: transform var(--nd-name-rise) cubic-bezier(.34, 1.3, .5, 1) 0s;
}

/* The colour rides the same number. color-mix blends toward the hover colour in
   proportion to --f, so a letter at 40% lift is 40% of the way to blue: the two
   halves of the effect stay in step by construction rather than by matching two
   sets of values by hand.

   NOT currentColor AS THE BASE, and this is the whole reason --nd-name-base
   exists. color-mix() with currentColor cannot be INTERPOLATED: the browser
   resolves the mix but has nothing to animate between, and the transition runs
   through rgba(0,0,0,0). Measured at 30ms and 120ms into a hover, the letter was
   fully transparent, which reads as a blink or as the letter vanishing
   altogether. An explicit colour interpolates cleanly.

   It stays a token so the name can still be recoloured, on a dark footer say,
   which is what currentColor was there for. */
.site-namemark .nd-name-letter path,
.site-namemark .nd-name-letter rect {
  fill: color-mix(in srgb, var(--nd-name-hover, var(--nd-blue)) calc(var(--f) * 100%), var(--nd-name-base, var(--nd-ink)));
  stroke: color-mix(in srgb, var(--nd-name-hover, var(--nd-blue)) calc(var(--f) * 100%), var(--nd-name-base, var(--nd-ink)));
  transition: fill var(--nd-name-fall) ease var(--nd-name-hold),
              stroke var(--nd-name-fall) ease var(--nd-name-hold);
}
.site-namemark .nd-name-letter.is-lifted path,
.site-namemark .nd-name-letter.is-lifted rect {
  transition: fill var(--nd-name-rise) ease 0s, stroke var(--nd-name-rise) ease 0s;
}

/* Where color-mix is unavailable the fill falls back to currentColor and the
   word simply stays black, which is a lost effect rather than a broken one. */
@supports not (color: color-mix(in srgb, red 50%, blue)) {
  .site-namemark .nd-name-letter.is-lifted path,
  .site-namemark .nd-name-letter.is-lifted rect { fill: var(--nd-name-hover, var(--nd-blue)); }
}

@media (prefers-reduced-motion: reduce) {
  /* The colour stays, since it is what identifies where the pointer is. Only
     the movement goes. */
  .site-namemark .nd-name-letter,
  .site-namemark .nd-name-letter.is-lifted { transform: none; transition: none; }
  .site-namemark .nd-name-letter path,
  .site-namemark .nd-name-letter rect { transition: fill .2s ease, stroke .2s ease; }
}

/* Same weight as the header mark, and it has to be stated here because the two
   are different components. Being in user units it renders proportionally the
   same despite the footer being nearly four times the size. If the large setting
   ever looks heavy, that is ordinary optical sizing and the fix is a lower
   --sh-mark-bold on this component alone, not a change to the shared value. */
.site-namemark svg { stroke-width: var(--sh-mark-bold, 4); }
.site-namemark svg > *, .site-namemark svg { stroke: currentColor; }
.site-namemark { color: var(--nd-ink); }

/* Stacked below the grid's own breakpoint, so the footer changes at the same
   width as everything else rather than introducing a fourth number. */
@media (max-width: 839px) {
  .site-namemark { display: block; }
  .site-namemark > * + * { margin-top: .35em; }
}

.site-footer-meta {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 8px 32px;
  margin-top: 2.5rem;
  color: var(--nd-faint);
}
.site-footer-meta nav { display: flex; flex-wrap: wrap; gap: 8px 20px; }
.site-footer-meta a { color: var(--nd-muted); text-decoration: none;
                      border-bottom: 1px solid transparent; }
.site-footer-meta a:hover { color: var(--nd-ink); border-bottom-color: currentColor; }

/* The same TWO STATES as the header, in the footer's own quieter register.
   The attribute was already being emitted here, since both navs share one
   walker; only the styling was scoped to .site-nav, so the accessible state was
   correct while nothing showed.

   Solid means this page, dashed means a page under this item, which is the same
   language the header uses. Drawn on the border the links already carry rather
   than on a pseudo-element, because these sit in a wrapping row where an
   absolutely positioned bar would need its own line-height handling for
   nothing. */
.site-footer-meta a[aria-current] { color: var(--nd-ink); }
.site-footer-meta a[aria-current="page"] { border-bottom-color: currentColor; }
.site-footer-meta a[aria-current="true"] {
  border-bottom-style: dashed;
  border-bottom-color: currentColor;
  opacity: .7;
}
.site-footer-copy { margin: 0; margin-right: auto; }


/* ============================================================================
   FILTER PANEL: the left rail on an index page.

     .filters            the panel
     .filters-toggle     collapses it
     .filter-group       one facet, e.g. Sector
     .filter             one term, a real <button aria-pressed>
     .filters-count      how many items are showing

   IT SHARES A SLOT WITH THE IN-PAGE NAV, and that is deliberate rather than a
   compromise: the two are never wanted at once. A case study has sections to
   move between and no collection to filter; an index has a collection to filter
   and no sections. So the left rail is whichever navigator that page needs.

   Filtering is CLASS-based, not data-attribute based: an item carries .f-<term>
   for each term it holds. That keeps the selection rule of this system intact,
   and it is also what WordPress already does, since post_class() emits a class
   per taxonomy term. The template gets this for free.

   Facet logic is OR within a group and AND across groups, which is what people
   expect: picking Music and Ecommerce means either sector, but adding a Type
   narrows within that rather than widening.
   ============================================================================ */

.filters {
  --filters-w: 13rem;
  width: var(--filters-w);
  font-family: var(--nd-font);
}

.filters-toggle {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  width: 100%;
  appearance: none;
  background: none;
  border: 0;
  border-bottom: 1px solid var(--nd-line-2);
  padding: 0 0 10px;
  font-family: inherit;
  font-weight: 600;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--nd-ink);
  cursor: pointer;
}
/* Same two-bar indicator as the accordion, so a collapse reads the same way
   wherever it appears. The accordion drives it from .acc-item.is-open, which
   never applies here, so the state has to be restated against .is-collapsed or
   the mark sits as a plus in both states and never moves. The sense is also
   INVERTED against the accordion's: this panel starts open, so its resting
   state is the minus and the plus means "expand". */
.filters-toggle .acc-mark { width: 12px; height: 12px; }
.filters-toggle .acc-mark::after { transform: rotate(0deg); }
.filters.is-collapsed .acc-mark::after { transform: rotate(90deg); }

.filters-body {
  display: grid;
  grid-template-rows: 1fr;
  transition: grid-template-rows .3s var(--nd-ease);
}
.filters.is-collapsed .filters-body { grid-template-rows: 0fr; }
.filters-body-inner { overflow: hidden; }

.filter-group { padding: 20px 0 4px; }
.filter-group-title {
  font-weight: 600;
  letter-spacing: .09em;
  text-transform: uppercase;
  color: var(--nd-faint);
  margin: 0 0 10px;
}

.filter-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}

.filter {
  appearance: none;
  background: none;
  border: 1px solid var(--nd-line-2);
  border-radius: var(--nd-radius-pill);
  padding: 4px 11px;
  font-family: inherit;
  color: var(--nd-muted);
  cursor: pointer;
  transition: color .15s ease, border-color .15s ease, background-color .15s ease;
}
.filter:hover { color: var(--nd-ink); border-color: var(--nd-ink); }

/* Selected state reads from aria-pressed rather than a parallel class, so the
   visual and the accessible state cannot drift. */
.filter[aria-pressed="true"] {
  background: var(--nd-indicator);
  border-color: var(--nd-indicator);
  color: #fff;
}

/* An item with no matches left is disabled rather than hidden: a term
   disappearing as you filter makes the list jump about, and hiding it also
   hides the information that nothing there matches. */
.filter[disabled] { opacity: .35; cursor: default; }
.filter[disabled]:hover { color: var(--nd-muted); border-color: var(--nd-line-2); }

.filters-count {
  color: var(--nd-faint);
  margin: 18px 0 0;
}
.filters-clear {
  appearance: none;
  background: none;
  border: 0;
  padding: 0;
  margin: 8px 0 0;
  font-family: inherit;
  color: var(--nd-muted);
  text-decoration: underline;
  cursor: pointer;
}
.filters-clear:hover { color: var(--nd-ink); }
.filters-clear[hidden] { display: none; }

/* Filtered out. Kept as a class rather than an inline style so the template,
   the JS and a no-JS fallback all agree on what hidden means. */
.is-filtered-out { display: none !important; }

@media (max-width: 839px) {
  .filters { width: auto; }
  .filter-list { gap: 8px; }
}

@media (prefers-reduced-motion: reduce) {
  .filters-body { transition: none; }
}


/* ============================================================================
   SITE HEADER: wordmark plus primary navigation.

     .site-header    the row
     .site-mark      the wordmark, links home
     .site-nav       primary navigation
     .site-nav-cta   the one link that should read as an action

   NOT sticky, deliberately. A case study already carries the fixed in-page nav
   down the left, and a second fixed bar along the top would leave the content
   boxed in on two sides while eating vertical space on exactly the screens with
   least of it. The in-page nav answers "where am I in this page"; the header
   answers "where am I in the site", and only the first of those needs to be
   permanently visible.

   No hamburger either. Four links wrap onto a second line perfectly well, and a
   menu that must be opened to see four items is worse than four items.
   ============================================================================ */

.site-header {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px 32px;
  padding: 28px 0 24px;
  border-bottom: 1px solid var(--nd-line);
}

/* ---------- fixed, full-bleed variant ----------
   Sits across the top of the viewport rather than in the page flow. Two things
   have to be supplied by the page rather than guessed here, because both depend
   on that page's own gutters:

     --sh-pad-x        where the wordmark sits. Match it to the in-page nav's
                       own left offset so the two line up in a column.
     --sh-lede-left    where the scrollable content starts, which is where the
                       subtitle belongs. Absolute rather than a margin, so it
                       lands on the content edge exactly instead of depending on
                       how wide the wordmark happens to render.

   z-index has to clear .cl-bubble (1000) as well as .page-nav-combo (900). The
   bubble is `position: fixed` and follows the pointer anywhere on the page, so
   at 950 it rode OVER the header while scrolling the client grid. The header is
   the one persistent chrome on the page and should never be covered by content
   reporting on something underneath it. The custom cursor (9999) and its trail
   (9998) stay above, correctly: a cursor that goes behind the furniture is
   worse than useless.

   The page also has to reserve --sh-height at the top of its content: a fixed
   header is out of flow, so without it the first section starts underneath. */
/* ANY PAGE CARRYING .site-header--fixed MUST RESERVE ITS HEIGHT, because a fixed
   header is out of flow and the page otherwise begins underneath it. This was
   left to the page and every page forgot, which is the wrong default: the
   component knows its own height, so it should claim the space itself.

   Applied to .nd-main rather than to body, so a page that wants content to run
   under the bar deliberately (a full-bleed hero, say) simply does not use it. */
.nd-main { padding-top: var(--sh-height, 4.5rem); }

/* The skip link: OFF SCREEN, NOT HIDDEN. display:none or visibility:hidden would
   take it out of the tab order, which removes the only thing it is for. Moved
   off screen instead, it stays focusable, and the first Tab on any page brings
   it back into view.

   It comes back as `fixed` rather than static so it cannot push the header down
   by a line the moment it is focused. */
.nd-skip {
  position: absolute;
  left: -9999px;
  top: auto;
  width: 1px;
  height: 1px;
  overflow: hidden;
}
.nd-skip:focus {
  position: fixed;
  z-index: 1200;          /* above the header, which is 1100 */
  left: 1rem;
  top: 1rem;
  width: auto;
  height: auto;
  padding: 10px 16px;
  background: var(--nd-ink);
  color: #fff;
  font-family: var(--nd-font);
  text-decoration: none;
  border-radius: var(--nd-radius, 8px);
}

.site-header--fixed {
  --sh-pad-x: 2rem;
  --sh-height: 4.5rem;
  /* Clear of the wordmark by default rather than 0, which put the lede on top of
     the logo on any page that did not set it. The mark is about 134px at its
     default height, so this leaves a readable gap. A page with its own content
     column should still override it to line the lede up with that column. */
  --sh-lede-left: calc(var(--nd-shell-inset) + 170px);
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 1100;
  /* border-box, or --sh-height is the CONTENT height and the padding and border
     add on top: 4.5rem became a 101px bar against a 4.5rem reservation, and the
     first section sat under it. The token has to mean the bar's real height,
     since that is the number the page reserves. */
  box-sizing: border-box;
  min-height: var(--sh-height);
  /* Horizontal padding centres the bar's CONTENT on the shell (wordmark to the
     shell's left edge, nav to its right edge) while the bar background stays
     full-bleed. --nd-shell-inset is the min gutter or half the leftover space,
     whichever is larger, so it matches the footer and sections exactly. */
  padding: 14px var(--nd-shell-inset-r) 14px var(--nd-shell-inset);
  flex-wrap: nowrap;
  gap: 24px;
  /* The TEXT sits on the wordmark's bottom edge, not on the bar's own centre
     line, so the lede and the nav read as one line ruled off by the logo.

     flex-end rather than baseline, because there is no baseline to share: the
     mark is artwork, and an <svg> is a replaced element, so `baseline` aligned
     it by its bottom edge and dropped it to the top of the content box (see
     .site-mark's align-self). Bottom-aligning the text instead puts the descender
     edge of each label on the content bottom, which lands the BASELINE a few px
     above it, and --sh-text-drop trims the difference. The mark keeps
     align-self: center, so this costs it no echo clearance. */
  align-items: flex-end;
  /* Half-opaque, so the page reads through it as it scrolls. The plain variant
     carries NO backdrop blur, deliberately: a blur across a full-width fixed bar
     is a per-frame cost on every scroll. --glass below opts into it knowingly. */
  background: rgba(255, 255, 255, .5);
  /* No bottom rule. The glass variant's specular edge already reads as the
     bar's lower boundary, and a hard line under a translucent surface fights
     it: you get a solid edge on a soft object. */
  border-bottom: 0;
}

/* ---------- .site-header--glass ----------
   Frosted glass, and it is opt-in rather than the default because it REVERSES a
   decision recorded earlier in this file: the fixed bar avoided backdrop-filter
   precisely because it is full width, always on screen, and recomposites on
   every scroll frame. That cost is real and has not gone away. It is a modifier
   so the trade is made per page rather than inherited by every one.

   WHAT ACTUALLY READS AS GLASS is not the blur, which on its own just looks like
   a foggy box. It is three things together:

     - blur, to destroy detail behind
     - a SATURATION boost, because real glass concentrates colour rather than
       washing it out, and blur alone reads grey and dead
     - a specular EDGE: one bright hairline along the top and a faint dark one
       along the bottom, which is the only cue that says "this is a solid pane
       with thickness" rather than "this area is blurry"

   Drop the saturation or the edge and it stops reading as a material. */
/* THE BLUR IS ON A PSEUDO-ELEMENT, NOT ON THE HEADER, and that is load-bearing
   rather than tidiness. backdrop-filter makes an element the CONTAINING BLOCK
   for its `position: fixed` descendants, exactly as transform and filter do. The
   mobile menu is a fixed child of this header, so with the filter on the header
   itself `inset: 0` resolved against the 72px bar instead of the viewport, and
   the panel rendered 72px tall with one nav item showing.

   A pseudo-element is a box of its own: the header's other children are not its
   descendants, so nothing they do is captured. The tint moves with the blur,
   since the two have to composite together. */
.site-header--glass {
  --sh-glass-blur: 14px;
  --sh-glass-sat: 1.7;
  --sh-glass-tint: rgba(255, 255, 255, .42);
  position: fixed;
  background: transparent;
}
/* z-index -1 rather than 0, so the glass sits behind the bar's contents without
   needing a positioning rule on the children. The obvious `> * { position:
   relative; z-index: 1 }` is a trap here: it applies to the mobile menu too and
   overrides its `position: fixed`, which collapsed the panel back into the flex
   row. The header's own background is transparent, so there is nothing for the
   layer to hide behind. */
.site-header--glass::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: -1;
  background: var(--sh-glass-tint);
  -webkit-backdrop-filter: blur(var(--sh-glass-blur)) saturate(var(--sh-glass-sat));
  backdrop-filter: blur(var(--sh-glass-blur)) saturate(var(--sh-glass-sat));
}

.site-header--glass::after {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;      /* the bar's links must stay clickable through it */
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .8),
              inset 0 -1px 0 rgba(0, 0, 0, .05);
}

/* ---------- .site-header--refract ----------
   Adds true REFRACTION, by displacing the backdrop through an SVG turbulence
   map rather than only blurring it. This is the part Kirki calls displacement,
   and it is genuinely different from blur: blur averages pixels in place, while
   a displacement map MOVES them, so straight lines behind the bar visibly bend.

   Two things to know before using it:

     - It is CHROMIUM ONLY. Safari and Firefox do not apply an SVG filter through
       backdrop-filter, and they will ignore this declaration entirely, falling
       back to the blur above. That degrades cleanly, which is why it is layered
       as a separate modifier rather than folded into --glass.
     - It needs the <svg><filter id="nd-glass"> block present IN THE PAGE. A CSS
       url(#id) reference resolves against the document, not the stylesheet, so
       pasting the CSS alone gives no refraction and no error either.

   --sh-glass-scale is the strength in pixels of displacement. Past about 30 the
   bar stops reading as glass and starts reading as a broken render. */
.site-header--refract::before {
  -webkit-backdrop-filter: blur(var(--sh-glass-blur)) saturate(var(--sh-glass-sat)) url(#nd-glass);
  backdrop-filter: blur(var(--sh-glass-blur)) saturate(var(--sh-glass-sat)) url(#nd-glass);
}

.site-header--fixed .site-mark { flex: 0 0 auto; }

/* The two text runs need SEPARATE offsets, which is not an oversight: the nav is
   a flex item and so is bottom-aligned by the rule above, but .site-lede is
   absolutely positioned for its horizontal placement and therefore ignores
   align-items entirely. Measured against a mark bottom of 50.5px, the nav
   baseline landed at 48 and the lede's at 53, so they are trimmed in opposite
   directions.

   These are OPTICAL values tied to the two font sizes. Change --sh-nav-size or
   --sh-lede-size and they need re-measuring: a baseline is a property of the
   face at that size, so there is no arithmetic that keeps them true for free.

   The lede is TWO lines now, so what meets the mark's bottom edge is its LAST
   baseline rather than its only one. Anchoring it by `bottom` is what makes that
   independent of the line count: add a third line and it grows upward, and the
   bottom line stays put. A `top` anchor would have pushed every extra line down
   and out of the bar. */
.site-header--fixed .site-nav  { transform: translateY(var(--sh-nav-drop, 2.5px)); }
.site-header--fixed .site-lede {
  bottom: var(--sh-lede-bottom, 14px);
  transform: translateY(var(--sh-lede-drop, 0px));
}

/* Sits at the content's left edge, alongside the wordmark rather than under it,
   so the top of the page reads as one line: who, what, and where to go. */
/* Two lines now, which the bar only just has room for: 44px of content box
   against 2 x 16px at a 1.15 leading, so 36.8px. The leading is deliberately
   tighter than body copy because this is a lockup rather than prose, and
   because a default 1.5 would need 48px and overflow the bar.

   white-space is normal, not nowrap: the line break is authored with <br>, and
   nowrap would not stop it since a <br> is a forced break either way. */
.site-lede {
  position: absolute;
  left: var(--sh-lede-left, 0px);
  font-family: var(--nd-font);
  font-size: var(--sh-lede-size);
  line-height: var(--sh-lede-leading, 1.15);
  font-weight: 500;
  color: var(--nd-muted);
  margin: 0;
}

.site-header--fixed .site-nav { margin-left: auto; flex: 0 0 auto; }

/* Below the point where the wordmark, the subtitle and four links stop fitting
   on one line, the subtitle is the first thing to go: the wordmark and the
   navigation are both doing a job it is not. */
@media (max-width: 860px) {
  .site-lede { display: none; }
}

.site-mark {
  font-family: var(--nd-font-display);
  font-weight: 400;
  line-height: 1;
  letter-spacing: 0;
  color: var(--nd-ink);
  text-decoration: none;
}
.site-mark:hover { color: var(--nd-muted); }

/* ---- Logotype slot -------------------------------------------------------
   .site-mark takes either live text (the default above) or an INLINE <svg>.
   Inline matters: an <img src="logo.svg"> or a background-image is an opaque
   box that CSS cannot reach into, so no hover can touch the letterforms.
   Inline, every stroke is a normal element.

   TWO THINGS ABOUT THE EXPORTED ARTWORK had to be corrected in the markup, and
   both will recur every time the logo is re-exported from Figma:

   1. The mark is 10 loose STROKES, not 4 letters. The M's top bar is one rect
      spanning all three stems, and the N's bar spans both diagonals. So a
      per-letter effect applied to the svg's direct children moves a bar
      independently of the stems it caps, which reads as the logo coming apart.
      The strokes are wrapped in one <g class="nd-mark-letter"> per letter, and
      every per-letter rule below targets THAT, never the raw children.

   2. The exported viewBox is 0 0 1000 240 while the artwork occupies
      0..890 by 40..240: 110 units of empty space on the right and 40 on top.
      Because the mark is sized by HEIGHT with width auto, that padding is not
      free, it scales with the mark and pushes .site-lede away from the wordmark
      by an amount no token accounts for. The viewBox is tightened to
      "0 40 890 200" so the box IS the mark.

   Sizing is by height so the mark tracks the header rather than its own
   viewBox, and the SVG carries no width or height attributes: hardcoded
   dimensions fight this and are the usual reason a pasted logo comes out wrong.
   -------------------------------------------------------------------------- */
/* A MARK MADE OF ARTWORK HAS TO OPT OUT OF BASELINE ALIGNMENT. The header aligns
   its children on the baseline, which is right for a text wordmark sitting
   against the nav, but an <svg> is a replaced element with no baseline of its
   own, so it aligns by its bottom edge and lands at the TOP of the content box.
   Measured before this rule: 14px of clearance above the mark against 26px
   below, in a bar that is meant to hold it centred. That reads as the logo
   sitting high, and it also starves the echo's upward phase, which then paints
   outside the header.

   Centring it is the fix rather than a downward nudge, which would only be
   correcting the symptom by eye. --sh-mark-nudge stays as an optical
   adjustment on top, at 0 by default, and note it costs the echo clearance in
   whichever direction it moves. */
.site-mark:has(svg) { align-self: center; }

.site-mark svg {
  display: block;
  height: var(--sh-mark-h, 30px);
  width: auto;
  overflow: visible;         /* a letter can lift past the box without clipping */
  transform: translateY(var(--sh-mark-nudge, 0px));
}
/* currentColor by default, NOT a baked hex, so the mark inherits from
   .site-mark and a dark header recolours it for free. The custom property in
   front of it is for the echo below, and the reason is worth stating exactly
   because the obvious guess is wrong:

   A DOCUMENT CSS RULE DOES NOT MATCH <use> SHADOW CONTENT. Measured: setting
   --nd-mark-fill on a <use> changed nothing, because the rule below never
   applied to the clones in the first place. What the clones get instead is
   INHERITANCE from the <use> element, so a copy is coloured by putting fill on
   the <use> itself, which is what the markup does.

   The custom property stays because the behaviour is not uniform across
   engines. Where a rule does reach the clone, the var resolves and wins; where
   it does not, the fill attribute on the <use> carries by inheritance. Setting
   both means the copy is coloured either way, and neither can silently produce
   a stack of black marks. */
.site-mark svg path,
.site-mark svg rect { fill: var(--nd-mark-fill, currentColor); }

/* THE WEIGHT IS A STROKE, and it is on at all times rather than only on hover.
   A stroke sits centred on the outline, so half of it falls outside the shape:
   --sh-mark-bold of 4 turns a 10-unit stem into 14, which is 40% heavier and is
   what gives the mark its weight. It stands in for the bolder cut of the
   typeface, which is not drawn yet. Replace it with the real weight when there
   is one, and this becomes one deletion.

   It lives on .site-mark rather than on --echo because it is a property of the
   LOGOTYPE, not of any one hover effect: the mark should carry the same weight
   with no modifier at all.

   In USER UNITS, so it scales with the mark and reads identically at 30px in the
   header and at 116px across the footer. A px value would have gone spindly at
   one size and clotted at the other.

   stroke-width is inherited, so declaring it on the svg reaches the <use> copies
   as well and every copy carries the same weight as the original. Only the PAINT
   differs, and that is set per copy: without it the echo would trail thin ghosts
   behind a heavy black mark, which reads as a different logo rather than the
   same one repeating. */
.site-mark svg { stroke-width: var(--sh-mark-bold, 4); }
.site-mark .nd-mark-art { stroke: var(--nd-mark-stroke, currentColor); }

/* A mark carrying its own artwork should not also take the text hover, which
   would grey the whole logo underneath whatever treatment is running. */
.site-mark:has(svg):hover { color: var(--nd-ink); }

/* Treatment 1, .site-mark--sweep: black washes into the brand gradient.
   This is what the coloured exports are for, and it needs neither a second copy
   of the artwork nor a crossfade between two: ONE linearGradient fills the
   strokes at all times, and its two stops sit at the ink colour when resting.
   stop-color is animatable, so the transition runs from black to the gradient
   with nothing stacked and nothing to keep in register.

   The second stop is DELAYED, which is what makes it read as a wash across the
   word rather than the whole thing fading at once. The gradient itself never
   moves: with gradientUnits="userSpaceOnUse" all ten strokes share one
   coordinate system, so the ramp spans the whole wordmark rather than
   restarting inside each stroke, which is what objectBoundingBox would do.

   The colourway is two tokens, so the other exported pairs are a token change
   rather than a second file:
     blue to pink    #001eff  #ff00c8   (default)
     cyan to green   #00fff2  #15ff00
     pink to cyan    #ff00c8  #00fff2

   The gradient's id is a document-wide name. Use the mark TWICE on a page, in a
   header and a footer say, and both fills resolve to the first one, so hovering
   the footer drives its own stops while its strokes read the header's gradient
   and nothing happens. A second instance needs its own id.
   -------------------------------------------------------------------------- */
.site-mark--sweep {
  --sh-mark-grad-1: #001eff;
  --sh-mark-grad-2: #ff00c8;
  --sh-mark-sweep: .5s;
}
.site-mark--sweep .nd-mark-art { --nd-mark-fill: url(#nd-mark-grad); }

.site-mark--sweep .nd-mark-stop-a,
.site-mark--sweep .nd-mark-stop-b {
  stop-color: var(--nd-ink);
  transition: stop-color var(--sh-mark-sweep) var(--nd-ease);
}
.site-mark--sweep:hover .nd-mark-stop-a,
.site-mark--sweep:focus-visible .nd-mark-stop-a { stop-color: var(--sh-mark-grad-1); }
.site-mark--sweep:hover .nd-mark-stop-b,
.site-mark--sweep:focus-visible .nd-mark-stop-b {
  stop-color: var(--sh-mark-grad-2);
  transition-delay: .14s;    /* trailing edge arrives late, so the colour sweeps */
}

/* Treatment 2, .site-mark--stagger: the letters lift in sequence.
   Composes with --sweep, since one drives transform and the other stop-color:
   no two rules here contend for a single property, which is the trap .reveal
   hit against .tilt-3d. */
.site-mark--stagger .nd-mark-letter {
  transition: transform .28s var(--nd-ease);
  transition-delay: 0ms;      /* the RESTING delay, so the exit is immediate */
  transform-box: fill-box;    /* see below */
  transform-origin: center;
}
.site-mark--stagger:hover .nd-mark-letter,
.site-mark--stagger:focus-visible .nd-mark-letter { transform: translateY(-9%); }

/* The delays sit on the HOVER state only. On the resting state they stagger the
   exit as well, which reads as the mark being slow to recover rather than as a
   flourish: on the way out the letters should land together. */
.site-mark--stagger:hover .nd-mark-letter:nth-child(2) { transition-delay: 45ms; }
.site-mark--stagger:hover .nd-mark-letter:nth-child(3) { transition-delay: 90ms; }
.site-mark--stagger:hover .nd-mark-letter:nth-child(n+4) { transition-delay: 135ms; }

/* On transform-box: fill-box. It changes nothing for THIS translate, and it is
   worth saying so rather than leaving a reason that sounds right: a percentage
   translateY resolves against HEIGHT, every letter here spans the full 200-unit
   viewBox height, so the letter's own box and the viewBox give the same 18
   units either way. It is set for the two cases where the default does bite:

     - a future letter that does not run the full height, where the default
       would scale its lift to the viewBox rather than to the letter
     - any rotate or scale, which without fill-box pivots about the SVG's
       origin rather than the letter's centre, and swings it across the word

   Measured: all four letters settle at -18 units, and the stagger reaches them
   at 0, 45, 90 and 135ms.
   -------------------------------------------------------------------------- */

/* ---- .site-mark--intro: the letters arrive one at a time ------------------
   ONLY ON THE FIRST VIEW OF A SESSION. An entrance animation is a greeting, and
   a greeting repeated on every page load stops being one: it becomes a delay
   between the reader and the page they asked for.

   The class is added by a small inline script rather than by the main JS, and
   that is a FOUC decision rather than a stylistic one. combined.js loads in the
   footer, so a class added there arrives after first paint: the mark would
   render finished, then blank, then animate. The inline script runs during parse,
   before the mark's markup is even reached, so the first paint is already
   correct. sessionStorage rather than localStorage, so it greets you again in a
   new tab tomorrow instead of once ever.

   The animation ends at the letter's natural state and carries no forwards fill,
   so once it is done --stagger's hover transition owns the transform again with
   nothing to unwind. */
@keyframes ndMarkIn {
  from { opacity: 0; transform: translateY(38%); }
  to   { opacity: 1; transform: none; }
}

.nd-first-visit .site-mark--intro .nd-mark-letter {
  animation: ndMarkIn .5s var(--nd-ease) backwards;
}
.nd-first-visit .site-mark--intro .nd-mark-letter:nth-child(2) { animation-delay: .09s; }
.nd-first-visit .site-mark--intro .nd-mark-letter:nth-child(3) { animation-delay: .18s; }
.nd-first-visit .site-mark--intro .nd-mark-letter:nth-child(4) { animation-delay: .27s; }
.nd-first-visit .site-mark--intro .nd-mark-letter:nth-child(5) { animation-delay: .36s; }
.nd-first-visit .site-mark--intro .nd-mark-letter:nth-child(n+6) { animation-delay: .45s; }

@media (prefers-reduced-motion: reduce) {
  .nd-first-visit .site-mark--intro .nd-mark-letter { animation: none; }
}

/* Treatment 3, .site-mark--echo: a step-and-repeat that keeps rolling.
   The black mark holds still and coloured copies drop in behind it, each one
   further right and further down than the last, one every --sh-echo-gap.

   THE GEOMETRY IS AUTHORED ONCE. Five <use> elements point at the rendered
   letters, so the ten strokes are not repeated six times in the markup, and a
   change to the artwork reaches every copy. Each copy carries BOTH a fill
   attribute and --nd-mark-fill, for the reason given above the fill rule: CSS
   does not reliably reach a use's shadow content, but inheritance from the
   <use> always does.

   PAINT ORDER IS DOM ORDER in SVG, with no z-index to appeal to. The copies are
   written furthest-first and the black original last, so it sits on top of the
   stack, which is what "underneath" in the brief requires.

   THE OFFSET IS IN USER UNITS, NOT PIXELS, and that is deliberate. A CSS px
   inside an SVG resolves to one user unit, so a literal 5px would be 5 units of
   a 200-unit-tall mark: about 0.65 device px at header size, which is nothing.
   Working in user units also makes the echo scale WITH the mark, so it looks the
   same at 26px in the header and at 90px across a footer, where a fixed 5px
   would shrink to a hairline.

   --sh-echo-step is 34 units, which is 4.4 device px at the header's 26px. That
   is the largest step that still fits: five copies at 34 units reach 170 units,
   or 22px, against the 23px of clear space below a 26px mark in a 72px bar. Go
   further and the stack paints outside the header, since the mark needs
   overflow: visible for the lift in --stagger.
   -------------------------------------------------------------------------- */
.site-mark--echo {
  --sh-echo-step: 24;       /* user units per copy, along both axes */
  --sh-echo-gap: .1s;       /* between one copy arriving and the next */
  --sh-echo-gap-last: .3s;  /* except before the LAST one: see below */
  --sh-echo-count: 5;
  --sh-echo-phase: 2s;      /* one direction: build, hold, clear */
  --sh-echo-peak: .5;       /* opacity of the NEAREST copy: the volume control */
  --sh-echo-falloff: .07;   /* how much fainter each further copy sits */
}

/* THE DIRECTION IS TWO NUMBERS, and they are what the four-phase cycle drives.
   Each copy multiplies its own offset by them, so one pair steers all five and
   the quadrant becomes a property of TIME rather than of the markup.

   They are registered with @property so the animation below is guaranteed
   discrete. An unregistered custom property is already non-interpolable, but a
   registered <number> would tween, sweeping the copies round the compass instead
   of snapping between quadrants, so `step-end` is what actually holds each phase
   flat. Registering plus step-end means it snaps either way. */
@property --nd-echo-x { syntax: "<number>"; inherits: true; initial-value: 1; }
@property --nd-echo-y { syntax: "<number>"; inherits: true; initial-value: 1; }

.site-mark--echo .nd-mark-echo {
  opacity: 0;
  /* --n is the copy's index, set per element, so one rule places all five */
  transform: translate(calc(var(--n) * var(--sh-echo-step) * var(--nd-echo-x) * 1px),
                       calc(var(--n) * var(--sh-echo-step) * var(--nd-echo-y) * 1px));
}

/* The arrivals are NOT an even multiple: four copies land a --sh-echo-gap apart
   and then the last one waits --sh-echo-gap-last, so the cascade stutters, pauses
   and lands. At .1s and .3s that is 0.1, 0.2, 0.3, 0.4, then 0.7s.

   That exception is why the last copy needs its own hook. Selecting it by
   position would work today, since the copies are written furthest-first, but it
   would silently attach to the wrong element the moment a sixth is added, and
   the `count - 1` in the sum below would be wrong with it. An explicit class
   cannot drift. */
.site-mark--echo:hover .nd-mark-echo,
.site-mark--echo:focus-visible .nd-mark-echo {
  animation: ndMarkEcho var(--sh-echo-phase) linear infinite;
  animation-delay: calc(var(--n) * var(--sh-echo-gap));
}
.site-mark--echo:hover .nd-mark-echo--last,
.site-mark--echo:focus-visible .nd-mark-echo--last {
  animation-delay: calc((var(--sh-echo-count) - 1) * var(--sh-echo-gap)
                        + var(--sh-echo-gap-last));
}

/* PARKED: the four-quadrant rotation. Bottom right only for now, which is what
   the initial-value of 1 on both axes already gives, so parking it is simply not
   running the animation: nothing else needs changing and the copies keep their
   direction from --nd-echo-x / --nd-echo-y as before.

   To bring it back, uncomment both blocks. The timing constraint further down
   still applies and is still satisfied, since it was solved for the phase
   length rather than for the number of directions.

.site-mark--echo:hover,
.site-mark--echo:focus-visible {
  animation: ndMarkEchoDir calc(var(--sh-echo-phase) * 4) step-end infinite;
}
@keyframes ndMarkEchoDir {
  0%,   24.99% { --nd-echo-x:  1; --nd-echo-y:  1; }
  25%,  49.99% { --nd-echo-x:  1; --nd-echo-y: -1; }
  50%,  74.99% { --nd-echo-x: -1; --nd-echo-y: -1; }
  75%, 100%    { --nd-echo-x: -1; --nd-echo-y:  1; }
}
*/

/* THE TIMINGS ARE NOT FREE, they solve for one thing: every copy must be gone
   before the direction flips, or a visible copy would snap across the mark
   mid-cascade. A copy appears at its own delay and is clear again a fixed
   fraction of the phase later, so the last one out sets the constraint:

       last arrival + clear * phase + slack  <=  phase

   where `last arrival` is (count - 1) * gap + gap-last, not a plain multiple,
   since the final copy waits longer than the rest.

   At .1s, .3s and a 2s phase that is 0.7s, so `clear` at 58% puts the last copy
   out at 1.86s against a boundary at 2.0s: 140ms of slack. The slack is the
   point. An earlier tuning solved the same constraint exactly and left a 2ms
   margin, which is a coincidence rather than a design, and one frame of jitter
   would light a copy through a flip.

   THE PHASE IS ALSO SET BY HOW LONG ALL FIVE ARE UP TOGETHER, which is
   `clear * phase - (last arrival - first arrival)`, measured at 0.52s.
   Shortening the phase to match the quicker cascade would have squeezed that
   window shut, so the phase came DOWN only from 2.2s to 2.0s even though the
   build got 200ms faster: the time saved went into the hold rather than back
   into the loop.

   Measured arrival intervals: 100, 99, 101, 300ms.

   Change any of the gaps or the copy count and this has to be solved again,
   keeping the slack, otherwise the stack snaps across the mark mid-cascade.

   That constraint is also why the phase is 2.2s rather than the 1.2s it was
   when the echo only ever went one way: the copies have to accumulate AND clear
   inside a single direction now, where before they could roll continuously. */
@keyframes ndMarkEcho {
  0%    { opacity: 0; }
  4%    { opacity: var(--nd-echo-peak); }
  51%   { opacity: var(--nd-echo-peak); }
  58%   { opacity: 0; }
  100%  { opacity: 0; }
}



/* Further copies sit fainter, so the stack reads as depth rather than five equal
   marks. Computed from --n, so adding a sixth copy needs no new rule.

   --sh-echo-peak is the volume control, and it is the one to reach for rather
   than the colours: at full strength five saturated gradients stacked under a
   black mark read as psychedelic, and dropping the opacity turns them pastel
   against the header's white without touching the palette. At .5 the nearest
   copy is half strength and the furthest is .22.

   If it still wants calming, the next step is fewer HUES rather than fewer
   copies: point all five <use> elements at one gradient id and the effect
   becomes a single-colour echo, which keeps the movement and loses the rainbow.
   That is a markup change, not a CSS one. */
.site-mark--echo .nd-mark-echo {
  --nd-echo-peak: calc(var(--sh-echo-peak) - (var(--n) - 1) * var(--sh-echo-falloff));
}

@media (prefers-reduced-motion: reduce) {
  .site-mark .nd-mark-letter,
  .site-mark--sweep .nd-mark-stop-a,
  .site-mark--sweep .nd-mark-stop-b { transition: none; }
  .site-mark--stagger:hover .nd-mark-letter { transform: none; }
  /* the echo is the whole effect, so it is dropped rather than shortened */
  .site-mark--echo:hover .nd-mark-echo,
  .site-mark--echo:focus-visible .nd-mark-echo,
  .site-mark--echo:hover,
  .site-mark--echo:focus-visible { animation: none; }
}


.site-nav {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 4px 26px;
}

.site-nav a {
  position: relative;
  font-family: var(--nd-font);
  font-size: var(--sh-nav-size);
  font-weight: 500;
  color: var(--nd-muted);
  text-decoration: none;
  padding-bottom: 3px;
  transition: color .18s ease;
}
.site-nav a:hover { color: var(--nd-ink); }

/* ---------- Contact in the nav ----------
   Contact is an ACTION, not a destination, so it is the email mechanism rather
   than a link to a page carrying one. It reuses .nd-contact's contract, so the
   address is still assembled at runtime and the raw HTML holds no mailto.

   The buttons are reset to look like the nav's links: a <button> is required
   because the engine binds click handlers to it, and an <a href="mailto:"> in
   the markup would put the address back into the page and defeat the whole
   point of the component. */
.site-nav-contact {
  position: relative;
  display: inline-flex;
  align-items: baseline;
  gap: 10px;
}
.site-nav-contact button {
  appearance: none;
  background: none;
  border: 0;
  padding: 0;
  font-family: var(--nd-font);
  font-size: var(--sh-nav-size);
  font-weight: 500;
  cursor: pointer;
}
.site-nav-contact .site-nav-cta { color: var(--nd-ink); font-weight: 600; }
.site-nav-contact .site-nav-cta:hover { color: var(--nd-blue); }
/* An icon rather than the word "Copy": the nav is five items of running text
   and a sixth word made it read as a sixth destination. The accessible name
   stays on the button as aria-label, so nothing is lost by dropping the
   visible word. */
.site-nav-copy {
  display: inline-flex;
  align-items: center;
  color: var(--nd-faint);
  transition: color .18s ease;
}
.site-nav-copy svg { display: block; }
.site-nav-copy:hover { color: var(--nd-ink); }
.site-nav-copy.is-copied { color: var(--nd-blue); }

/* The toast is absolutely positioned so confirming a copy cannot reflow the
   nav, which would shift every link sideways at the moment of a click. */
.site-nav-toast {
  position: absolute;
  top: 100%;
  right: 0;
  margin-top: 6px;
  padding: 4px 9px;
  border-radius: var(--nd-radius-pill);
  background: var(--nd-ink);
  color: #fff;
  font-family: var(--nd-font);
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transition: opacity .2s ease;
}
.site-nav-toast:not(:empty) { opacity: 1; }

/* The current page is marked with aria-current, so the styling reads from the
   accessible state rather than from a duplicate .is-active class that could
   fall out of step with it. */
/* TWO states, not one, because "this page" and "somewhere under this item" are
   different answers and a nav that conflates them is lying on every child page.

     aria-current="page"   this exact page. A solid rule: you are here.
     aria-current="true"   an ancestor. A child page of this section is open, so
                           the item is the right part of the site but not the
                           document you are reading.

   Both are real ARIA values rather than a parallel class, so the visual and the
   accessible state cannot drift, which is the same reasoning `.filters` uses for
   aria-pressed. A screen reader announces the exact page as "current page" and
   the ancestor as "current", which is the distinction being drawn visually.

   IT MAPS ONTO WHAT WORDPRESS ALREADY EMITS, which is why it is worth doing this
   way rather than inventing classes: wp_nav_menu puts .current-menu-item on the
   exact item and .current-menu-ancestor / .current-menu-parent on its parents.
   A nav walker turns the first into aria-current="page" and the others into
   aria-current="true", and nothing here changes. */
.site-nav a[aria-current="page"],
.site-nav a[aria-current="true"] {
  color: var(--nd-ink);
  font-weight: 600;
}
.site-nav a[aria-current]::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 2px;
  background: var(--nd-indicator);
}
/* The ancestor's rule is dashed and set back from full strength: present enough
   to locate you in the site, quiet enough that it cannot be mistaken for the
   page you are actually on when both are visible at once. Drawn as a border
   rather than a background, since a background cannot be dashed. */
.site-nav a[aria-current="true"]::after {
  background: none;
  height: 0;
  border-bottom: 2px dashed var(--nd-indicator);
  opacity: .55;
}

/* One link reads as an action rather than a destination. Kept to ONE: a nav
   where everything is emphasised has nothing emphasised. */
.site-nav-cta {
  color: var(--nd-ink);
  border-bottom: 2px solid var(--nd-indicator);
}
.site-nav-cta:hover { color: var(--nd-muted); border-bottom-color: var(--nd-muted); }

@media (max-width: 560px) {
  .site-header { padding-top: 20px; }
  .site-nav { gap: 4px 18px; }
  .site-nav a { font-size: var(--sh-nav-size); }
}


/* ============================================================================
   TABS: one row of triggers, one panel visible at a time.

     .tabs          wrapper
     .tabs-list     the row of triggers, role="tablist"
     .tab           one trigger, a real <button role="tab">
     .tab-panel     one panel, role="tabpanel", hidden unless .is-active

   Real buttons with the ARIA roles rather than styled divs, because a tab strip
   has an expected keyboard contract: arrow keys move between tabs, Home and End
   jump to the ends, and only the selected tab is in the tab order so Tab moves
   OUT of the strip to the panel rather than through every trigger. The JS
   implements that; the markup just has to use buttons.
   ============================================================================ */

.tabs { --tabs-gap: 28px; }

.tabs-list {
  display: flex;
  flex-wrap: wrap;
  gap: var(--tabs-gap);
  margin: 0 0 22px;
  padding: 0 0 2px;
  border-bottom: 1px solid var(--nd-line);
}

.tab {
  position: relative;
  appearance: none;
  background: none;
  border: 0;
  padding: 0 0 12px;
  font-family: var(--nd-font);
  font-weight: 500;
  color: var(--nd-muted);
  cursor: pointer;
  transition: color .18s ease;
}
.tab:hover { color: var(--nd-ink); }

/* The underline is a pseudo element rather than a border, so it can sit ON the
   list's own border-bottom and animate its width from the centre without
   shifting the text by a pixel. */
.tab::after {
  content: '';
  position: absolute;
  left: 50%;
  right: 50%;
  bottom: -1px;
  height: 2px;
  background: var(--nd-indicator);
  transition: left .22s var(--nd-ease), right .22s var(--nd-ease);
}
.tab[aria-selected="true"] {
  color: var(--nd-ink);
  font-weight: 600;
}
.tab[aria-selected="true"]::after { left: 0; right: 0; }

.tab-panel { display: none; }
.tab-panel.is-active { display: block; }

@media (prefers-reduced-motion: reduce) {
  .tab, .tab::after { transition: none; }
}


/* ============================================================================
   ACCORDION: one open at a time.

     .acc           wrapper
     .acc-item      one section, takes .is-open
     .acc-trigger   the title, a real <button aria-expanded>
     .acc-panel     the content
     .acc-mark      the indicator

   THE HEIGHT ANIMATION IS A GRID TRICK, and it is worth knowing why. `height`
   cannot transition to `auto`, which is why accordions are traditionally done
   with a max-height guess: too small and long content clips, too large and the
   easing goes slack because most of the duration is spent animating empty
   space. A grid row CAN transition from `0fr` to `1fr`, which resolves to the
   content's real height, so the panel opens to exactly its own size at an even
   speed whatever it contains. The inner wrapper is required: the overflow has
   to be clipped on the child, not the grid itself.
   ============================================================================ */

.acc { --acc-dur: .32s; }

/* The rules belong to the ITEMS, not to .acc. A border on the wrapper spans its
   padding box, so a padded .acc drew a line wider than its own rows, which is
   visible the moment the component is inset inside a container. Item-bounded
   means the accordion has no edge of its own to misalign. */
.acc-item { border-bottom: 1px solid var(--nd-line); }
.acc-item:first-child { border-top: 1px solid var(--nd-line); }

.acc-heading { margin: 0; }

.acc-trigger {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 20px;
  width: 100%;
  appearance: none;
  background: none;
  border: 0;
  padding: 20px 2px;
  font-family: var(--nd-font-display);
  font-weight: 400;
  line-height: 1.25;
  color: var(--nd-ink);
  text-align: left;
  cursor: pointer;
}
.acc-trigger:hover { color: var(--nd-muted); }

/* The indicator is drawn from two bars, so ONE element gives both marks: the
   pair reads as a plus, and rotating the vertical bar out of the way leaves a
   minus. No icon swap, nothing to keep in step, and it animates. */
.acc-mark {
  position: relative;
  flex: 0 0 auto;
  width: 14px;
  height: 14px;
}
.acc-mark::before,
.acc-mark::after {
  content: '';
  position: absolute;
  inset: 50% 0 auto 0;
  height: 1.5px;
  background: currentColor;
  transition: transform var(--acc-dur) var(--nd-ease);
}
.acc-mark::after { transform: rotate(90deg); }
.acc-item.is-open .acc-mark::after { transform: rotate(0deg); }

/* Chevron instead of plus/minus, for the same markup. */
.acc--chevron .acc-mark::before { transform: rotate(45deg) translate(-3px, 0); width: 60%; }
.acc--chevron .acc-mark::after  { transform: rotate(-45deg) translate(3px, 0); width: 60%; left: auto; right: 0; }
.acc--chevron .acc-item.is-open .acc-mark::before { transform: rotate(-45deg) translate(-3px, 0); }
.acc--chevron .acc-item.is-open .acc-mark::after  { transform: rotate(45deg) translate(3px, 0); }

.acc-panel {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows var(--acc-dur) var(--nd-ease);
}
.acc-item.is-open .acc-panel { grid-template-rows: 1fr; }

.acc-panel-inner {
  overflow: hidden;   /* required: the clip goes on the child, not the grid */
}
.acc-panel-inner > * { margin-top: 0; }
.acc-panel-inner p {
  font-family: var(--nd-font-body);
  line-height: 1.55;
  color: var(--nd-muted);
  margin: 0 0 20px;
  max-width: 68ch;
}

@media (prefers-reduced-motion: reduce) {
  .acc-panel, .acc-mark::before, .acc-mark::after { transition: none; }
}


/* ============================================================================
   MARQUEE: a slow, seamless, constant-speed scrolling track.

     .mq            the viewport. overflow hidden, any width, masked at the edges
     .mq-track      the moving row. The JS DUPLICATES its children into it
     .mq-item       one thing in the row

   Three things it does that the older .ts-row marquee does not:

   1. THE TRAILING GAP. This is trap 14, built in from the start rather than
      found later. The track drifts by -50%, which only loops seamlessly if half
      its width equals the distance from item 1 to item N+1. Flex `gap` sits
      BETWEEN items, so 2N items carry only 2N-1 gaps and the row measures
      2N*item + (2N-1)*gap. Half of that is gap/2 short, and the row snaps back
      by that much every cycle. padding-right restores it, and a percentage
      translate resolves against the BORDER box so the padding counts. Both read
      the same token deliberately: change one alone and the snap returns.

   2. THE AUTHOR WRITES THE ITEMS ONCE. The JS clones them, so there is no
      second copy in the markup to keep in step. The clones are aria-hidden, so
      a screen reader hears the list once rather than twice.

   3. CONSTANT SPEED, not constant duration. --mq-speed is px per second and
      the JS derives the duration from the measured width, so adding items makes
      the track longer rather than making everything faster. A fixed duration
      would speed up as the list grew, which is the thing that makes a marquee
      feel frantic.

   Hover pauses it. That matters more than it sounds for the logo variant: the
   bubble follows the cursor, so without a pause the logo slides out from under
   the pointer while its own description is still being read.
   ============================================================================ */

.mq {
  --mq-gap: 56px;
  --mq-speed: 34px;        /* px per second: slow is the point */
  --mq-fade: 6%;           /* how far the edge mask reaches in */
  --mq-angle: 0deg;        /* a slight tilt, see .mq--full below */
  position: relative;
  overflow: hidden;
  width: 100%;
  margin-bottom: 3rem;     /* clear space below a scrolling row */
  -webkit-mask-image: linear-gradient(to right, transparent, #000 var(--mq-fade),
                                      #000 calc(100% - var(--mq-fade)), transparent);
  mask-image: linear-gradient(to right, transparent, #000 var(--mq-fade),
                              #000 calc(100% - var(--mq-fade)), transparent);
}

/* The end fades are two things, not one: --mq-fade tunes how far they reach in,
   and .mq--no-fade removes them outright. Worth having both, because a row that
   bleeds to the viewport usually wants the fade, while one sitting inside a
   bordered panel usually does not. Same pair on .ts-field below. */
.mq--no-fade {
  -webkit-mask-image: none;
  mask-image: none;
}

.mq-track {
  display: flex;
  align-items: center;
  gap: var(--mq-gap);
  padding-right: var(--mq-gap);   /* see 1 above; do not remove */
  width: max-content;
  animation: mqDrift var(--mq-duration, 60s) linear infinite;
}
/* Named outside the `pcard` prefix on purpose: the card's drag and hover-pause
   logic scrubs every animation whose name starts with that, and would ramp this
   one to a standstill. Same reason .cl-spin and ndFinderBlink are named as they
   are, see trap 7. */
@keyframes mqDrift {
  from { transform: translateX(0); }
  to   { transform: translateX(-50%); }
}

.mq:hover .mq-track,
.mq:focus-within .mq-track {
  animation-play-state: paused;
}

/* Right to left is the default, so items enter from the right. */
.mq--reverse .mq-track { animation-direction: reverse; }

/* ---------- full bleed ----------
   The usual trick, margin-left: calc(50% - 50vw), assumes the container is
   CENTRED in the viewport. Here it is not: the reference page indents each
   section by 20%, and a case study page carries a nav gutter, so that
   calculation would push the row off to one side.

   So the JS measures instead. It zeroes --mq-bleed, reads the element's real
   left edge, and sets the offset to minus that, which pins the row to the
   viewport's left edge wherever its container happens to sit. Width comes from
   documentElement.clientWidth rather than 100vw, because 100vw INCLUDES the
   scrollbar and would push a horizontal one of its own.

   Reset-before-measure is the same pattern buildSegmented uses, and for the
   same reason: measuring after the offset is applied reads the value back. */
.nd-bleed,
.mq--full {
  width: var(--mq-bleed-w, 100vw);
  margin-left: var(--mq-bleed, 0px);
  max-width: none;
}

/* A slight tilt. The rotation goes on .mq, while the drift animation is on
   .mq-track inside it, so the two never contend for one transform property:
   that is the trap .reveal hit against .tilt-3d, avoided here by construction.

   Corner gaps are not an issue at these angles because the edge mask above
   already fades the row out exactly where they would appear. */
.mq--angle {
  transform: rotate(var(--mq-angle));
  transform-origin: center center;
}

.mq-item { flex: 0 0 auto; }

/* ---------- text variant ---------- */
/* z-index: 1, one below .mq--logos. Both rows are .mq--angle, and a rotated
   box's visual extent is taller than its own layout height (the rotation is
   paint-only, so no extra space is reserved in flow), so the two can overlap
   at the seam. Paint order alone is not enough to guarantee which one wins:
   it follows DOM order, and the logos row moved ahead of the text row in the
   markup, so without this the text row (painted second) would sit on top. */
.mq--text {
  --mq-speed: 24px;   /* words need longer in the eye than a logo does */
  position: relative;
  z-index: 1;
  margin-top: 1rem;
}
.mq--text .mq-item {
  font-family: var(--nd-font-display);
  line-height: 1.1;
  color: var(--nd-ink);
  white-space: nowrap;
}
/* A separator between phrases, so they do not read as one run-on sentence.
   On the clone as well, since it is just another item. */
.mq--text .mq-item::after {
  content: '';
  display: inline-block;
  width: 7px;
  height: 7px;
  margin-left: calc(var(--mq-gap) / 2);
  vertical-align: middle;
  border-radius: 50%;
  background: var(--nd-faint);
}

/* ---------- logo variant ---------- */
.mq--logos {
  --mq-gap: 2px;
  position: relative;
  z-index: 2;
}
.mq--logos .mq-item {
  width: 160px;   /* the item IS a .cl-item, so the shape and chrome come from
                     that; only the width needs pinning, since a flex row has no
                     column to size it */
}

@media (prefers-reduced-motion: reduce) {
  .mq-track { animation: none; }
  .mq { -webkit-mask-image: none; mask-image: none; overflow-x: auto; }
}

/* ============================================================================
   CLIENT BUBBLE: an alternative to the tile turn.

   Opt in with .cl-grid--bubble on the grid. Instead of the tile rotating to
   its back face, a single bubble follows the cursor carrying that client's
   name and description. A blend of the tooltip (content that belongs to the
   thing hovered) and the custom cursor (one element, moved with the pointer).

   ONE bubble for the whole page, not one per tile. With 232 tiles the
   per-element version would be 232 nodes and 464 listeners for something only
   ever visible once at a time, so the JS delegates from the grid and reads the
   content out of the tile's existing .cl-name and .cl-desc. No new markup.

   Position and scale are separate custom properties fed into ONE transform.
   Writing the position straight into `transform` from JS would blow away the
   scale, which is the same one-transform-per-element trap .reveal and .tilt-3d
   hit. The transition then eases both together, which is what makes it read as
   following rather than teleporting.
   ============================================================================ */

.cl-bubble {
  --cl-bubble-size: 180px;
  /* how far the bubble's BOTTOM edge sits above the pointer */
  --cl-bubble-lift: 14px;
  --bx: 0px;
  --by: 0px;
  --bs: .82;
  /* THE ENTRANCE HAS A DIRECTION. A symmetric scale-up has none, so the eye
     reads it as arriving from wherever the bubble happened to be parked. A small
     offset that resolves gives it an origin on purpose: it drifts up and to the
     right into place, which is away from the cursor's usual travel and so does
     not fight the pointer.

     Not to be confused with the corner-travel bug this sits on top of: park()
     leaves the bubble at 0,0, and placing it and showing it in the same frame
     used to animate the whole journey across the page. The JS now places it with
     the transition suppressed, so what remains is only this deliberate drift. */
  --cl-enter-x: -12px;
  --cl-enter-y: 16px;
  /* How long the bubble takes to LEAVE. Read by the JS too, see the transition
     below. */
  --cl-exit: .45s;
  position: fixed;
  top: 0;
  left: 0;
  z-index: 1000;
  width: var(--cl-bubble-size);
  height: var(--cl-bubble-size);
  border-radius: 50%;
  /* Filled with the logo's own brand colour, sampled by the JS, with the ink
     chosen from that colour's luminance so the text stays readable on a pale
     brand. Falls back to the ink pair when a logo yields nothing usable, which
     is the case for anything purely black, white or grey. Set --cl-brand on a
     tile to override the sampling, the same escape hatch .pcard gives with
     --pcard-hover. */
  background: var(--cl-bubble-bg, var(--nd-ink));
  color: var(--cl-bubble-ink, #fff);
  /* Lifts it clear of the grid it floats over. A plain box-shadow is fine
     here: the bubble is a border-radius circle, not clip-path, so nothing cuts
     the shadow away, unlike the card frame and the impact boxes. */
  box-shadow: 0 14px 34px rgba(28, 29, 50, .30),
              0 3px 8px rgba(28, 29, 50, .16);
  display: grid;
  place-content: center;
  text-align: center;
  padding: 18px;
  box-sizing: border-box;
  pointer-events: none;   /* never steals the hover it is reporting on */
  opacity: 0;
  /* Enters soft and resolves. The blur is what stops it reading as a hard
     object snapping into existence: at rest it is out of focus, and it comes
     into focus as it fades up. Paired with the JS placing it before showing it,
     since a blur on something sliding in from the corner would only draw
     attention to the slide.

     The blur amount is shared by both directions, because both pass through
     this same resting state. Only the DURATIONS differ, and that asymmetry is
     the point: arriving should be brisk, leaving should be unhurried. */
  filter: blur(var(--cl-bubble-blur, 10px));
  /* -100% puts the bubble's bottom edge exactly on the pointer, then the lift
     backs it off by a fixed gap. The old -118% scaled the offset with the
     bubble's own height, which floated it further away than intended. */
  transform: translate(var(--bx), var(--by))
             translate(var(--cl-enter-x), var(--cl-enter-y))
             translate(-50%, calc(-100% - var(--cl-bubble-lift)))
             scale(var(--bs));
  /* THE TRANSITION DECLARED HERE IS THE ONE THAT RUNS ON THE WAY OUT, since this
     is the state being transitioned TO when .is-visible is removed. The entrance
     is declared on .is-visible below. That split is what lets the two directions
     have different timings at all: a single transition here would make them
     symmetrical, and a leisurely arrival feels sluggish where a leisurely
     departure feels considered.

     --cl-exit is read by the JS, which must not park the bubble until this has
     finished. Leaving that as a number in both places guarantees they drift the
     first time either is tuned. */
  transition: opacity var(--cl-exit) ease,
              transform var(--cl-exit) var(--nd-ease),
              filter var(--cl-exit) var(--nd-ease),
              background-color .16s ease, color .16s ease;
  will-change: transform;
}
.cl-bubble.is-visible {
  opacity: 1;
  filter: blur(0px);
  --bs: 1;
  --cl-enter-x: 0px;
  --cl-enter-y: 0px;
  /* the entrance, deliberately brisker than the exit above */
  transition: opacity .2s ease, transform .16s var(--nd-ease),
              filter .24s var(--nd-ease),
              background-color .16s ease, color .16s ease;
}
/* Wrapping the copy lets the TEXT cross-fade on its own when the pointer moves
   to a neighbouring logo, while the bubble itself stays put and keeps
   following. Fading the whole bubble would read as it leaving and returning. */
.cl-bubble .cl-bubble-text {
  transition: opacity .16s ease;
}
.cl-bubble.is-swapping .cl-bubble-text {
  opacity: 0;
}
.cl-bubble .cl-bubble-name {
  font-family: var(--nd-font);
  font-weight: 600;
  line-height: 1.25;
  margin: 0 0 5px;
}
.cl-bubble .cl-bubble-desc {
  font-family: var(--nd-font-body);
  line-height: 1.3;
  margin: 0;
  color: inherit;
  opacity: .82;
}

/* The hovered tile lifts, so it is obvious which logo the bubble is describing.
   Stronger than the base hover, which is a hairline meant to be felt rather
   than seen, and z-index so the shadow sits over its neighbours rather than
   under them. */
.mq--bubble .cl-item:hover,
.cl-grid--bubble .cl-item:hover {
  border-color: var(--nd-ink);
  box-shadow: 0 10px 24px rgba(28, 29, 50, .22);
  z-index: 2;
}

/* In bubble mode the tile stops turning: two reveals of the same content at
   once is noise, and the bubble is now the one doing the telling. */
.cl-grid--bubble .cl-item:hover .cl-spin,
.cl-grid--bubble .cl-item:focus-within .cl-spin,
.cl-grid--bubble .cl-item.is-active .cl-spin,
.mq--bubble .cl-item:hover .cl-spin,
.mq--bubble .cl-item:focus-within .cl-spin,
.mq--bubble .cl-item.is-active .cl-spin {
  transform: rotateY(0deg);
}
.cl-grid--bubble .cl-item:hover .cl-face,
.cl-grid--bubble .cl-item:focus-within .cl-face,
.cl-grid--bubble .cl-item.is-active .cl-face,
.mq--bubble .cl-item:hover .cl-face,
.mq--bubble .cl-item:focus-within .cl-face,
.mq--bubble .cl-item.is-active .cl-face {
  filter: blur(0);
}

@media (prefers-reduced-motion: reduce) {
  .cl-bubble { transition: opacity .16s ease; }
}

/* ============================================================================
   CASE STUDY SECTIONS: the storytelling spine of a project page.

   A case study is a run of .cs-section blocks. Each one carries a heading that
   says what is happening at that point in the story, and a LAYOUT MODIFIER
   that decides how it sits on the page.

   The layout modifier is the whole point. A page reads as a blog when every
   block is the same width in one column; it reads as art directed when the
   measure, alignment and bleed change with the content. So layout is a
   property of the section, chosen per section, not a global page setting.

     .cs-section              - default, reading measure, centred
     .cs-section--narrow      - tighter measure, for a pivotal short passage
     .cs-section--wide        - full grid width, for media that needs room
     .cs-section--bleed       - edge to edge, breaks the measure entirely
     .cs-section--split       - text one side, media the other, 5:7 at desktop
     .cs-section--split-flip  - the same, media first
     .cs-section--offset      - media pushed off the measure, deliberately
                                asymmetric

   Give every section an id. The page-nav reads its links and resolves each
   href to an element, then sizes each bar segment to that element's real
   height, so the index doubles as a map of how long each part is.

   Content classes inside a section (.cs-lead, .cs-body, .cs-bullets, .cs-media,
   .cs-figure, .cs-caption) are layout-agnostic on purpose: the same paragraph
   works in a narrow section or a split one, which is what keeps the number of
   combinations small.
   ============================================================================ */

.cs-page {
  --cs-measure: 68ch;        /* comfortable reading line length */
  --cs-narrow: 52ch;
  --cs-wide: 100%;           /* wide sections fill the content area, edge to edge in the 4% page margins */
  --cs-gutter: 0px;          /* the page margin does the insetting; a gutter here would offset content from the header */
  --cs-rhythm: 96px;         /* vertical space BETWEEN sections */
  font-family: var(--nd-font);
  color: var(--nd-ink);
}

.cs-section {
  padding-block: var(--cs-rhythm);
  /* section boundaries are what the nav measures, so keep them real elements
     rather than margins collapsing into each other */
  margin: 0;
}

/* The inner wrapper owns the measure, so a modifier changes ONE value rather
   than re-stating padding and centring on every variant. */
.cs-section > .cs-inner {
  width: min(100%, var(--cs-measure));
  margin-inline: auto;
  padding-inline: var(--cs-gutter);
  box-sizing: border-box;
}
.cs-section--narrow > .cs-inner { width: min(100%, var(--cs-narrow)); }
.cs-section--wide   > .cs-inner { width: min(100%, var(--cs-wide)); }
.cs-section--bleed  > .cs-inner { width: 100%; padding-inline: 0; }

/* Two-up. 5:7 rather than 6:6 so the text column keeps a readable measure and
   the media gets the larger share, which is usually what the content wants. */
.cs-section--split > .cs-inner,
.cs-section--split-flip > .cs-inner {
  width: min(100%, var(--cs-wide));
  display: grid;
  gap: 48px;
  align-items: start;
}
@media (min-width: 840px) {
  .cs-section--split > .cs-inner      { grid-template-columns: 5fr 7fr; }
  .cs-section--split-flip > .cs-inner { grid-template-columns: 7fr 5fr; }
  /* flip puts the media first visually while leaving the markup in reading
     order, so the text still comes first for a screen reader */
  .cs-section--split-flip .cs-col--text  { order: 2; }
  .cs-section--split-flip .cs-col--media { order: 1; }
}
/* A 1fr track floors at min-content, so a long word or a wide image would
   push the columns out of their declared ratio. Same fix as .pg-row. */
.cs-section--split > .cs-inner > *,
.cs-section--split-flip > .cs-inner > * { min-width: 0; }

/* Deliberate asymmetry: the media hangs past the reading measure on one side.
   Clamped so it can never exceed the viewport and cause a horizontal scroll. */
.cs-section--offset > .cs-inner {
  width: min(100%, var(--cs-wide));
}
@media (min-width: 840px) {
  .cs-section--offset .cs-media {
    width: min(100%, calc(100% + 12vw));
    margin-inline-start: min(0px, calc(-6vw));
  }
}

/* ---------- content, layout-agnostic ---------- */

.cs-eyebrow {
  letter-spacing: .12em;
  text-transform: uppercase;
  color: var(--nd-faint);
  margin: 0 0 8px;
}
.cs-title {
  line-height: 1.2;
  letter-spacing: -0.01em;
  margin: 0 0 20px;
  color: var(--nd-ink);
}
/* These carry --nd-font-body (Times) but were missing the x-height correction
   the card body already applies: Times at a nominal 16px has a much smaller
   x-height than Inter, so it READ like ~13px, the "0.8rem" complaint. The base
   sizes are also raised a step for comfortable long-form reading, then the
   serif scale brings the apparent size up to match. */
.cs-lead {
  line-height: 1.5;
  color: var(--nd-ink);
  margin: 0 0 20px;
}
.cs-body { font-size: var(--nd-fs-body); line-height: 1.7; color: var(--nd-muted); margin: 0 0 16px; }
.cs-body:last-child { margin-bottom: 0; }
.cs-body strong { color: var(--nd-ink); font-weight: 600; }

.cs-bullets { margin: 0 0 16px; padding-left: 1.1em; }
.cs-bullets li { font-size: var(--nd-fs-body); line-height: 1.7; color: var(--nd-muted); margin-bottom: 8px; }
.cs-bullets li::marker { color: var(--nd-faint); }
.cs-bullets li strong { color: var(--nd-ink); font-weight: 600; }

/* ---------- media ---------- */

.cs-media {
  display: block;
  width: 100%;
  border-radius: var(--nd-radius);
  overflow: hidden;
  background: var(--nd-surface-2);
}
.cs-media img,
.cs-media video {
  display: block;
  width: 100%;
  height: auto;
  -webkit-user-drag: none;   /* same guard as .draggable and .pcard-face img */
}
.cs-section--bleed .cs-media { border-radius: 0; }

.cs-figure { margin: 0; }
.cs-caption {
  line-height: 1.5;
  color: var(--nd-faint);
  margin: 10px 0 0;
}
.cs-section--bleed .cs-caption { padding-inline: var(--cs-gutter); }

/* An embedded prototype or video keeps its aspect rather than guessing a
   height, so it never letterboxes or clips on a narrow screen. */
.cs-embed {
  position: relative;
  width: 100%;
  aspect-ratio: var(--cs-embed-ratio, 16 / 10);
  border-radius: var(--nd-radius);
  overflow: hidden;
  background: var(--nd-surface-2);
}
.cs-embed > iframe {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
}

/* ---------- paper notes ----------
   Needs, constraints and observations as loose bits of paper rather than a
   bulleted list. The point is that a list of needs looks like every other
   portfolio; a pinboard reads as the artefact of doing the work.

   Built on the existing .draggable engine, so a note can be nudged: put
   .drag-field on the container and .draggable on each note and it is wired,
   no new JS. That engine moves items with left/top rather than transform,
   which is what leaves `transform` free here for the paper tilt. If it had
   used transform, the two would have fought, which is the trap .reveal hit
   against .tilt-3d.

   Placement is authored with --nx/--ny as PERCENTAGES, so the arrangement
   holds its proportions as the container changes width, the same approach
   .annotated-image uses for its dots. The container needs an explicit height
   for a percentage top to resolve against, hence --cs-notes-h rather than a
   min-height.

   Below the layout breakpoint the whole thing becomes a plain stack: absolute
   positioning and dragging both stop, the tilt is zeroed, and the notes flow
   in source order. A pinboard does not work at 375px, and a recruiter on a
   phone wants to read them, not arrange them.
   ---------------------------------------------------------------------- */
.cs-notes {
  --cs-notes-h: 440px;
  position: relative;
  height: var(--cs-notes-h);
  margin: 8px 0 0;
}

.cs-note {
  position: absolute;
  left: var(--nx, 0);
  top: var(--ny, 0);
  width: var(--nw, 15rem);
  max-width: 80%;
  box-sizing: border-box;
  padding: 16px 18px 18px;
  background: var(--cs-note-bg, #fdf6d8);
  color: var(--nd-ink);
  transform: rotate(var(--nr, 0deg));
  box-shadow: 0 1px 1px rgba(28, 29, 50, .10),
              0 8px 18px -8px rgba(28, 29, 50, .28);
  cursor: grab;
}
.cs-note:active { cursor: grabbing; }

/* the kind of note, small and in the UI face: it is a label, not prose */
.cs-note-kind {
  font-family: var(--nd-font);
  font-weight: 600;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: rgba(28, 29, 50, .55);
  margin: 0 0 6px;
}
.cs-note p {
  font-family: var(--nd-font-body);
  line-height: 1.35;
  margin: 0;
}

/* Three kinds, so a glance separates whose need is whose. Paper tints rather
   than brand colours: these are meant to read as things that were written
   down, not as UI. */
.cs-note--user        { --cs-note-bg: #fdf6d8; }   /* yellow, user need, the default pad */
.cs-note--business    { --cs-note-bg: #dfeaf6; }   /* blue, business need */
.cs-note--limit       { --cs-note-bg: #f6dfe0; }   /* pink, constraint */
.cs-note--opportunity { --cs-note-bg: #dff0dc; }   /* green, opportunity */

/* "Get alignment" toggle, added by combined.js to any pinboard with two or more
   notes. Absolutely placed top-right, over the scattered notes. */
.cs-notes-align {
  position: absolute;
  top: 8px;
  right: 8px;
  z-index: 5;
  appearance: none;
  cursor: pointer;
  font-family: var(--nd-font);
  font-size: var(--nd-fs-ui);
  line-height: 1;
  padding: 8px 14px;
  color: #fff;
  background: var(--nd-graphite, #333);
  border: 0;
  border-radius: 999px;
  box-shadow: 0 2px 8px -2px rgba(28, 29, 50, .4);
  transition: background .2s ease, transform .1s ease;
}
.cs-notes-align:hover { background: var(--nd-ink, #000); }
.cs-notes-align:active { transform: translateY(1px); }

/* Only DURING the group/scatter tween do the notes ease between positions; at
   rest they carry no transition, so dragging stays instant. */
.cs-notes--animating .cs-note {
  transition: left .5s cubic-bezier(.4, 0, .2, 1),
              top .5s cubic-bezier(.4, 0, .2, 1),
              transform .5s cubic-bezier(.4, 0, .2, 1);
}

@media (max-width: 839px) {
  .cs-notes {
    height: auto;
    display: flex;
    flex-direction: column;
    gap: 12px;
  }
  /* .cs-notes .cs-note, not a bare .cs-note: the note also carries .draggable,
     which sets position: absolute at the SAME specificity and is declared later
     in this file, so a bare selector loses on source order and the notes stay
     absolute, collapsing the container to zero height. Same trap as
     .pg-row--auto against the mobile .pg-row rule, and the page-nav's own
     mobile block. */
  .cs-notes .cs-note {
    position: static;
    width: auto;
    max-width: none;
    transform: none;   /* a tilt in a vertical stack just looks broken */
    cursor: default;
  }
  /* On the stacked mobile board the button sits in the flow at the top rather
     than absolutely over the first note; align then reorders the stack by kind
     via flex order (user, business, constraint, opportunity). */
  .cs-notes-align {
    position: static;
    order: -1;
    align-self: flex-start;
    margin-bottom: 6px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .cs-note { transition: none; }
}

/* ---------- a pair of stats, for the results section ---------- */

.cs-stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  gap: 24px;
  margin: 0;
  padding: 0;
}
.cs-stat { margin: 0; }
.cs-stat dd {
  margin: 0;
  line-height: 1.1;
  font-weight: 700;
  color: var(--nd-ink);
  letter-spacing: -0.02em;
}
.cs-stat dt {
  margin: 6px 0 0;
  line-height: 1.4;
  color: var(--nd-muted);
}

@media (max-width: 839px) {
  .cs-page { --cs-rhythm: 64px; }
  .cs-section--split > .cs-inner,
  .cs-section--split-flip > .cs-inner { gap: 28px; }
}

/* ============================================================================
   CLIENT LOGO GRID: categorised, responsive, tiles turn to show the client.
   
   .cl-section        - one category block (title + grid)
   .cl-title           - category heading
   .cl-grid            - responsive grid of logo items
   .cl-item            - one tile, holds the spinning layer
   .cl-spin            - the rotating layer, carries the two faces
   .cl-face            - one side of the tile; --back is pre-rotated 180deg
   .cl-logo            - the logo image itself, on the front face
   .cl-name            - client name, on the back face
   .cl-desc            - one-line project description, on the back face

   Source images are 200x134, and the grid preserves that ratio via aspect-ratio.
   Text lives in the HTML, so it is accessible and indexable whichever face is
   showing. Hover, focus or tap holds the turn so a face can be read.
   ============================================================================ */

.cl-section {
  max-width: var(--nd-shell-max);
  margin: 0 auto;
  padding: 0 24px 56px;
}
.cl-title {
  /* an h3, so it follows the heading face rather than the UI one */
  font-family: var(--nd-font-display);
  font-weight: 400;
  color: var(--nd-ink);
  margin: 0 0 20px;
  padding-bottom: 10px;
  border-bottom: 1px solid var(--nd-line);
}

/* Reflows on its own: auto-fill packs as many 200px tracks as the width allows
   and 1fr shares the remainder, so tiles grow rather than leaving a ragged
   gutter. 200px is the source logo's natural width, so at the tightest fitting
   column the artwork sits at 1:1 and only ever scales up from there. */
.cl-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(160px, 2fr));
  gap: 2px;
}

.cl-item {
  position: relative;
  aspect-ratio: 160 / 107.2;
  border-radius: 8px;
  overflow: hidden;
  perspective: 900px;   /* depth for the spinning layer inside */
  background: #fff;
  cursor: pointer;
  border: 1px solid rgba(0, 0, 0, 0.06);
  transition: border-color 0.2s ease, box-shadow 0.2s ease;
}

.cl-item:hover,
.cl-item:focus-within {
  border-color: rgba(0, 0, 0, 0.12);
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}

.cl-logo {
  /* border-box is essential, not tidiness: with the default content-box the
     100% width plus 16px of horizontal padding made the image's box wider than
     its tile, so overflow:hidden clipped the right and bottom while object-fit
     centred the artwork inside that oversized box. The logo then sat visibly
     down and to the right. The global box-sizing rule only covers `.card *`,
     which does not reach this component. */
  box-sizing: border-box;
  width: 100%;
  height: 100%;
  object-fit: contain;
  padding: 12px 16px;
  display: block;
  -webkit-user-drag: none;   /* same guard as .draggable: the native image drag
                                would otherwise fight the tap-to-turn gesture */
}

/* The turn lives on its OWN layer, exactly as the pcard keeps rotation off
   .pcard-panel and on .pcard-panel-spin. The tile itself is already driving
   two transforms, the pointer tilt and the reveal offset, and an element has
   only one transform property: a turn here would cancel both.

   A transition rather than an animation, deliberately. The tile rests on the
   logo, turns to the client on hover and turns back on leave, and a transition
   reverses from wherever it has got to. An animation would restart from the
   beginning on a fast in-out-in, which is exactly the gesture a grid of thirty
   tiles invites. */
.cl-spin {
  --cl-turn: 0.45s;
  --cl-blur: 8px;        /* front face, a logo: broad forms, takes a soft focus */
  --cl-blur-back: 3px;   /* back face, text: fine strokes, needs far less */
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  transform: rotateY(0deg);
  transition: transform var(--cl-turn) ease;
}

/* Touch has no hover, so the tap-toggled .is-active is what turns the tile
   there. Focus keeps it reachable from the keyboard. */
.cl-item:hover .cl-spin,
.cl-item:focus-within .cl-spin,
.cl-item.is-active .cl-spin {
  transform: rotateY(180deg);
}

.cl-face {
  position: absolute;
  inset: 0;
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #fff;
  border-radius: inherit;
  /* Linear so the blur is exactly half-strength at the halfway point, which is
     where the two faces swap. */
  transition: filter var(--cl-turn) linear;
}

/* Both faces are sharp at rest and blurred at the far end of the turn. Since a
   face is only visible for the half of the turn nearest its own resting angle,
   nobody ever sees the full blur: the front ramps 0 to half as it turns away
   and is hidden past 90deg, and the back arrives at 90deg already half blurred
   and resolves to sharp. A transition cannot peak in the middle, so the peak
   is put where each face disappears instead. Fully reversible, and no blur on
   load, which is what an animation-based version would have cost.

   The two faces need DIFFERENT blur amounts, which is not a fudge. Blur radius
   is absolute pixels, so the same figure does very different things to a logo
   and to 15px text: at 4px the logo reads as soft focus while the text is
   destroyed, because its strokes are thinner than the radius. The back face
   therefore gets its own smaller value. */
.cl-face                                    { filter: blur(0); }
.cl-face--back                              { filter: blur(var(--cl-blur-back)); }
.cl-item:hover     .cl-face,
.cl-item:focus-within .cl-face,
.cl-item.is-active .cl-face                 { filter: blur(var(--cl-blur)); }
.cl-item:hover     .cl-face--back,
.cl-item:focus-within .cl-face--back,
.cl-item.is-active .cl-face--back           { filter: blur(0); }

/* Pre-rotated so it reads the right way round when the turn brings it forward,
   the same trick the card faces use. */
.cl-face--back {
  transform: rotateY(180deg);
  flex-direction: column;
  text-align: center;
  padding: 8px 12px;
  gap: 2px;
}

@media (prefers-reduced-motion: reduce) {
  .cl-spin, .cl-face { transition: none; }
  .cl-face { filter: none !important; }
}

.cl-name {
  font-family: var(--nd-font);
  font-size: var(--nd-fs-body);
  font-weight: 600;
  color: var(--nd-ink);
  margin: 0 0 2px;
  letter-spacing: 0.02em;
}
.cl-desc {
  font-family: var(--nd-font);
  font-size: var(--nd-fs-body);
  color: var(--nd-muted);
  margin: 0;
  line-height: 1.35;
}

/* Touch has no hover, so the tap-toggled .is-active is what holds the turn
   there. Handled by the .cl-item.is-active rule above, alongside hover and
   focus, so nothing extra is needed here. */


/* ============================================================================
   HEADER STRAPLINE: cycling text, cross-fade (lighter blur), JS-driven.
   Auto-detects however many .strap-line children are present, add or
   remove lines in the HTML and the timing recalculates itself, no CSS edits.

   .strap        - container (fixed height, hides overflow)
   .strap-line   - each line of text; JS toggles .is-active
   ============================================================================ */

.strap {
  position: relative;
  height: 1.3em;
  min-width: 220px;
  color: #6b6d7a;
  letter-spacing: 0.02em;
  overflow: hidden;
}
.strap-line {
  display: inline;               /* works whether the tag is <span>, <p>, or <div> */
  position: absolute;
  left: 0;
  top: 0;
  white-space: nowrap;
  opacity: 0;
  filter: blur(2px);
  transition: opacity 0.6s ease, filter 0.6s ease;
  pointer-events: none;
}
.strap-line.is-active {
  opacity: 1;
  filter: blur(0);
}

@media (prefers-reduced-motion: reduce) {
  .strap-line { transition: opacity 0.25s ease; filter: none !important; }
}


/* ============================================================================
   HEADER STRAPLINE: TYPEWRITER variant.
   Add .strap--type to a .strap container (alongside the same .strap-line
   spans used by the cross-fade version) to switch mechanics: JS reads each
   line's text, types it out letter-by-letter, holds, deletes it letter-by-
   letter, then moves to the next line. Auto-detects line count, same as
   the cross-fade version.

   Markup is IDENTICAL to the cross-fade strap: .strap-line spans hold the
   source text (hidden), and JS writes into a separate visible output span.
   ============================================================================ */

.strap--type .strap-line {
  /* the source lines are never shown directly in typewriter mode,
     JS reads their text then hides them permanently */
  position: absolute;
  opacity: 0 !important;
  pointer-events: none;
}

.strap-type-out {
  font: inherit;
  color: inherit;
  white-space: nowrap;
}

.strap-cursor {
  display: inline-block;
  width: 1px;
  margin-left: 1px;
  background: currentColor;
  animation: strapBlink 0.9s step-end infinite;
}
@keyframes strapBlink {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
  .strap-cursor { animation: none; opacity: 1; }
}


/* ============================================================================
   TESTIMONIALS - floating bubble field (drifting rows) + expandable full quote.

   .ts-field       - full-width container, fixed height, hides overflow
   .ts-row         - one horizontal drifting row (different speed per row)
   .ts-bubble      - one testimonial: avatar, name, role/company, short quote
   .ts-avatar      - circular avatar image
   .ts-quote       - the one-line teaser shown in the bubble
   .ts-name/.ts-role - attribution

   Click a bubble to open .ts-modal with the FULL multi-paragraph quote,
   solves short-vs-long testimonials without the bubble needing to resize.
   ============================================================================ */

.ts-field {
  position: relative;
  overflow: hidden;
  padding: 32px 0;
  display: flex;
  flex-direction: column;
  gap: 28px;
  /* fade the edges so bubbles drift in/out softly rather than clipping hard.
     --ts-fade tunes the reach, .ts-field--no-fade removes them, matching the
     pair the marquee offers. */
  --ts-fade: 6%;
  -webkit-mask-image: linear-gradient(to right, transparent, #000 var(--ts-fade),
                                      #000 calc(100% - var(--ts-fade)), transparent);
  mask-image: linear-gradient(to right, transparent, #000 var(--ts-fade),
                              #000 calc(100% - var(--ts-fade)), transparent);
}
.ts-field--no-fade {
  -webkit-mask-image: none;
  mask-image: none;
}

.ts-row {
  --ts-gap: 20px;
  display: flex;
  gap: var(--ts-gap);
  /* The trailing gap is NOT decoration, it is what makes the loop seamless.
     Flex puts a gap BETWEEN items, so 2N bubbles have only 2N-1 of them and the
     row measures 2N*bubble + (2N-1)*gap. Half of that is half a gap short of
     the real loop distance, which is bubble 1 to bubble N+1, or N*bubble +
     N*gap. Without this padding the -50% below undershoots by exactly gap/2 and
     the row snaps 10px at the end of every cycle. Both values come off the same
     token so they cannot drift apart. */
  padding-right: var(--ts-gap);
  width: max-content;
  animation: tsDrift linear infinite;
  animation-play-state: paused;      /* held until web fonts finish loading, see JS */
}
html.fonts-ready .ts-row { animation-play-state: running; }
/* three speeds: vary per row so they don't feel mechanical */
.ts-row--slow   { animation-duration: 70s; }
.ts-row--medium { animation-duration: 50s; animation-direction: reverse; }
.ts-row--fast   { animation-duration: 38s; }

@keyframes tsDrift {
  from { transform: translateX(0); }
  /* A percentage translate resolves against the BORDER box, so this picks up
     the trailing padding above. The content is duplicated, so -50% then lands
     exactly one set along and the loop is invisible. */
  to   { transform: translateX(-50%); }
}

.ts-bubble {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  gap: 12px;
  background: #fff;
  border: 1px solid var(--nd-line);
  border-radius: 999px;
  padding: 10px 20px 10px 10px;
  cursor: pointer;
  transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
  max-width: 430px;   /* +50px so long job titles (the role line) fit on one line */
}
.ts-bubble:hover {
  border-color: var(--nd-blue);
  box-shadow: 0 4px 14px rgba(0,0,0,0.08);
  transform: translateY(-2px);
}
/* pause the row's drift while any bubble in it is hovered, so it can be read */
.ts-row:hover { animation-play-state: paused; }

.ts-avatar {
  width: 36px;
  height: 36px;
  border-radius: 50%;
  overflow: hidden;
  flex: 0 0 36px;
  background: var(--nd-surface-2);
}
.ts-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }

.ts-body { min-width: 0; }
.ts-quote {
  color: var(--nd-ink);
  margin: 0 0 2px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.ts-attrib {
  color: var(--nd-muted);
  white-space: nowrap;
}
.ts-attrib b { color: var(--nd-ink); font-weight: 600; }
/* The role (job title) is often very long; truncate it with an ellipsis on one
   line like the quote, rather than letting it overflow the pill. The full title
   is shown in the modal on click. */
.ts-role {
  display: block;
  overflow: hidden;
  text-overflow: ellipsis;
}

@media (prefers-reduced-motion: reduce) {
  .ts-row { animation: none; flex-wrap: wrap; width: auto; }
  .ts-field { mask-image: none; -webkit-mask-image: none; }
}

/* ---------- expanded modal: full multi-paragraph quote ---------- */
.ts-modal-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(20, 20, 26, 0.5);
  backdrop-filter: blur(4px);
  -webkit-backdrop-filter: blur(4px);
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.3s ease;
}
.ts-modal-backdrop.is-open { opacity: 1; pointer-events: auto; }

.ts-modal {
  background: #fff;
  border-radius: 16px;
  max-width: 560px;
  width: 100%;
  max-height: 80vh;
  overflow-y: auto;
  padding: 40px;
  position: relative;
  transform: scale(0.96);
  transition: transform 0.3s ease;
}
.ts-modal-backdrop.is-open .ts-modal { transform: scale(1); }

.ts-modal-close {
  position: absolute;
  top: 16px;
  right: 16px;
  width: 32px;
  height: 32px;
  border-radius: 50%;
  border: 0;
  background: var(--nd-surface-2);
  color: var(--nd-ink);
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
}
.ts-modal-close:hover { background: var(--nd-blue); color: #fff; }

.ts-modal-header {
  display: flex;
  align-items: center;
  gap: 14px;
  margin-bottom: 24px;
}
.ts-modal-avatar {
  width: 52px; height: 52px; border-radius: 50%; overflow: hidden; flex: 0 0 52px;
  background: var(--nd-surface-2);
}
.ts-modal-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
.ts-modal-name { font-size: var(--nd-fs-body); font-weight: 700; color: var(--nd-ink); margin: 0; }
.ts-modal-role { font-size: var(--nd-fs-label); color: var(--nd-muted); margin: 2px 0 0; }

.ts-modal-quote { font-size: var(--nd-fs-ui); line-height: 1.7; color: var(--nd-ink); }
.ts-modal-quote p { margin: 0 0 16px; }
.ts-modal-quote p:last-child { margin-bottom: 0; }


/* ============================================================================
   QUOTE ROTATOR: homepage cherry-picked lines, one slide at a time.
   Each .qr-slide uses a DIFFERENT editorial grid layout (variety is the point).
   Auto-advances, cross-fades, progress bar + dots, pauses on hover.
   ============================================================================ */

.qr {
  position: relative;
  max-width: 1100px;
  margin: 0 auto;
  padding: 80px 24px 56px;
  min-height: 260px;
  font-family: var(--nd-font);
}
.qr-track { position: relative; height: 100%; }

.qr-slide {
  position: absolute;
  inset: 0;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.6s ease;
  display: grid;
}
.qr-slide.is-active { opacity: 1; pointer-events: auto; }

.qr-quote {
  font-family: 'Playfair Display', Georgia, serif;
  color: var(--nd-ink);
  margin: 0;
}
.qr-attrib { font-size: var(--nd-fs-label); color: var(--nd-muted); }
.qr-attrib b { color: var(--nd-ink); font-weight: 600; }

/* layout 1: large left quote, right-anchored attribution */
.qr-layout-1 {
  grid-template-columns: 2fr 1fr;
  align-items: center;
  gap: 40px;
}
.qr-layout-1 .qr-quote { font-size: var(--nd-fs-title); line-height: 1.35; }
.qr-layout-1 .qr-attrib { text-align: right; align-self: end; }

/* layout 2: centred single line */
.qr-layout-2 {
  text-align: center;
  justify-items: center;
  gap: 16px;
}
.qr-layout-2 .qr-quote { font-size: var(--nd-fs-title); line-height: 1.3; max-width: 20ch; }

/* layout 3: three-column split with vertical rule */
.qr-layout-3 {
  grid-template-columns: 1fr auto 2fr;
  align-items: center;
  gap: 32px;
}
.qr-layout-3 .qr-rule { width: 1px; height: 60px; background: var(--nd-line); }
.qr-layout-3 .qr-quote { font-size: var(--nd-fs-lead); line-height: 1.45; }
.qr-layout-3 .qr-attrib { text-align: right; }

/* layout 4: oversized opening quotation mark */
.qr-layout-4 { grid-template-columns: auto 1fr; gap: 8px; align-items: start; }
.qr-layout-4 .qr-mark {
  font-family: 'Playfair Display', Georgia, serif;
  line-height: 0.6;
  color: rgba(0,0,0,0.12);
  grid-row: span 2;
}
.qr-layout-4 .qr-quote { font-size: var(--nd-fs-lead); line-height: 1.4; padding-top: 24px; }
.qr-layout-4 .qr-attrib { grid-column: 2; }

/* layout 5: two-column with floating attribution */
.qr-layout-5 { grid-template-columns: 1fr 1fr; align-items: center; gap: 40px; }
.qr-layout-5 .qr-quote { font-size: var(--nd-fs-lead); line-height: 1.4; }
.qr-layout-5 .qr-attrib {
  justify-self: start;
  padding: 16px 20px;
  border-left: 2px solid var(--nd-blue);
}

/* layout 6: full-width with horizontal rule separator */
.qr-layout-6 { gap: 20px; }
.qr-layout-6 .qr-quote { font-size: var(--nd-fs-lead); line-height: 1.4; text-align: center; }
.qr-layout-6 .qr-hr { height: 1px; background: var(--nd-line); width: 60px; margin: 0 auto; }
.qr-layout-6 .qr-attrib { text-align: center; }

@media (max-width: 720px) {
  .qr-layout-1, .qr-layout-3, .qr-layout-5 { grid-template-columns: 1fr; text-align: left; }
  .qr-layout-3 .qr-rule { display: none; }
  .qr-quote { font-size: var(--nd-fs-lead) !important; }
}

/* progress + dots: pinned bottom-right, out of the text flow entirely */
.qr-controls {
  position: absolute;
  right: 24px;
  bottom: 20px;
  z-index: 5;
  display: flex;
  align-items: center;
  gap: 12px;
  width: 160px;
}
.qr-progress {
  flex: 1;
  height: 2px;
  background: rgba(0,0,0,0.1);
  position: relative;
  overflow: hidden;
}
.qr-progress-fill {
  height: 100%;
  width: 0;
  background: var(--nd-blue);
  transform-origin: left center;
}
@keyframes qrProgressFill { from { transform: scaleX(0); } to { transform: scaleX(1); } }

.qr-dots { display: flex; gap: 6px; }
.qr-dot {
  width: 6px; height: 6px; border-radius: 50%; border: 0; padding: 0;
  background: rgba(0,0,0,0.2); cursor: pointer; transition: background 0.2s;
}
.qr-dot[aria-current="true"] { background: var(--nd-indicator); }


/* ============================================================================
   FLAT TESTIMONIAL CARDS: full quote always visible, no interaction needed.
   For the testimonials page body, below the bubble highlight strip.

   .tf-grid    - responsive grid container
   .tf-card    - one flat card: avatar, name, role, full quote
   ============================================================================ */

/* Bare .section-divider had no rules at all, so its heading sat flush to the
   viewport edge instead of on the same 1100px/24px column as .tf-grid below
   it. Same max-width and side padding, so the two line up. */
.section-divider {
  max-width: 1100px;
  margin: 0 auto;
  padding: 0 24px;
}

.tf-grid {
  max-width: 1100px;
  margin: 0 auto;
  padding: 0 24px 80px;
  display: grid;
  /* min(100%, 320px), not a bare 320px: a track floor wider than its container
     cannot shrink below itself, so it overflows and pushes a horizontal
     scrollbar instead of dropping to one column. It did exactly that at 375px.
     Same fix as .pg-row--auto and .cl-grid already carry. */
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
  gap: 24px;
  align-items: start;
}

.tf-card {
  background: #fff;
  border: 1px solid var(--nd-line);
  border-radius: 12px;
  padding: 28px;
  font-family: var(--nd-font);
}

.tf-header {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-bottom: 16px;
}
.tf-avatar {
  width: 44px; height: 44px; border-radius: 50%; overflow: hidden; flex: 0 0 44px;
  background: var(--nd-surface-2);
}
.tf-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
.tf-name { font-size: var(--nd-fs-ui); font-weight: 600; color: var(--nd-ink); margin: 0; }
.tf-role { font-size: var(--nd-fs-micro); color: var(--nd-muted); margin: 2px 0 0; }

.tf-quote { font-size: var(--nd-fs-ui); line-height: 1.65; color: var(--nd-ink); }
.tf-quote p { margin: 0 0 12px; }
.tf-quote p:last-child { margin-bottom: 0; }

/* subtle opening mark, echoes the homepage rotator's layout 4 */
.tf-mark {
  font-family: 'Playfair Display', Georgia, serif;
  line-height: 0.5;
  color: rgba(0,0,0,0.15);
  display: block;
  margin-bottom: 6px;
}


/* initials-avatar placeholders (used until real photos are added) fill
   whichever avatar container they're placed in */
.ts-avatar > div,
.tf-avatar > div,
.ts-modal-avatar > div {
  width: 100%;
  height: 100%;
}

/* AWARD CARD, the other entry kind the testimonials running order can hold.
   Reuses .tf-card as the outer box on purpose, so it drops into the SAME
   .tf-grid with no layout change: only the inside differs, an image and two
   lines of text instead of an avatar, name and quote. Font sizes are NOT set
   here; .tf-award-title/-desc join the TYPE SCALE block's UI/LABEL groups. */
.tf-card--award {
  display: flex;
  flex-direction: column;
  gap: 12px;
}
.tf-award-image {
  width: 100%;
  aspect-ratio: 4 / 3;
  border-radius: 8px;
  overflow: hidden;
  background: var(--nd-surface-2);
}
.tf-award-image img { width: 100%; height: 100%; object-fit: contain; display: block; }
.tf-award-title { font-weight: 600; color: var(--nd-ink); margin: 0; }
.tf-award-desc { margin: 0; line-height: 1.5; }


/* ============================================================================
   BEFORE / AFTER SLIDER: drag or tap to reveal, auto-detects image ratio.

   .ba-slider          - outer wrapper
   .ba-frame           - the clipping frame (aspect-ratio set automatically by JS)
   .ba-after           - full-size "after" image, sits behind
   .ba-before-wrap      - clips the "before" image to the handle position
   .ba-before           - the "before" image (JS keeps it pixel-locked to the
                          frame's width so it never distorts as the wrap narrows)
   .ba-handle           - draggable divider + grip, keyboard accessible
   .ba-label--before/after - optional corner labels

   Works with mouse, touch, and pen via Pointer Events; arrow keys move the
   handle when it has focus. Responsive: portrait, square, and landscape all
   work automatically: the ratio comes from the image, not a fixed CSS value.
   ============================================================================ */

.ba-slider { position: relative; width: 100%; }

.ba-frame {
  position: relative;
  width: 100%;
  aspect-ratio: var(--ba-ratio, 3 / 2);   /* JS sets this once the image loads */
  overflow: hidden;
  border-radius: var(--nd-radius);
  box-shadow: var(--nd-shadow);
  background: var(--nd-surface-2);
  cursor: ew-resize;
  touch-action: pan-y;                     /* lets vertical page-scroll pass through on touch */
  user-select: none;
}

.ba-img {
  position: absolute;
  inset: 0;
  height: 100%;
  object-fit: cover;
  display: block;
  pointer-events: none;
}
.ba-after { width: 100%; }

.ba-before-wrap {
  position: absolute;
  inset: 0;
  width: 50%;                              /* JS updates this on drag */
  overflow: hidden;
}
/* JS sets an explicit pixel width on this image equal to the FRAME's width,
   so the picture never squishes as its wrapper narrows, only clips. */
.ba-before-wrap .ba-img { left: 0; }

.ba-handle {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 50%;                                /* JS updates this on drag */
  transform: translateX(-50%);
  width: 2px;
  background: #fff;
  box-shadow: 0 0 0 1px rgba(0,0,0,0.08);
  cursor: ew-resize;
}
.ba-grip {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 2px 10px rgba(0,0,0,0.2);
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--nd-ink);
  transition: background 0.2s ease, color 0.2s ease;
}
.ba-handle:hover .ba-grip,
.ba-handle:focus-visible .ba-grip,
.ba-handle.is-dragging .ba-grip {
  background: var(--nd-blue);
  color: #fff;
}
.ba-handle:focus-visible { outline: none; }
.ba-handle:focus-visible .ba-grip { box-shadow: 0 0 0 3px rgba(var(--nd-blue-rgb),0.35), 0 2px 10px rgba(0,0,0,0.2); }

/* pulse ring: invites interaction, stops once the person has dragged it once */
.ba-grip::before {
  content: "";
  position: absolute;
  inset: -10px;
  border-radius: 50%;
  border: 2px solid var(--nd-blue);
  opacity: 0;
  animation: baPulse 2.2s ease-out infinite;
  pointer-events: none;
}
.ba-slider.ba-interacted .ba-grip::before { animation: none; opacity: 0; }
@keyframes baPulse {
  0%   { transform: scale(0.75); opacity: 0.55; }
  70%  { opacity: 0; }
  100% { transform: scale(1.7); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .ba-grip::before { animation: none; }
}

.ba-label {
  position: absolute;
  bottom: 12px;
  z-index: 3;
  font-family: var(--nd-font);
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: #fff;
  background: rgba(0,0,0,0.45);
  padding: 4px 10px;
  border-radius: 999px;
  pointer-events: none;
}
.ba-label--before { left: 12px; }
.ba-label--after { right: 12px; }

@media (prefers-reduced-motion: reduce) {
  .ba-handle, .ba-before-wrap { transition: none; }
}


/* ============================================================================
   ANNOTATED IMAGE: numbered dots positioned on a photo, linked to descriptions.
   Position each dot with inline custom properties: style="--ai-x:32%; --ai-y:58%;"
   Percentage-based, so it holds correctly at any image size/shape (portrait,
   landscape, square) with no JS calculation needed.

   VARIANT 1: SIMPLE: dots + a synced list, both always visible, no interaction
   required. Hovering a dot highlights its list item and vice versa (bonus link,
   not required to use it).

   .ai-block   - layout: image left, list right (stacks on mobile)
   .ai-media   - image container, dots position relative to this
   .ai-dot     - numbered marker; give it data-ai-index="1" etc. matching the list
   .ai-list    - the description list; each <li> needs matching data-ai-index
   ============================================================================ */

.ai-block {
  display: grid;
  grid-template-columns: 1.3fr 1fr;
  gap: 32px;
  align-items: start;
  max-width: 1100px;
  margin: 0 auto;
}
@media (max-width: 720px) {
  .ai-block { grid-template-columns: 1fr; }
}

.ai-media {
  position: relative;
  width: 100%;
  border-radius: var(--nd-radius);
  overflow: hidden;
  background: var(--nd-surface-2);
}
.ai-media img { width: 100%; height: 100%; display: block; object-fit: cover; }

.ai-dot {
  position: absolute;
  left: var(--ai-x, 50%);
  top: var(--ai-y, 50%);
  transform: translate(-50%, -50%);
  width: 28px;
  height: 28px;
  border-radius: 50%;
  border: 2px solid #fff;
  background: var(--nd-blue);
  color: #fff;
  font-family: var(--nd-font);
  font-weight: 700;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  box-shadow: 0 2px 8px rgba(0,0,0,0.25);
  transition: transform 0.2s ease, background 0.2s ease;
  z-index: 2;
}
.ai-dot::before {
  content: "";
  position: absolute;
  inset: -8px;
  border-radius: 50%;
  border: 2px solid var(--nd-blue);
  opacity: 0;
  animation: baPulse 2.2s ease-out infinite;   /* same pulse used on the before/after handle */
}
.ai-dot:hover,
.ai-dot:focus-visible,
.ai-dot.is-active { transform: translate(-50%, -50%) scale(1.18); background: #1874cc; }
.ai-dot:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(var(--nd-blue-rgb),0.4), 0 2px 8px rgba(0,0,0,0.25); }

.ai-list { font-family: var(--nd-font); }
.ai-list ol { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 16px; }
.ai-list li {
  display: flex;
  gap: 12px;
  padding: 10px;
  border-radius: 8px;
  transition: background 0.2s ease;
  cursor: pointer;
}
.ai-list li:hover,
.ai-list li.is-active { background: rgba(var(--nd-blue-rgb),0.08); }
.ai-list-num {
  flex: 0 0 24px;
  height: 24px;
  border-radius: 50%;
  background: var(--nd-blue);
  color: #fff;
  font-weight: 700;
  display: flex;
  align-items: center;
  justify-content: center;
}
.ai-list-text { font-size: var(--nd-fs-label); line-height: 1.5; color: var(--nd-ink); margin: 0; padding-top: 2px; }

@media (prefers-reduced-motion: reduce) {
  .ai-dot::before { animation: none; }
}

/* ============================================================================
   VARIANT 4: CAROUSEL / GUIDED TOUR: step through each dot one at a time,
   with prev/next and a text panel. Stage 0 is an overview (all dots pulsing,
   nothing highlighted); stages 1..N spotlight one dot each.

   .ai-tour        - outer wrapper (image + panel side by side, stacks on mobile)
   .ai-tour-media  - same dot markup as the simple variant
   .ai-tour-panel  - the text + controls
   .ai-tour-text   - swaps per step (data-ai-step on each dot must match)
   .ai-tour-dots   - small step indicators, auto-generated by JS
   ============================================================================ */

.ai-tour { display: grid; grid-template-columns: 1.3fr 1fr; gap: 32px; align-items: center; max-width: 1100px; margin: 0 auto; }
@media (max-width: 720px) { .ai-tour { grid-template-columns: 1fr; } }

.ai-tour-media { position: relative; width: 100%; border-radius: var(--nd-radius); overflow: hidden; background: var(--nd-surface-2); }
.ai-tour-media img { width: 100%; height: 100%; display: block; object-fit: cover; }

/* during the overview stage every dot pulses; once a step is chosen, only
   the active dot is prominent and the rest dim back */
.ai-tour-media .ai-dot { transition: opacity 0.3s ease, transform 0.2s ease, background 0.2s ease; }
.ai-tour-media.is-stepping .ai-dot { opacity: 0.35; }
.ai-tour-media.is-stepping .ai-dot::before { animation: none; opacity: 0; }
.ai-tour-media.is-stepping .ai-dot.is-active { opacity: 1; background: #1874cc; transform: translate(-50%, -50%) scale(1.25); }

.ai-tour-panel { font-family: var(--nd-font); }
.ai-tour-eyebrow { font-size: var(--nd-fs-micro); text-transform: uppercase; letter-spacing: 0.08em; color: var(--nd-muted); margin: 0 0 8px; }
.ai-tour-text { font-size: var(--nd-fs-ui); line-height: 1.6; color: var(--nd-ink); margin: 0 0 24px; min-height: 3.2em; }

.ai-tour-controls { display: flex; align-items: center; gap: 14px; }
.ai-tour-btn {
  width: 34px; height: 34px; border-radius: 50%; border: 1px solid var(--nd-line);
  background: #fff; color: var(--nd-ink); cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease;
}
.ai-tour-btn:hover { border-color: var(--nd-blue); background: var(--nd-blue); color: #fff; }
.ai-tour-btn:disabled { opacity: 0.35; cursor: default; }
.ai-tour-btn:disabled:hover { border-color: var(--nd-line); background: #fff; color: var(--nd-ink); }

.ai-tour-dots { display: flex; gap: 6px; }
.ai-tour-dots .ai-tour-step-dot {
  width: 6px; height: 6px; border-radius: 50%; border: 0; padding: 0;
  background: rgba(0,0,0,0.2); cursor: pointer; transition: background 0.2s, width 0.2s;
}
.ai-tour-dots .ai-tour-step-dot[aria-current="true"] { background: var(--nd-blue); width: 16px; border-radius: 3px; }


/* ============================================================================
   TILT-3D - standalone pointer-tilt, usable on ANY element (img, div, logo,
   whatever). Unlike the card system's tilt (which needs a wrapper to avoid
   fighting the crossfade), this applies directly to the element itself,
   perspective and rotation both live in one transform, so no wrapper needed.

   Usage:  <img class="tilt-3d" src="logo.png" alt="">
           <div class="tilt-3d">...</div>
   ============================================================================ */

.tilt-3d {
  transition: transform 0.35s ease;
  transform: perspective(650px) translateY(var(--t3d-y, 0px)) rotateX(var(--t3d-ty, 0deg)) rotateY(var(--t3d-tx, 0deg));
  will-change: transform;
}

/* page-wide mode: reacts to the cursor anywhere on the page, not just on
   hover: tilts continuously toward wherever the mouse is. Slightly softer
   transition since it's updating constantly rather than on enter/leave. */
.tilt-3d--page {
  transition: transform 0.5s ease-out;
}

@media (prefers-reduced-motion: reduce) {
  .tilt-3d { transform: none; transition: none; }
}


/* ============================================================================
   REVEAL - fade + ease upward as an element enters the viewport on scroll.
   Add .reveal to any element. Fires once (won't re-hide if you scroll back
   up past it), the common, calmer choice for a portfolio. Stagger multiple
   items with data-reveal-delay="0.1s" (or any value) on each one.

   Usage:  <div class="reveal">...</div>
           <div class="reveal" data-reveal-delay="0.15s">...</div>
   ============================================================================ */

.reveal {
  opacity: 0;
  transform: translateY(28px);
  transition: opacity 0.7s ease, transform 0.7s cubic-bezier(0.16, 1, 0.3, 1);
}
.reveal.is-revealed {
  opacity: 1;
  transform: translateY(0);
}

/* An element can carry only ONE transform, and .reveal is declared after
   .tilt-3d, so on an element with both classes .reveal's translateY would
   cancel the tilt outright. Re-declare the tilt here (0,2,0 beats .reveal's
   0,1,0) and feed the reveal's offset through the tilt's own translate slot,
   so the two compose instead of fighting. */
.reveal.tilt-3d {
  --t3d-y: 28px;
  transform: perspective(650px) translateY(var(--t3d-y)) rotateX(var(--t3d-ty, 0deg)) rotateY(var(--t3d-tx, 0deg));
  transition: opacity 0.7s ease, transform 0.7s cubic-bezier(0.16, 1, 0.3, 1);
}
.reveal.tilt-3d.is-revealed {
  --t3d-y: 0px;
}

/* Light stagger so a row does not arrive as one block. Done with nth-child
   rather than the component's data-reveal-delay hook: it needs no attribute
   per tile, and it survives the grid reflowing to fewer columns. */
.cl-grid .cl-item.reveal:nth-child(4n + 2) { transition-delay: 60ms; }
.cl-grid .cl-item.reveal:nth-child(4n + 3) { transition-delay: 120ms; }
.cl-grid .cl-item.reveal:nth-child(4n + 4) { transition-delay: 180ms; }

@media (prefers-reduced-motion: reduce) {
  .reveal { opacity: 1; transform: none; transition: none; }
  .reveal.tilt-3d { --t3d-y: 0px; transition: none; }
  .cl-grid .cl-item.reveal { transition-delay: 0s; }
}


/* ============================================================================
   FREEFORM LAYOUT: percentage-positioned items that collapse to a plain
   vertical stack on mobile. This is the PATTERN the paper notes (.cs-notes /
   .cs-note) already use, generalised: content-agnostic, no paper styling, no
   tilt, no colour, so it works for an image, a text block, a card, anything
   that wants a free desktop layout without becoming unreadable on a phone.
   The paper notes are left as their own thing rather than refactored onto
   this, since they already ship and work; this is the same mechanism made
   reusable for whatever comes next.

   .nd-freeform       the container (position relative, a height for the
                       percentage positions to resolve against)
   .nd-freeform-item  a child, positioned by --nx / --ny (percentages) and
                       optionally --nw (width) / --nr (rotation, degrees)

   Combine with .drag-field / .draggable (below) for interactivity: they are
   deliberately separate concerns, since not everything positioned freely
   needs to be draggable too.
   ============================================================================ */
.nd-freeform {
  --nd-freeform-h: 440px;
  position: relative;
  height: var(--nd-freeform-h);
}
.nd-freeform-item {
  position: absolute;
  left: var(--nx, 0);
  top: var(--ny, 0);
  width: var(--nw, auto);
  max-width: 90%;
  box-sizing: border-box;
  transform: rotate(var(--nr, 0deg));
}

@media (max-width: 839px) {
  .nd-freeform {
    height: auto;
    display: flex;
    flex-direction: column;
    gap: 16px;
  }
  /* .nd-freeform .nd-freeform-item, not a bare selector: an item also
     carrying .draggable (below) would set position: absolute at the SAME
     specificity and lose to source order otherwise, same trap .cs-notes,
     .pg-row--auto and the page-nav's own mobile block already hit. */
  .nd-freeform .nd-freeform-item {
    position: static;
    width: auto;
    max-width: none;
    transform: none;   /* a tilt in a vertical stack just looks broken */
  }
}


/* ============================================================================
   GLASS: a reusable translucent/blurred surface, add .glass to any element in
   any builder. Same blur+saturate pairing the site header's own glass variant
   already uses (--sh-glass-blur/-sat), generalised into its own tokens so any
   element can have the effect without touching the header's.

   USE DELIBERATELY, NOT EVERYWHERE. backdrop-filter is one of the more
   expensive things a browser paints; this codebase already stripped it back
   to a single instance once purely for that cost (see the pcard/testimonials
   modal history). A handful of glass panels on a page is fine; glass on every
   card is not.

   --glass-blur  blur radius
   --glass-sat   saturation boost (values above 1 make what is behind it look
                 richer, which is most of what reads as "glass" rather than
                 "smudged")
   --glass-tint  the translucent fill; override per instance for a dark
                 surface, e.g. style="--glass-tint: rgba(20,20,24,.45)"

   No @supports fallback needed: a browser without backdrop-filter support
   simply ignores that one declaration and keeps the tint, which is a flat
   translucent panel rather than a broken one.
   ============================================================================ */
.glass {
  --glass-blur: 16px;
  --glass-sat: 1.6;
  --glass-tint: rgba(255, 255, 255, .55);
  background: var(--glass-tint);
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-sat));
  backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-sat));
}


/* ============================================================================
   DRAGGABLE ITEMS: drag any item within a field; whichever you touch
   comes to the front. Works with mouse, touch, and pen via Pointer Events.

   .drag-field   - the container (position relative, defines the drag bounds)
   .draggable    - any child inside it, add position:absolute + a starting
                   left/top in your own markup/CSS; the JS only handles
                   movement and z-index, not initial placement.
   ============================================================================ */

.drag-field {
  position: relative;
}
.draggable {
  position: absolute;
  cursor: grab;
  touch-action: none;     /* stops the browser's own touch-scroll/zoom taking over the gesture */
  user-select: none;
  /* Stops the browser's native "drag this image" gesture stealing the pointer.
     Without it an item holding an <img> would bring itself to the front on
     pointerdown and then refuse to move, because the native drag starts and
     no further pointermove is delivered. Needed on the image too, not just
     the wrapper, since it is the img that carries the native behaviour. */
  -webkit-user-drag: none;
}

.draggable img {
  -webkit-user-drag: none;
  pointer-events: none;   /* the wrapper owns the gesture, not the picture inside it */
}
.draggable.is-dragging {
  cursor: grabbing;
}

/* ============================================================================
   TOOLTIP - hover (or tap, on touch) an element with .tooltip to reveal a
   bubble above it. Pure CSS on desktop (the text comes from data-tooltip via
   attr()); a small JS module only handles the tap-to-toggle on touch, since
   touch devices have no hover to trigger it.

   Usage: <span class="tooltip" data-tooltip="Some text">Hover me</span>
   ============================================================================ */

.tooltip {
  position: relative;
  cursor: help;
}
.tooltip::after {
  content: attr(data-tooltip);
  position: absolute;
  bottom: 100%;
  left: 50%;
  transform: translateX(-50%) translateY(4px) scale(0.95);
  margin-bottom: 8px;
  background: #1c1d24;
  color: #fff;
  font-family: var(--nd-font);
  line-height: 1.3;
  white-space: nowrap;
  padding: 6px 10px;
  border-radius: 6px;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.18s ease, transform 0.18s ease;
  z-index: 50;
}
.tooltip::before {
  content: "";
  position: absolute;
  bottom: 100%;
  left: 50%;
  transform: translateX(-50%) translateY(3px);
  margin-bottom: 2px;
  border: 5px solid transparent;
  border-top-color: #1c1d24;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.18s ease, transform 0.18s ease;
  z-index: 50;
}
.tooltip:hover::after,
.tooltip:hover::before,
.tooltip.is-active::after,
.tooltip.is-active::before {
  opacity: 1;
  transform: translateX(-50%) translateY(0) scale(1);
}
.tooltip:hover::before,
.tooltip.is-active::before { transform: translateX(-50%) translateY(-3px); }

/* The bubble is nowrap by default, which is right on a desktop line but is what
   sends it off the side of a phone: it is centred on its trigger, so a trigger
   near the right edge puts half a 122px bubble past the viewport and pushes a
   horizontal scrollbar. Wrapping it and capping the width bounds that.

   WHAT THIS ACTUALLY FIXES is a LONG tooltip, which used to run as one nowrap
   line of any width. It does nothing for a short one: the cap is 40vw, or 150px
   at 375px, and the reference page's own bubble is 122px of max-content, so the
   max-width never applies and that bubble still overhangs by the same amount.
   Measured before and after at 375px: 36px of page overflow either way.

   THE EDGE CASE CANNOT BE FIXED IN CSS. A bubble centred on its trigger will
   always overhang when the trigger sits closer to the edge than half the
   bubble's width, and choosing a side instead means comparing the trigger's
   position against the viewport, which is a measurement. A CSS-only tooltip has
   no way to do that. The options are a JS-positioned bubble or CSS anchor
   positioning once support is broad enough, and until one of those the residual
   is real: 36px at 375px on this page.
   -------------------------------------------------------------------------- */
@media (max-width: 720px) {
  .tooltip::after { white-space: normal; max-width: 40vw; width: max-content; }
}

@media (prefers-reduced-motion: reduce) {
  .tooltip::after, .tooltip::before { transition: opacity 0.1s ease; }
}

/* ============================================================================
   CUSTOM CURSOR: while the mouse is over a .cursor-zone, the native cursor
   hides and a custom element (image or shape) follows the pointer instead.
   Outside the zone, the standard cursor returns automatically.

   Usage: <div class="cursor-zone" data-cursor-image="url(icon.png)">...</div>
   Omit data-cursor-image to use a plain dot instead.
   ============================================================================ */

.cursor-zone { cursor: none; }

.custom-cursor {
  position: fixed;
  top: 0;
  left: 0;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background: var(--nd-blue);
  background-size: contain;
  background-repeat: no-repeat;
  background-position: center;
  pointer-events: none;
  z-index: 9999;
  opacity: 0;
  transform: translate(-50%, -50%);
  transition: opacity 0.15s ease, width 0.15s ease, height 0.15s ease;
}
.custom-cursor.is-visible { opacity: 1; }

@media (hover: none) {
  /* touch devices have no real cursor to replace, never show it there */
  .custom-cursor { display: none; }
}

/* ============================================================================
   CURSOR TRAIL: a single continuous, elastic ribbon following the cursor,
   tapering and fading toward the tail with a chromatic-aberration fringe.
   Drawn on a full-screen canvas (a connected line, not separate dots), which
   is what makes it read as one continuous trail rather than a string of
   gapped circles). Toggle globally with .has-cursor-trail on <body>.
   ============================================================================ */

.cursor-trail-canvas {
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 9998;
}

@media (prefers-reduced-motion: reduce) {
  .cursor-trail-canvas { display: none; }
}


/* ============================================================================
   SCROLL PROGRESS BAR: a thin bar on one edge of the viewport showing how
   far down the page you've scrolled. Track at 20% opacity, fill in
   royal blue at 80% opacity. Pairs naturally with the in-page scrollspy nav
   (.page-nav), which shows WHICH section you're in, this shows HOW FAR
   through the whole page you are.

   Usage:  <div class="scroll-progress scroll-progress--top">
             <div class="scroll-progress-fill"></div>
           </div>

   Edge modifiers: --top (default) / --bottom / --left / --right
   ============================================================================ */

.scroll-progress {
  position: fixed;
  z-index: 70;
  background: rgba(0, 0, 0, 0.2);   /* track */
  pointer-events: none;
}
.scroll-progress-fill {
  background: var(--nd-indicator);
}

/* top / bottom: full-width horizontal bar, fill grows left -> right */
.scroll-progress--top,
.scroll-progress--bottom {
  left: 0;
  right: 0;
  height: 4px;
}
.scroll-progress--top { top: 0; }
.scroll-progress--bottom { bottom: 0; }
.scroll-progress--top .scroll-progress-fill,
.scroll-progress--bottom .scroll-progress-fill {
  height: 100%;
  width: 0%;              /* JS sets this on scroll */
  transform-origin: left;
}

/* left / right: full-height vertical bar, fill grows top -> bottom */
.scroll-progress--left,
.scroll-progress--right {
  top: 0;
  bottom: 0;
  width: 4px;
}
.scroll-progress--left { left: 0; }
.scroll-progress--right { right: 0; }
.scroll-progress--left .scroll-progress-fill,
.scroll-progress--right .scroll-progress-fill {
  width: 100%;
  height: 0%;              /* JS sets this on scroll */
  transform-origin: top;
}


/* ============================================================================
   SCROLL PROGRESS: SEGMENTED variant. Add .scroll-progress--segmented for
   a track divided into one chunk per page section, each chunk sized
   proportionally to that section's actual rendered height (not just an
   equal split). The fill still sweeps continuously across the whole bar
   showing true scroll percentage: segments you've passed read as solid
   blue; segments still ahead show as distinct grey chunks with a small gap
   between them marking each section boundary.

   The JS reads the SAME sections your .page-nav-link hrefs point to, so it
   automatically matches however many sections your scrollspy nav has.
   ============================================================================ */

.scroll-progress--segmented { --sp-gap: 6px; }   /* increase to space segments further apart */

/* SEGMENTED layout: the grey track and blue fill now sit side by side as
   two separate lanes (not overlapping), which also avoids the sub-pixel
   rendering seams that overlapping absolute+transform elements can cause.
   Each lane is its own row (top/bottom bars) or column (left/right bars). */
/* Bottom, segmented: the version actually in use. Two lanes stacked
   (grey track, blue fill), each built from real pixel measurements rather
   than percentages, and the fill reveals via a plain overflow:hidden clip,
   the same reliable technique the original single-block bar used. This
   avoids transform/scale entirely, which is what caused the earlier bug. */
.scroll-progress--bottom.scroll-progress--segmented {
  height: 9px;
  display: flex;
  flex-direction: column;
  gap: 1px;
  background: none;
}
.scroll-progress--bottom.scroll-progress--segmented .scroll-progress-track {
  position: static;
  width: auto;
  height: 4px;
  display: flex;
  gap: var(--sp-gap);
}
.scroll-progress--bottom.scroll-progress--segmented .scroll-progress-fill-clip {
  position: relative;
  width: 0;              /* JS sets this, in px, to reveal the fill */
  height: 4px;
  overflow: hidden;
}
.scroll-progress--bottom.scroll-progress--segmented .scroll-progress-fill {
  position: absolute;
  top: 0; left: 0;
  height: 4px;
  display: flex;
  gap: var(--sp-gap);
  background: none;
  width: auto;            /* JS sets an explicit px width so it never shrinks with its clipped parent */
}

.scroll-progress-segment {
  background: rgba(0, 0, 0, 0.2);
  flex: 0 0 auto;
  pointer-events: auto;      /* clickable, despite the bar's own pointer-events:none */
  cursor: pointer;
  transition: background-color 0.15s ease;
  -webkit-tap-highlight-color: transparent;   /* suppress the default grey tap flash so :active shows instead */
}
.scroll-progress-segment:hover,
.scroll-progress-segment:active {
  background: rgba(0, 0, 0, 0.4);   /* darken on hover, and on touch press as the touchscreen equivalent */
}
.scroll-progress-fill-segment {
  background: var(--nd-indicator);
  flex: 0 0 auto;
  pointer-events: auto;      /* clickable, mirrors the grey track segment beside it */
  cursor: pointer;
  transition: background-color 0.15s ease;
  -webkit-tap-highlight-color: transparent;
}
.scroll-progress-fill-segment:hover,
.scroll-progress-fill-segment:active {
  /* Lightens rather than shifting hue, now that the fill is black: skyblue on
     a black bar read as a different control rather than the same one hovered.
     Applies on touch press too, as the touchscreen equivalent of hover. */
  background: #555;
}


/* .scroll-progress--floating: pulls the bar in from the edge with a 2rem
   margin on all applicable sides, so it reads as a floating element rather
   than flush against the viewport edge. */
.scroll-progress--floating.scroll-progress--top {
  top: 2rem; left: 2rem; right: 2rem; bottom: auto;
}
.scroll-progress--floating.scroll-progress--bottom {
  bottom: 2rem; left: 2rem; right: 2rem; top: auto;
}
.scroll-progress--floating.scroll-progress--left {
  left: 2rem; top: 2rem; bottom: 2rem; right: auto;
}
.scroll-progress--floating.scroll-progress--right {
  right: 2rem; top: 2rem; bottom: 2rem; left: auto;
}
.scroll-progress--floating { border-radius: 4px; overflow: hidden; }


/* Left, segmented: same pixel-based clip-reveal technique as bottom,
   rotated: two lanes sit SIDE BY SIDE (not stacked), each running the full
   height with segments stacked top-to-bottom, height driving the reveal
   instead of width. */
.scroll-progress--left.scroll-progress--segmented {
  width: 9px;
  display: flex;
  flex-direction: row;
  gap: 1px;
  background: none;
}
.scroll-progress--left.scroll-progress--segmented .scroll-progress-track {
  position: static;
  width: 4px;
  height: auto;
  display: flex;
  flex-direction: column;
  gap: var(--sp-gap);
}
.scroll-progress--left.scroll-progress--segmented .scroll-progress-fill-clip {
  position: relative;
  width: 4px;
  height: 0;              /* JS sets this, in px, to reveal the fill */
  overflow: hidden;
}
.scroll-progress--left.scroll-progress--segmented .scroll-progress-fill {
  position: absolute;
  top: 0; left: 0;
  width: 4px;
  display: flex;
  flex-direction: column;
  gap: var(--sp-gap);
  background: none;
  height: auto;            /* JS sets an explicit px height so it never shrinks with its clipped parent */
}


/* ============================================================================
   PAGE-NAV - V1 / V2 SWITCH. Two indicator styles for the same scrollspy
   nav, chosen by class on the wrapper:

   .page-nav-combo--v1  - original: small dash/block markers per link
                          (this is just the existing .page-nav, unchanged,
                          use it directly with no wrapper if you only want v1)
   .page-nav-combo--v2  - new: the segmented scroll-progress bar sits beside
                          the text, doubling as the indicator (per-link
                          dashes are hidden, since the bar now does that job)

   Mobile (<720px): text hides either way (existing rule): v1 shows just
   the dashes, v2 shows just the bar. No extra mobile-specific code needed;
   this falls out of the two pieces already behaving independently.
   ============================================================================ */

.page-nav-combo {
  position: fixed;
  top: 50%;
  left: 2rem;
  transform: translateY(-50%);
  z-index: 900;
  display: flex;
  align-items: stretch;
  gap: 14px;
}

/* Right-aligned variant: a full mirror. The nav pins to the right edge, the bar
   sits hard against that edge (row-reverse puts it last in the visual order),
   and the labels sit to its LEFT, right-aligned, ending one flex gap before it.
   The 1rem gap IS that standoff, so the label ends land 1rem from the bar. The
   label-placement JS flips the labels to right: 0 for this variant (see
   combined.js); everything vertical is unchanged. */
.page-nav-combo--right {
  left: auto;
  right: 2rem;
  flex-direction: row-reverse;
  gap: 1rem;
}
/* The JS pushes the label column with margin-left for the left variant; here the
   row-reverse flow and the 1rem gap place it, so that push must be off. */
.page-nav-combo--right .page-nav { margin-left: 0; }
/* Labels are pinned by their right edge on this side, so a wrapped label must
   grow leftward and its lines stay flush right, ending on the same line 1rem
   from the bar rather than ragged. */
.page-nav-combo--right .page-nav-link { text-align: right; }
.page-nav-combo .page-nav {
  position: static;   /* the combo wrapper handles fixed positioning instead */
  top: auto;
  left: auto;
}

/* v1: hide the embedded bar (if present in the markup): dashes are the
   indicator here, same as .page-nav has always behaved */
.page-nav-combo--v1 .scroll-progress {
  display: none;
}

/* v2: hide the per-link dash, the embedded bar is the indicator instead */
.page-nav-combo--v2 .page-nav-link::before {
  display: none;
}
.page-nav-combo--v2 .page-nav-link {
  padding-left: 0;   /* the dash's reserved space is no longer needed */
  /* A SHORT label stays on one line; a long one WRAPS instead of running on and
     overlapping the page content beside the nav. width: max-content sizes the
     box to the label's natural single-line width, and --pnav-label-max caps it,
     so wrapping only kicks in once a label is genuinely long. The collision
     guard in combined.js measures each label's real height, so a wrapped label
     just pushes the next one further down; nothing else is needed. */
  white-space: normal;
  width: max-content;
  max-width: var(--pnav-label-max, 13rem);
}

/* the embedded bar in v2: same segmented/clip-reveal component as the
   standalone one, just placed beside the nav instead of at the page edge */
.page-nav-combo--v2 .scroll-progress {
  position: static;   /* sits in the flex row rather than being independently fixed */
}


/* ============================================================================
   PAGE-NAV V2: refinements: 1rem track/fill (overlaid, not side-by-side),
   an optional taller inset variant, and text links sized to match their
   own segment's proportion so each link's top aligns with its bar segment.
   ============================================================================ */

/* 1rem track + fill, blue OVERLAID on top of grey (not adjacent lanes) */
.page-nav-combo--v2 .scroll-progress--left.scroll-progress--segmented {
  /* The container is now as narrow as the ink. It used to stay at 1rem while
     the visible bar thinned, which meant the label gap was measured from an
     edge 11px to the right of anything you could see: the labels looked far
     away no matter what --pnav-gap was set to. */
  width: var(--pnav-bar-w);
  position: relative;   /* fill-clip positions absolutely on top of the track */
}
.page-nav-combo--v2 .scroll-progress--left.scroll-progress--segmented .scroll-progress-track {
  /* The grey is now as narrow as the fill. What stays 1rem is the CONTAINER,
     which is what the label-placement JS measures, so the text column does not
     move when the visible bar gets thinner. */
  width: var(--pnav-fill-w);
  height: 100%;
}

/* Visible weight and hit area are separate concerns. The bar is 5px of ink but
   a 5px pointer target is unusable, so each segment carries a transparent
   ::after that stretches the clickable area out to --pnav-hit-w. A pseudo
   element takes pointer events on behalf of the element that owns it, so this
   needs no extra markup and no JS.

   It reaches RIGHT, into the gap between the bar and the labels, which is
   empty: the labels start at the container's right edge plus --pnav-gap, so
   the two never overlap. */
.page-nav-combo--v2 .scroll-progress--left.scroll-progress--segmented {
  --pnav-hit-w: 1.75rem;
}
.page-nav-combo--v2 .scroll-progress--left.scroll-progress--segmented .scroll-progress-segment,
.page-nav-combo--v2 .scroll-progress--left.scroll-progress--segmented .scroll-progress-fill-segment {
  position: relative;
}
.page-nav-combo--v2 .scroll-progress--left.scroll-progress--segmented .scroll-progress-segment::after,
.page-nav-combo--v2 .scroll-progress--left.scroll-progress--segmented .scroll-progress-fill-segment::after {
  content: '';
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  width: var(--pnav-hit-w);
}
/* The fill is NARROWER than the track it sits on, and flush to its left edge.
   Black is a much stronger read than the old translucent blue, so at full width
   the progress bar competed with the page rather than reporting on it. A third
   of the width keeps it legible as a position marker while letting the grey
   skeleton carry the shape of the page. The grey is untouched: it still spans
   the full 1rem.

   Expressed as a division of the bar width rather than a literal, so the
   proportion survives a change to --pnav-bar-w. */
.page-nav-combo--v2 .scroll-progress--left.scroll-progress--segmented {
  /* Grey and black are the same width now, so one token covers the ink and the
     container both. --pnav-hit-w is what keeps the target usable at this size. */
  --pnav-bar-w: 0.35rem;
  --pnav-fill-w: var(--pnav-bar-w);
}
.page-nav-combo--v2 .scroll-progress--left.scroll-progress--segmented .scroll-progress-fill-clip {
  position: absolute;
  top: 0;
  left: 0;   /* flush left against the track, not centred in it */
  width: var(--pnav-fill-w);
  height: 0;             /* JS-controlled */
  z-index: 1;            /* sits above the track */
}
.page-nav-combo--v2 .scroll-progress--left.scroll-progress--segmented .scroll-progress-fill {
  width: var(--pnav-fill-w);
}

/* v2 tunables: change these two to retune the component:
   --pnav-gap   : horizontal space between the bar and the text labels
   --pnav-bar-h : bar height, as a share of the viewport. The combo is fixed
                  and vertically centred (top: 50% plus a -50% translate), so
                  the height alone sets the margins: 80vh leaves exactly 10vh
                  clear above and 10vh below at every viewport size. */
.page-nav-combo--v2 {
  gap: 0;   /* the JS measures and sets the bar-to-label distance itself; a flex
               gap here would be added on top of --pnav-gap, not absorbed by it */
  --pnav-gap: 0.5rem;   /* bar-to-label gap, rendered exactly by the JS measurement */
  --pnav-bar-h: 80vh;   /* 10vh clear top and bottom, from the centring above */
}
/* The bar is taken out of flow (position:absolute) and given an explicit
   height, so it no longer depends on the nav's natural (short) height. It's
   pinned to the top of the combo; the text column below reserves matching
   horizontal space so the two sit side by side. .page-nav-combo is already
   position:fixed (see base rule), a valid positioning context for this. */
.page-nav-combo--v2 .scroll-progress {
  position: absolute;
  top: 0;
  left: 0;
  height: var(--pnav-bar-h);
}
.page-nav-combo--v2 .page-nav {
  /* The hit area now reaches under the labels, so they need to sit above it.
     No behaviour is lost where they overlap: a label links to the same section
     its segment does. */
  position: relative;
  z-index: 2;
  margin-left: calc(var(--pnav-bar-w) + var(--pnav-gap));   /* pre-JS fallback only; the JS overwrites this with a measured value once it runs. */
  padding: 0;   /* links are positioned from the nav's own edge; no padding
                   should sit between the bar and the labels */
}

/* Each text link's TOP is pinned to its bar segment's top by the segmented
   progress-bar JS: it positions the links absolutely (reusing the very same
   section measurements that size the segments) and sets .page-nav to
   position:relative with a locked height as their positioning context.
   Links keep their natural height so short sections' labels stay readable,
   so nothing is sized here. On mobile the JS leaves the links alone for the
   base indicator-only layout. No overflow:hidden: that was a remnant of the
   earlier size-to-segment approach and would clip the labels. */
.page-nav-combo--v2 .page-nav-link {
  padding-top: 0;    /* the JS pins the link box top to the segment top; zeroing
                        the top padding brings the label's TEXT top up to meet it */
  line-height: 1.1;
}

/* Mobile: the nav collapses to a narrow indicator strip on the left edge.

   This block MUST sit after .page-nav-combo above. It overrides `left` at the
   same specificity, so on source order alone the base rule would win, which is
   the same trap .pg-row--auto has against the mobile .pg-row rule.

   Three things happen here:
   1. left: 0.5rem, because at 2rem in the strip sat over the body copy, but
      hard against the edge read as a mistake rather than a decision.
   2. --pnav-gap: 0 and a zeroed .page-nav margin, since the labels are hidden
      at this width so the bar-to-label distance is dead space that only makes
      the strip wider. The JS skips its measured placement on mobile, so the
      CSS fallback is what renders.
   3. pointer-events. The combo is a tall fixed box that is mostly empty, and
      it was INTERCEPTING TAPS on the text underneath rather than merely
      overlapping it. Taps now fall through everywhere except the anchors,
      which are the real 44px targets. */
@media (max-width: 720px) {
  .page-nav-combo {
    left: 0.5rem;   /* hard against the edge read as an error, not a decision */
    pointer-events: none;
  }
  .page-nav-combo--right { left: auto; right: 0.5rem; }
  .page-nav-combo .page-nav-link > a { pointer-events: auto; }
  .page-nav-combo--v2 { --pnav-gap: 0; }
  .page-nav-combo--v2 .page-nav { margin-left: 0; }
}


/* ============================================================================
   PASSWORD LOCK SCREEN. .nd-lock replaces WordPress's default password form
   (a bare grey box) on a gated case study, via the_password_form filter in
   the plugin. Styled as a paper note rather than a card face: it sits below
   the project's own pcard on single-nirmtronik_project.php, so it reads as
   the next thing to do rather than as another face of the card above it.
   ============================================================================ */

.nd-lock {
  max-width: 420px;
  margin: 0 auto;
  padding: 2rem;
  background: var(--nd-surface);
  border: 1px solid var(--nd-line);
  border-radius: var(--nd-radius);
  box-shadow: var(--nd-shadow);
  font-family: var(--nd-font);
  text-align: center;
}

.nd-lock-lead {
  margin: 0 0 .35rem;
  font-weight: 600;
  font-size: var(--nd-fs-body);
  color: var(--nd-ink);
}

.nd-lock-note {
  margin: 0 0 1.25rem;
  font-size: var(--nd-fs-ui);
  color: var(--nd-muted);
}

.nd-lock-field {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: .35rem;
  text-align: left;
  margin-bottom: 1rem;
}
.nd-lock-field span {
  font-size: var(--nd-fs-label);
  font-weight: 600;
  color: var(--nd-faint);
  text-transform: uppercase;
  letter-spacing: .04em;
}
.nd-lock-field input {
  width: 100%;
  padding: 10px 14px;
  border: 1px solid var(--nd-line-2);
  border-radius: var(--nd-radius-sm);
  background: var(--nd-surface);
  color: var(--nd-ink);
  font: inherit;
  font-size: var(--nd-fs-body);
  transition: border-color .2s ease, box-shadow .2s ease;
}
.nd-lock-field input:focus {
  outline: none;
  border-color: var(--nd-blue);
  box-shadow: 0 0 0 3px rgba(var(--nd-blue-rgb), .18);
}

/* Dark grey with white text, per Nirm: the unlock reads as a quiet, serious
   action rather than the bright accent CTA the rest of the system uses, which
   suits a gate more than an invitation. */
.nd-lock-submit {
  width: 100%;
  padding: 10px 16px;
  border: 0;
  border-radius: var(--nd-radius-pill);
  background: var(--nd-graphite);
  color: #fff;
  font: inherit;
  font-weight: 600;
  font-size: var(--nd-fs-ui);
  cursor: pointer;
  transition: background .2s ease, box-shadow .2s ease;
}
.nd-lock-submit:hover {
  background: var(--nd-ink);
  box-shadow: 0 8px 18px -8px rgba(28, 29, 50, .45);
}

/* Simple lock, for a plain protected Page: the centred bordered box above. */
.nd-lock-page {
  display: flex;
  justify-content: center;
}

/* The stage centres the lock box in the viewport, below the header, with room
   above and below. min-height leaves the header's own reserved space out of the
   calc so the box sits in the middle of what remains rather than being pushed
   down; the padding guarantees breathing room when the box is tall. */
.nd-lock-stage {
  /* width: 100% is load-bearing: Bricks' #brx-content is a flex column that
     does not stretch its children, so without it the stage shrinks to its
     content and justify-content: center then centres within that shrunk width,
     which reads as left-aligned. Full width lets the box actually centre. */
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 2rem 0;
  /* flex: 1 fills the content area rather than claiming its own 100vh. The
     sticky-footer layout already grows the main content to fill the viewport
     below the header and above the footer, so the stage just fills THAT and
     centres the box in the space actually available, with no extra height
     stacked on top to force a scroll. min-height is a small floor for any
     context where the parent is not a flex column. */
  flex: 1;
  min-height: 50vh;
}

/* Rich lock, for a gated PROJECT: a landscape chamfered box (the card's own
   frame, widened), text on the left, the project's rotating card on the right.
   TWO LAYERS give it the 2px grey rule the card frame has: a border cannot
   follow a clip-path chamfer (it is cut on the diagonals), so the outer box is
   the rule, a grey background showing through 2px of padding, and the inner
   sits on the surface chamfered one rule-width less. Depth is a drop-shadow
   filter, which traces the clipped silhouette where a box-shadow could not. */
.nd-lock-hero-box {
  --nd-lock-chamfer: 16px;
  --nd-lock-rule: 2px;
  --nd-lock-rule-ink: #999;
  max-width: 960px;
  margin: 0 auto;
  padding: var(--nd-lock-rule);
  background: var(--nd-lock-rule-ink);
  clip-path: polygon(
    var(--nd-lock-chamfer) 0, calc(100% - var(--nd-lock-chamfer)) 0,
    100% var(--nd-lock-chamfer), 100% calc(100% - var(--nd-lock-chamfer)),
    calc(100% - var(--nd-lock-chamfer)) 100%, var(--nd-lock-chamfer) 100%,
    0 calc(100% - var(--nd-lock-chamfer)), 0 var(--nd-lock-chamfer)
  );
  filter: drop-shadow(0 24px 44px rgba(28, 29, 50, .16));
  font-family: var(--nd-font);
}
.nd-lock-hero-inner {
  display: grid;
  grid-template-columns: 1fr 1fr;
  align-items: stretch;
  padding: 2.5rem;
  background: var(--nd-surface);
  /* chamfer inset by the rule width, so the grey shows as an even 2px edge */
  clip-path: polygon(
    calc(var(--nd-lock-chamfer) - var(--nd-lock-rule)) 0, calc(100% - var(--nd-lock-chamfer) + var(--nd-lock-rule)) 0,
    100% calc(var(--nd-lock-chamfer) - var(--nd-lock-rule)), 100% calc(100% - var(--nd-lock-chamfer) + var(--nd-lock-rule)),
    calc(100% - var(--nd-lock-chamfer) + var(--nd-lock-rule)) 100%, calc(var(--nd-lock-chamfer) - var(--nd-lock-rule)) 100%,
    0 calc(100% - var(--nd-lock-chamfer) + var(--nd-lock-rule)), 0 calc(var(--nd-lock-chamfer) - var(--nd-lock-rule))
  );
}
@media (max-width: 720px) {
  .nd-lock-hero-inner { grid-template-columns: 1fr; }
}

/* Left: the hook as a headline at the top-left, the form pushed to the
   bottom-LEFT. space-between does the top/bottom split. */
.nd-lock-panel--text {
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  gap: 2rem;
  padding-right: 2rem;
}
/* The top group (labels + hook); the form is pushed below it by space-between. */
.nd-lock-top { width: 100%; }

/* Client and project, moved off the card and placed above the hook. */
.nd-lock-labels {
  display: flex;
  flex-wrap: wrap;
  gap: .5rem;
  align-items: baseline;
  margin: 0 0 .6rem;
  font-family: var(--nd-font);
}
.nd-lock-client { font-weight: 600; color: var(--nd-ink); }
.nd-lock-project { color: var(--nd-muted); }

.nd-lock-headline {
  align-self: flex-start;
  width: 90%;
  margin: 0;
  font-family: var(--nd-font-body);
  line-height: 1.3;
  color: var(--nd-ink);
}

/* The card's own client/project labels are hidden here: they now live in the
   left panel. The time and angle readouts stay on the rotating image. */
.nd-lock-panel--media .pcard-labels,
.nd-lock-panel--media .pcard-client,
.nd-lock-panel--media .pcard-project { display: none; }

/* Right: the card fills the panel, and its OWN white frame is neutralised so
   the rotating octagon and its readouts sit directly inside the hero box rather
   than as a frame within a frame. The JS wiring is untouched, so rotation,
   hover, drag and the readouts all keep working; only the frame's paint is
   removed, not the layout that positions the chrome.

   NOT display: flex here (Nirm's find): as a flex parent it centred the card at
   its content width, keeping it small; as a plain block the card's width: 100%
   fills the column and the panel grows with it. */
.nd-lock-panel--media {
  display: block;
}
.nd-lock-panel--media .pcard {
  width: 100%;
  max-width: none;
  margin: 0;
}
.nd-lock-panel--media .card-hook,
.nd-lock-panel--media .pcard-footer { display: none; }
.nd-lock-panel--media .card-stage > .pcard-media {
  background: transparent;
  clip-path: none;
  filter: none;
}
/* The rotating panel is capped at --pcard-w (400px) by default, which left it
   small in this wider column. Here it fills the column instead: width 100% and
   the cap lifted, so it grows to the available width and its 4:3 height follows.
   The extra vertical margin is dropped so it uses the height too. */
.nd-lock-panel--media .pcard-panel {
  width: 100%;
  max-width: 100%;
  margin-block: 0;
}

/* Inside the hero box the form carries no chrome of its own and sits
   bottom-LEFT; on a plain Page .nd-lock keeps the bordered-box styling above. */
.nd-lock-hero-box .nd-lock {
  align-self: flex-start;
  width: 100%;
  max-width: 320px;
  margin: 0;
  padding: 0;
  background: none;
  border: 0;
  box-shadow: none;
  text-align: left;
}

/* The form is compressed inside the hero box: it sits at the bottom of the left
   column, so tighter margins keep it from taking more height than it needs. The
   simple-page form (base .nd-lock-* rules) keeps its roomier spacing. */
.nd-lock-hero-box .nd-lock-lead { margin-bottom: .15rem; }
.nd-lock-hero-box .nd-lock-note { margin-bottom: .6rem; }
.nd-lock-hero-box .nd-lock-field { gap: .2rem; margin-bottom: .5rem; }
.nd-lock-hero-box .nd-lock-field input { padding: 7px 12px; }
.nd-lock-hero-box .nd-lock-error { margin-top: 1rem; }

/* The wrong-password feedback below the button, a reply to the attempt rather
   than part of the form's own instructions. */
.nd-lock-error {
  margin: 2rem 0 0;
  font-family: var(--nd-font);
  font-size: var(--nd-fs-ui);
  color: var(--nd-accent-ink);
}

/* A standalone card in a case study (the Project Card block) still centres. */
.cs-page .pcard {
  margin-left: auto;
  margin-right: auto;
}


/* ============================================================================
   ERROR PAGE (404). Reuses .cs-section/.cs-inner/.cs-figure/.cs-media rather than
   inventing new furniture, since a 404 is one more piece of page content,
   not a distinct component. The image is capped and centred, since .cs-media
   is otherwise full-width, which would blow a small illustration up to the
   whole measure.
   ============================================================================ */

.nd-404 {
  text-align: center;
}
.nd-404-figure {
  max-width: 280px;
  margin: 0 auto 1.5rem;
}
.nd-404 .cs-lead {
  margin-left: auto;
  margin-right: auto;
}


/* HEADER CLEARANCE. The header is position: fixed and full-bleed, so the top of
   the content sits under it. Under nirmtronik theme .nd-main reserves the space;
   under Bricks there is no .nd-main, so #brx-content needs it instead (scoped to
   the Bricks body so nirmtronik is not double-padded). scroll-padding-top and
   scroll-margin-top then keep an in-page-nav jump from landing under the header
   too, which is the "each section needs clearance" case. */
html { scroll-padding-top: calc(var(--sh-height, 4.5rem) + 2rem); }
.cs-section[id],
[data-nav-label] { scroll-margin-top: calc(var(--sh-height, 4.5rem) + 2rem); }
.brx-body #brx-content { padding-top: calc(var(--sh-height, 4.5rem) + 2rem); }


/* ============================================================================
   TYPE SCALE. The single place type SIZE is set: EVERY text element in the
   system points at one of the --nd-fs-* role tokens (defined in :root), which
   are all multiples of the one master knob --nd-fs. So the whole site scales
   from that one number, and no element carries its own hardcoded px size.

   !important because this deliberately overrides the scattered legacy px sizes
   still sitting in the component rules above (11px..90px, one per component,
   which is exactly what made the sizing inconsistent before this pass). This
   layer is the authority; the legacy declarations are now inert and can be
   stripped in a later tidy. line-height is set alongside so a resize keeps its
   rhythm.

   Grouped by role, most-prominent first. If any element is still the wrong
   size, MOVE ITS SELECTOR to a different group here, do not add a px size back
   into its component rule.
   ============================================================================ */

/* BASE for WordPress-rendered pages. Text on a Bricks or theme page that
   carries none of our classes (a bare Bricks Text element, a widget) inherits
   the body size from the content root, so the WHOLE page scales with the knob,
   not just the elements named in the groups below. No !important, so every
   targeted rule that follows still wins. */
.brx-body #brx-content,
.brx-body .brxe-container,
body.nirmtronik #content {
  font-size: var(--nd-fs-body);
}

/* MEGA: the largest decorative quote marks. */
.qr-mark, .qr-layout-4 .qr-mark {
  font-size: var(--nd-fs-mega) !important;
  line-height: var(--nd-lh-mega) !important;
}

/* DISPLAY: big stat numbers and the flat-card quote mark. */
.cs-stat dd, .tf-mark {
  font-size: var(--nd-fs-display) !important;
  line-height: var(--nd-lh-display) !important;
}

/* TITLE: headings, the card hook, the lock headline, big pull-quotes and the
   logotype text. Also the theme/Bricks page and archive titles (h1.title,
   .brxe-post-title), so a WordPress-rendered heading follows the same scale as
   an in-system one. The header Contact button is NOT here: it is tied to the
   nav size instead (see .site-nav-contact button), so it matches the other nav
   items rather than towering over them. */
.cs-title, .wp-block-heading, h1.title, .brxe-post-title, .brxe-post-title a,
.cs-page :is(h1, h2, h3, h4), #brx-content :is(h1, h2, h3, h4),
.card-hook, .nd-lock-headline, .site-mark,
.mq--text .mq-item,
.qr-layout-1 .qr-quote, .qr-layout-2 .qr-quote {
  font-size: var(--nd-fs-title) !important;
  line-height: var(--nd-lh-title) !important;
}

/* LEAD: section leads, accordion triggers, the logo-tile title and the
   smaller pull-quote layouts. */
.cs-lead, .acc-trigger, .cl-title,
.qr-layout-3 .qr-quote, .qr-layout-5 .qr-quote,
.qr-layout-6 .qr-quote, .qr-layout-4 .qr-quote {
  font-size: var(--nd-fs-lead) !important;
  line-height: var(--nd-lh-lead) !important;
}

/* BODY: all running copy and list items, in content areas and components.
   Bricks Text elements (.brxe-text) and post content are included so plain
   copy dropped into a Bricks page follows the scale like everything else. */
.card-role, .card-detail-body p, .card-sentences, .card-sentences li,
.cs-body, .cs-bullets li, .cs-note p, .acc-panel-inner p,
.nd-lock-lead, .nd-lock-note,
.ts-modal-name, .ts-modal-quote, .cl-name,
.cs-page :is(p, li, blockquote), #brx-content :is(p, li, blockquote),
.brxe-text, .brxe-text p, .brxe-text li,
.brxe-post-content p, .brxe-post-content li,
.wp-block-paragraph, .wp-block-list li {
  font-size: var(--nd-fs-body) !important;
  line-height: var(--nd-lh-body) !important;
}

/* UI: nav links, buttons, tabs, form controls, panels, small hover copy. */
.card-link, .card-open, .card-case-link,
.nd-lock-submit, .nd-lock-field input, .nd-lock-error,
.page-nav-link, .tab, .nd-contact__btn,
.hs-panel, .nd-skip:focus,
.cl-bubble .cl-bubble-name, .ai-tour-text,
.tf-quote, .tf-name, .tf-award-title {
  font-size: var(--nd-fs-ui) !important;
  line-height: var(--nd-lh-ui) !important;
}

/* LABEL: meta rows, chips, captions, attributions, small named labels. */
.card-meta, .card-meta-row, .card-meta-row b, .card-chip,
.cs-caption, .cs-figure figcaption, .cs-stat dt,
.nd-lock-labels, .nd-lock-field span,
.filter, .ts-modal-role, .qr-attrib,
.ai-list-text, .cl-bubble .cl-bubble-desc, .cl-desc,
.site-footer-meta, .hs-label, .strap, .tf-role, .tf-award-desc,
.brxe-post-meta, .brxe-post-meta span, .brxe-post-meta a, .post-meta > span {
  font-size: var(--nd-fs-label) !important;
  line-height: var(--nd-lh-label) !important;
}

/* MICRO: eyebrows, toasts, dots, the tiniest meta. */
.tooltip::after, .site-nav-toast, .nd-contact__toast,
.filters-toggle, .filters-count, .filters-clear, .filter-group-title,
.cs-note-kind, .cs-eyebrow, .ba-label,
.ai-tour-eyebrow, .ai-list-num, .ai-dot {
  font-size: var(--nd-fs-micro) !important;
  line-height: var(--nd-lh-micro) !important;
}

/* TESTIMONIAL BUBBLE: a compact pill, so its quote, name and role sit a step
   below the global chrome (the 1.6rem base makes even micro read large in a
   pill). The bubble only; the modal keeps its readable sizes. ts-name/ts-role
   inherit from ts-attrib. */
.ts-quote, .ts-attrib {
  font-size: var(--nd-fs-2xs) !important;
  line-height: var(--nd-lh-micro) !important;
}

/* INSTRUMENT: the card's own readouts. */
.pcard-client, .pcard-project, .pcard-time, .pcard-angle,
.card-metric b, .card-metric span {
  font-size: var(--nd-fs-instrument) !important;
  line-height: var(--nd-lh-instrument) !important;
}
/* --- END TYPE SCALE --------------------------------------------------------- */
