@charset "UTF-8";
/* ============================================================================
   artitech.css — the public theme for ArtitechCMS
   ----------------------------------------------------------------------------
   Ported from the React prototype (the devteam .jsx files + styles.css) via the
   ten design specs in scratchpad/spec. The prototype's styles.css was only
   ~10 KB of tokens + utilities; the actual layout lived in inline
   style={{...}} objects inside the components. Everything below the "UTILITIES"
   section is therefore a transcription of those inline objects into real
   classes, using the class names the specs propose.

   HOUSE RULES OBSERVED HERE
   - Every load-bearing value says WHICH TOKEN drives it and WHY.
   - Where two specs disagreed, the rejected alternative is named in a comment.
   - Deliberate prototype oddities are preserved and labelled, not "fixed".

   ----------------------------------------------------------------------------
   TABLE OF CONTENTS
   ----------------------------------------------------------------------------
   1.  TOKENS
       1.1  :root — light theme
       1.2  [data-theme="dark"]
       1.3  prefers-color-scheme fallback            (ADDITION — not in proto)
   2.  RESETS / BASE ELEMENTS
   3.  UTILITIES
       3.1  Layout            .wrap .sec .alt
       3.2  Buttons           .btn + solid/ink/line/ghost/full/white/on-dark
       3.3  Card              .card  (+ [data-cards] variants)
       3.4  Reveal            .rv    (MUST follow .card — see note)
       3.5  Form fields       .fld
       3.6  Chip              .chip
       3.7  Eyebrow           .eyebrow (+ .plain, .light)
       3.8  Grids and splits  .grid .g2 .g3 .g4 .split.a–.h .fcols .priceline
       3.9  Prose             .prose
       3.10 Nav/drawer utils  .navlinks .navcta .burger .drawer*
       3.11 Icon font         .material-symbols-outlined + .ico-fill .ico-w500
       3.12 Media            .img-slot / image-slot
       3.13 Keyframes
   4.  PRIMITIVES (ui.jsx)
       4.1  SectionHead       .sechead*
       4.2  PlayButton        .playbtn*
       4.3  Avatar            .avatar
       4.4  ArchLine          .archline
       4.5  Counter           .counter
   5.  SITE CHROME (shell.jsx)
       5.1  Header / nav      .site-header .header-bar
       5.2  Logo              .logo*
       5.3  Drawer            .drawer*
       5.4  Page banner       .banner* .crumbs
       5.5  CTA band          .ctaband*
       5.6  Newsletter band   .nlband*
       5.7  Footer            .site-footer .fcols .f-*
       5.8  Back to top       .btt
   6.  PAGE SECTIONS
       6.1  Home — hero, ticker
       6.2  Shared — About block
       6.3  Shared — WhyUs dark band
       6.4  Shared — HowItWorks (steps + facts)
       6.5  Shared — Services grid
       6.6  Shared — TechStack
       6.7  Shared — Team
       6.8  Shared — Testimonials rail
       6.9  Work — portfolio grid + modal
       6.10 Pricing — tiers + included panel
       6.11 Pricing — FAQ accordion
       6.12 Articles — featured card, filter bar, article cards, empty state
       6.13 Article detail — banner, body, sidebar
       6.14 Contact — info cards + brief form
       6.15 404 — .nf-*
   7.  RESPONSIVE  (1180 / 1040 / 1000 / 900 / 720 — prototype order)
   8.  REDUCED MOTION + NO-JS
   9.  PRINT
   ============================================================================ */


/* ============================================================================
   1. TOKENS
   ============================================================================
   The prototype set these twice: statically in styles.css and again at runtime
   as inline custom properties on <html> via tweaks.jsx > applyTweaks(). The
   tweak panel is dev-only and does NOT ship, so the stylesheet values are the
   ones to keep (spec 00 §0.1). Two runtime values genuinely differed:

     --accent-soft light : styles.css #e4f0ea | applyTweaks rendered ~#e4eae9
     --accent-soft dark  : styles.css #12231f | applyTweaks rendered ~#182f2b

   We ship the STYLESHEET hex. Rejected alternative: spec 09 argues for keeping
   the color-mix(in srgb,#1a4f47 12%,#ffffff) literal because that is what the
   browser actually painted in the prototype. Spec 00 is the primary source and
   says "keep the stylesheet values", so #e4f0ea wins; the ~4-unit difference is
   invisible on the three surfaces that use it (.eyebrow pill, .nf-link-ico
   tile, .nf-link:hover row).
   ---------------------------------------------------------------------------- */

:root {
  /* --- brand -------------------------------------------------------------
     RE-THEMED FROM THE PROTOTYPE'S GREEN TO THE REAL ARTITECH BLUE.

     The prototype (and therefore spec 00) specified a deep green
     --accent:#1a4f47 / --accent-2:#2f7d6f / --accent-soft:#e4f0ea and a
     near-black --ink:#101314. Those were the Nova template's colours, kept
     through the re-skin. The actual Artitech logo is a faceted BLUE monogram
     with a navy wordmark, so a green site would have put a blue mark next to
     green nav pills and a green button — the one place a brand mismatch is
     impossible to miss.

     Values are sampled from the logo itself: the monogram's mid-tone blue, its
     bright highlight, and the wordmark's navy. Nothing else about the design
     changed — layout, spacing, type scale and every component's geometry are
     still exactly as the specs describe, because they were all authored against
     these tokens rather than against literal colours.

     If the green is ever wanted back, these five values plus the four in the
     dark block and the four in the prefers-color-scheme block are the whole
     change; the old values are named above so they can be restored verbatim. */
  --accent:      #1560a8;   /* logo mid-blue. Links, .btn.solid, .eyebrow text,
                               logo mark, focus ring, .btt, .nf-code,
                               blockquote border, .chip.on, every radial glow,
                               ::selection. Change this and the site rebrands.
                               Chosen over the brighter #29a8ff because this is
                               the value that has to carry BODY-SIZE TEXT on
                               white (links, .eyebrow): #1560a8 measures ~5.8:1
                               against #ffffff and clears WCAG AA at any size,
                               where #29a8ff is ~2.9:1 and fails. The bright
                               tone lives in --accent-2 instead. */
  --accent-2:    #29a8ff;   /* logo highlight blue. Still UNUSED by every rule
                               in this file — kept because the prototype
                               declared it and a future gradient may want it.
                               Unlike the prototype's green it now HAS a dark
                               override below: bright blue on a near-black
                               ground is fine as-is, so the override keeps it
                               unchanged deliberately rather than by omission,
                               which is what spec 00 flagged as a probable
                               prototype bug. */
  --accent-soft: #e6f1fb;   /* pale blue. .eyebrow bg, .nf-link:hover bg,
                               .nf-link-ico bg, .svc-ico bg, .info-ico bg.
                               Tinted from --accent so the eyebrow pill reads as
                               the same family; ~1.06:1 against white, i.e. a
                               wash, exactly as the mint it replaces. */
  --on-accent:   #ffffff;   /* intended text-on-accent. The prototype hardcoded
                               #fff everywhere and never referenced this; we do
                               the same for fidelity. Kept for future use. */

  /* --- ink / surfaces ----------------------------------------------------
     --ink is now the LOGO'S NAVY rather than a neutral near-black, so the
     footer and the CTA band read as brand colour instead of as grey. It stays
     dark enough to carry white body text: #0f2340 measures ~15.3:1 against
     #ffffff. */
  --ink:       #0f2340;     /* logo wordmark navy. footer bg, .ctaband panel bg,
                               featured pricing tier bg, drawer scrim base. */
  --ink-2:     #16304f;     /* secondary navy. Declared, unused. */
  --bg:        #ffffff;     /* page background; .btn.ink label colour; drawer
                               panel bg; scrolled-header base for the
                               color-mix() blur. */
  --surface:   #ffffff;     /* card / input / burger background. */
  --surface-2: #f7f8f8;     /* .alt band, .chip bg, active nav pill, .banner bg,
                               newsletter panel, blockquote bg, drawer hover. */
  --surface-3: #eef2f0;     /* image placeholder bg, .nf-path bg. */

  /* --- text -------------------------------------------------------------- */
  --text:  #101314;         /* headings, strong copy, .btn.line/.ghost label. */
  --body:  #585f63;         /* default body copy, inactive nav links, .prose. */
  --muted: #666e72;         /* placeholders, .crumbs, meta text.
                               WAS #8a9296, which measured 3.17:1 on #ffffff and
                               2.98:1 on the --surface-2 (#f7f8f8) .alt band —
                               below WCAG AA 1.4.3 (4.5:1) at all thirteen
                               consumers (breadcrumbs, placeholders, card metric
                               labels, testimonial roles, bylines, tech
                               captions). #666e72 measures 4.80:1 on #ffffff and
                               4.51:1 on #f7f8f8, so it clears AA on BOTH
                               surfaces — the .alt band is the binding
                               constraint, not white. Token-only change: every
                               consumer inherits it and no selector moved. */
  --border:#e6e8e8;         /* EVERY 1px hairline in the design: cards, inputs,
                               chips, header bottom, banner bottom, scrollbar
                               thumb, ArchLine stroke, hairline-grid dividers.
                               DECORATIVE ONLY — see --border-strong below for
                               anything that is the edge of a CONTROL. */
  --border-strong:#878e91;  /* ADDITION. WCAG 1.4.11 requires 3:1 for the visual
                               boundary of a control. --border above is 1.23:1
                               on white — fine for a hairline that merely divides
                               content, invisible as the edge of a text input.

                               WAS #b3b9bc, introduced with the claim that it
                               measured 3.06:1 on white. IT DOES NOT: #b3b9bc has
                               a relative luminance of .478 and measures 1.98:1
                               on #ffffff and 1.87:1 on the --surface-2 chip
                               fill, so it missed 1.4.11 by a third and the
                               control edges it was added to fix were still
                               invisible. Re-measured and corrected.

                               #878e91 measures 3.33:1 on #ffffff and 3.13:1 on
                               #f7f8f8. The --surface-2 chip fill is the binding
                               constraint, not white — a value tuned only against
                               white lands under 3:1 the moment it is drawn on
                               the .alt band. Verified in-browser against the
                               computed style, not from a table.

                               Used by: .fld input/textarea/select, .chip,
                               .btn.line, .step-n, .burger, .drawer-close, the
                               /about org chart connector and the chat widget's
                               --pl-line bridge. Everything else keeps --border. */

  /* --- type -------------------------------------------------------------- */
  --font-display: "Plus Jakarta Sans", system-ui, sans-serif;  /* h1–h5, logo,
                               blockquote, .nf-code, avatar initials, numbers */
  --font-body:    "DM Sans", system-ui, sans-serif;            /* body, .btn,
                               inputs */
  /* Manrope + Sora were requested by the prototype's <head> but referenced
     ONLY by the dev tweak panel's font pairings — dropped from the CI4 build.
     "Roboto Mono" is named first in .nf-path's stack but was never requested,
     so it always fell back to the system mono stack; we keep the same stack
     and do not load the font (spec 00 §A.3 recommendation). */

  /* --- type scale ---------------------------------------------------------
     ADDITION. The prototype's inline styles gave every section its own h2 size,
     so ONE semantic level rendered at five different sizes on a single 1440px
     screen: 46px (.sechead h2), 40px (.ctaband h2), 38px (.feat-body h2),
     34px (.nlband h2) and 32px (.prose h2). Nothing about a band's position on
     the page changes what an h2 IS, so they now share one token.

     --fs-h2 clamps between the old smallest floor and a ceiling below the old
     46px maximum: the five bands each keep a fluid size, but they keep the
     SAME one. Only the five h2 declarations are pointed at it.

     DELIBERATELY NOT DONE IN THIS PASS: the ~ten near-duplicate body sizes
     (15px / 15.5px / 16px / 16.5px / 17.5px …). Nothing is broken by them, and
     re-pointing every one of them is the highest-regression-risk edit in this
     stylesheet. --fs-body / --fs-sm / --fs-xs are declared here so that
     follow-up has a target to move to; they are UNUSED today, by design. */
  --fs-h2:   clamp(30px, 3.4vw, 42px);
  --fs-h3:   20px;
  --fs-body: 16.5px;   /* unused — see note above */
  --fs-sm:   15px;     /* unused — see note above */
  --fs-xs:   13px;     /* unused — see note above */

  /* --- rhythm ------------------------------------------------------------ */
  --space: 1;               /* GLOBAL DENSITY MULTIPLIER. Multiplies into
                               --section-pad, .grid gap, .banner bottom pad,
                               .sechead margins, .nlband bottom pad, .nf-grid
                               gap. The tweak panel's DENSITY scale, kept as
                               documentation for future use:
                                 compact  = [0.74,  76px]
                                 regular  = [1,    112px]   <- shipped
                                 spacious = [1.26, 148px]
                               Spec 00 says keep it: it is load-bearing even
                               without the panel. */
  --section-pad: calc(112px * var(--space));   /* vertical padding of .sec */

  --r:    20px;             /* card corner radius */
  --maxw: 1220px;           /* .wrap max width */

  /* --- elevation --------------------------------------------------------- */
  --shadow-sm: 0 1px 2px rgba(16,19,20,.05);          /* .step-n, card tags */
  --shadow:    0 4px 24px -8px rgba(16,19,20,.10);    /* card hover, .nf-links */
  --shadow-lg: 0 24px 60px -20px rgba(16,19,20,.20);  /* .btt, floating cards,
                                                         featured tier, modal */
}

/* ---------------------------------------------------------------------------
   1.2 Dark theme — stamped by PHP as <html data-theme="dark">.
   15 properties are overridden; --accent-2, --on-accent, the fonts, --space,
   --section-pad, --r and --maxw deliberately inherit the light values.

   ODDITY PRESERVED: --surface-2 (#0d1212) is DARKER than --surface (#111717),
   so in dark mode the .alt band RECEDES rather than lifts, and any card that
   paints var(--bg) sits darker than the band behind it. Spec 00 §A.2 confirms
   the inversion is intentional. Do not "fix" it.
   --------------------------------------------------------------------------- */

[data-theme="dark"] {
  /* Re-themed to the Artitech blue alongside :root. The prototype's dark values
     were a green/grey set (#4fb3a0, #12231f, #080b0b, #0a0e0e, #111717, #0d1212,
     #161e1e, #f2f5f4, #9daaa7, #6b7876, #1d2726) — recorded here so the original
     can be restored verbatim.

     --accent is LIGHTENED for dark mode rather than reused from :root. #1560a8
     was picked for contrast against WHITE; on a near-black ground it measures
     only ~2.4:1 and links would be barely legible. #4da6f0 measures ~7.4:1
     against --bg, so it clears AA for body text in both themes. This asymmetry
     is the point of having a dark block at all. */
  --accent:      #4da6f0;
  --accent-2:    #29a8ff;   /* unchanged from :root, and stated EXPLICITLY. The
                               prototype omitted --accent-2 here, which spec 00
                               flags as a probable bug because the light green
                               would have bled into dark mode. A bright blue
                               needs no adjustment on a dark ground, so this
                               override records "no change" as a decision. */
  --accent-soft: #0e2035;
  --on-accent:   #06121f;   /* ADDITION. :root's --on-accent is #ffffff, and
                               white on the dark --accent #4da6f0 measures only
                               2.62:1 — every accent-backed control (.btn.solid,
                               .chip.on, the open FAQ toggle, the hovered service
                               icon, .feat-tag, ::selection) failed AA in dark
                               mode because the prototype hardcoded #fff into
                               each of them. #06121f on #4da6f0 measures ~9.0:1.
                               The six literals are now var(--on-accent). */
  --ink:         #070f1a;
  --ink-2:       #10203a;
  --bg:          #08111c;
  --surface:     #0f1a29;
  --surface-2:   #0b1522;   /* darker than --surface — see note above */
  --surface-3:   #142235;
  --text:        #eef4fa;
  --body:        #9fb2c6;
  --muted:       #7f92a8;   /* was #6b7d92 (3.6:1 on --surface). #7f92a8 is
                               5.0:1 on --surface and 5.6:1 on --bg, matching the
                               light theme's AA-clearing --muted. */
  --border:      #1c2a3d;
  --border-strong: #566a80; /* dark-mode partner for :root's #b3b9bc — 3.12:1 on
                               --surface, 3.38:1 on --bg. */
  --shadow-sm:   0 1px 2px rgba(0,0,0,.4);
  --shadow:      0 4px 24px -8px rgba(0,0,0,.6);
  --shadow-lg:   0 24px 60px -20px rgba(0,0,0,.7);
}

/* ---------------------------------------------------------------------------
   1.3 ADDITION — OS dark-mode fallback.
   The prototype had NO prefers-color-scheme rule anywhere; dark mode existed
   only when applyTweaks() wrote data-theme="dark". This block is new.

   It is scoped to :root:not([data-theme]) on purpose: an EXPLICIT choice must
   always beat the operating system. If the CI4 layout hardcodes
   <html data-theme="light"> (spec 00 §0.2), this block is inert BY DESIGN —
   that is the correct outcome, not a bug. It only fires if the layout omits the
   attribute entirely (e.g. a future "follow system" setting).
   --------------------------------------------------------------------------- */

@media (prefers-color-scheme: dark) {
  :root:not([data-theme]) {
    /* MUST stay identical to the [data-theme="dark"] block above — two copies of
       the same palette is the drift risk here, so change them in one edit. */
    --accent:      #4da6f0;
    --accent-2:    #29a8ff;
    --accent-soft: #0e2035;
    --on-accent:   #06121f;
    --ink:         #070f1a;
    --ink-2:       #10203a;
    --bg:          #08111c;
    --surface:     #0f1a29;
    --surface-2:   #0b1522;
    --surface-3:   #142235;
    --text:        #eef4fa;
    --body:        #9fb2c6;
    --muted:       #7f92a8;
    --border:      #1c2a3d;
    --border-strong: #566a80;
    --shadow-sm:   0 1px 2px rgba(0,0,0,.4);
    --shadow:      0 4px 24px -8px rgba(0,0,0,.6);
    --shadow-lg:   0 24px 60px -20px rgba(0,0,0,.7);
  }
}


/* ============================================================================
   2. RESETS / BASE ELEMENTS  (verbatim from styles.css unless marked)
   ============================================================================ */

* { box-sizing: border-box }

html { scroll-behavior: smooth }

body {
  margin: 0;
  background: var(--bg);
  color: var(--body);
  font-family: var(--font-body);
  font-size: 16.5px;
  line-height: 1.7;
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;       /* LOAD-BEARING. This is the only thing containing
                               the off-canvas decorative circles (.banner:after
                               at right:-160px, the hero glow at right:-180px,
                               the .ctaband glow) and the About mission card,
                               which deliberately overhangs its column by 26px.
                               Remove it and every page gains a horizontal
                               scrollbar on mobile. */
}

h1, h2, h3, h4, h5 {
  margin: 0;                /* all vertical rhythm is explicit — see note on p */
  font-family: var(--font-display);
  color: var(--text);
  font-weight: 700;
  line-height: 1.14;
  letter-spacing: -.032em;
}

p { margin: 0 }
/* p{margin:0} + h*{margin:0} means there is NO margin collapse to rely on
   anywhere in this design. Every gap below is an explicit margin/gap on a
   named class. If a section looks cramped, the missing margin is the bug. */

a {
  color: var(--accent);
  text-decoration: none;
  transition: color .2s, opacity .2s;
}
a:hover { color: var(--accent); opacity: .75 }
/* ^ THIS is why almost every button/link hover state below restates
   `opacity:1`. Without it a .btn rendered as an <a> fades to 75% on hover on
   top of whatever colour change the variant makes, which reads as a bug. */

/* ADDITION — WCAG 1.4.1 (Use of Colour). The global `a{text-decoration:none}`
   above is right for NAV PILLS and BUTTONS, where shape, background and
   position already say "this is a control". It is wrong for a link sitting
   INSIDE a paragraph: --accent (#1560a8) against --body (#585f63) measures
   1.01:1, so an inline link in running text is distinguished from the prose
   around it by nothing a person with a colour-vision difference can perceive.

   Scoped to the four places a link genuinely appears mid-sentence — the CMS
   rich-text body, the article sidebar cards, the footer contact block and the
   newsletter band's copy — so nav, .btn and card CTAs are untouched.
   3px offset keeps the rule clear of the descenders in "hello@artitech.ph". */
.prose a,
.aside-card p a,
.f-contact a,
.nlband p a {
  text-decoration: underline;
  text-underline-offset: 3px;
  text-decoration-thickness: 1px;
}

button { font-family: inherit }
img { max-width: 100% }

::selection { background: var(--accent); color: var(--on-accent) }
/* var(--on-accent) rather than the prototype's literal #fff: it resolves to
   #ffffff in light mode (identical rendering) and to #06121f in dark, where
   white on the lightened --accent is only 2.62:1. Same swap at .btn.solid,
   .chip.on, .card:hover .svc-ico, the open FAQ toggle and .feat-tag. */

::-webkit-scrollbar { width: 10px; height: 10px }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 99px }
.noscroll::-webkit-scrollbar { display: none }   /* used by the testimonial rail */

/* ADDITION. html{scroll-behavior:smooth} plus a position:fixed header means an
   in-page anchor (#how, linked from the footer as /about#how) lands UNDERNEATH
   the header. 100px clears the unscrolled header (~89px, see §5.1) with room. */
[id] { scroll-margin-top: 100px }


/* ============================================================================
   3. UTILITIES
   ============================================================================ */

/* --- 3.1 Layout ----------------------------------------------------------- */

.wrap {
  max-width: var(--maxw);
  margin: 0 auto;
  padding: 0 26px;          /* drops to 20px at <=1000px */
}

.sec { padding: var(--section-pad) 0 }

.alt { background: var(--surface-2) }

/* --- 3.1b visually-hidden -------------------------------------------------
   ADDITION, AND IT WAS ALREADY BEING USED. Nine elements across the views ship
   `class="visually-hidden"` — the newsletter's <label for="nlEmail">, the
   /articles search label, the chat widget's five icon-button labels — and this
   stylesheet declared no such rule, so the class did nothing and every one of
   them rendered as ORDINARY VISIBLE TEXT. The newsletter band measured a stray
   106x51px "Email address" above its email pill on all eleven pages, and the
   chat launcher was a transparent circle with the words "Open live chat" painted
   next to it. Two visible defects from one missing utility.

   The correct pattern, not `display:none` and not `visibility:hidden` — both of
   those remove the text from the accessibility tree as well as from the screen,
   which is the exact opposite of the intent. A 1px clipped box stays in the
   tree, so a screen reader reads it and nobody sees it.

   clip-path is the modern property; the deprecated `clip` stays for older
   engines. white-space:nowrap stops a long string wrapping inside the 1px box
   and being reported with stray line breaks. */
.visually-hidden {
  position: absolute !important;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
/* The one exception every implementation of this pattern needs: a skip link (or
   any focusable element) must become visible when it receives focus, or a
   keyboard user is tabbing into something they cannot see. .skip-link in §8.9
   has its own :focus rule; this covers anything else that opts in. */
.visually-hidden.focusable:focus,
.visually-hidden.focusable:focus-within {
  position: static !important;
  width: auto;
  height: auto;
  margin: 0;
  overflow: visible;
  clip: auto;
  clip-path: none;
  white-space: normal;
}

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

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 9px;
  border: 1px solid transparent;
  cursor: pointer;
  font-family: var(--font-body);
  font-size: 15px;
  font-weight: 500;
  padding: 13px 26px;
  border-radius: 999px;
  transition: all .22s cubic-bezier(.4,0,.2,1);
  white-space: nowrap;
  line-height: 1.4;
}
/* Computed height ~= 49px (13+13 padding + 15px x 1.4 line box + 2px border).
   That number sets the whole header height above 1180px — see §5.1. */

/* THE THREE PRIMARY TREATMENTS, AND WHEN EACH APPLIES.
   The prototype shipped .solid, .ink and .white with no stated rule, so all
   three could land on one screen and the visitor had no way to tell which pill
   was the page's actual primary action.

     .btn.solid  — accent fill. THE in-page primary action, everywhere.
     .btn.ink    — near-black fill. CHROME ONLY: the fixed header CTA and the
                   newsletter Subscribe. Never inside page content. It is the
                   site furniture's button, which is why it is a different
                   colour from the page's own primary — see the note at .ink.
     .btn.white  — white fill. .solid's stand-in on an --ink panel (CTA band,
                   featured pricing tier), where an accent fill has too little
                   contrast against the navy behind it.
     .btn.line   — secondary/outlined, on any ground. .on-dark for --ink panels.

   Consequence to preserve: a content band contains at most ONE filled pill.

   THE PRICING TIERS ARE THE ONE PLACE THAT LOOKS LIKE AN EXCEPTION AND IS NOT.
   Three tier cards carry three CTAs with the same label, and the split is
   `.btn.white` on the FEATURED tier, `.btn.line` on the other two. That follows
   the rule rather than breaking it: the featured tier's card ground is --ink, so
   white IS its filled variant, and the band still contains exactly one filled
   pill. Making all three .solid would put three competing primaries in one row
   and delete the emphasis the featured tier exists to carry. Do not "make them
   consistent" — they already are; the rule is one filled pill per band, not one
   class per component. */
.btn.solid  { background: var(--accent); color: var(--on-accent) }
.btn.solid:hover {
  background: var(--text);
  opacity: 1;                       /* cancels a:hover{opacity:.75} */
  transform: translateY(-2px);
  box-shadow: var(--shadow);
}

/* CHROME-ONLY VARIANT — see the rule stated at .btn.solid above. The only two
   users are the header CTA and the newsletter Subscribe, both of which sit in
   site furniture rather than in page content. Putting .ink on an in-page action
   is what produced three competing "primary" pills on /careers. */
.btn.ink    { background: var(--text); color: var(--bg) }
.btn.ink:hover { background: var(--accent); opacity: 1; transform: translateY(-2px) }

/* --border-strong, not --border: this is the visible boundary of a CONTROL and
   WCAG 1.4.11 wants 3:1 for that. --border is 1.23:1 — the outlined button read
   as bare text on white. */
.btn.line   { background: transparent; color: var(--text); border-color: var(--border-strong) }
.btn.line:hover { border-color: var(--text); opacity: 1; transform: translateY(-2px) }

.btn.ghost  { background: transparent; color: var(--text); padding: 12px 8px }
.btn.ghost:hover { color: var(--accent); opacity: 1 }

.btn .material-symbols-outlined { font-size: 19px; transition: transform .22s }
.btn:hover .material-symbols-outlined.mv { transform: translateX(4px) }
/* .mv on a TRAILING arrow is what makes it slide 4px right on hover. A leading
   icon (e.g. `call`, `arrow_back`, `send`) never carries .mv and never moves. */

/* ADDITION (spec 00 §C.2) — the Btn primitive's `full` prop. */
.btn.full { width: 100% }

/* ADDITION (spec 00 §D.6) — the white pill used on dark panels (CTA band
   primary, featured pricing tier CTA).
   WHY THIS IS A SEPARATE CLASS AND NOT `.btn.solid` + an override:
   in the prototype the white came from an INLINE background, which no
   stylesheet rule can beat, so `.btn.solid:hover{background:var(--text)}`
   never fired and the button stayed white on hover — it only lifted 2px and
   gained a shadow. Moving the colour into a class would let .btn.solid:hover
   win and the button would flip near-black on hover: a visible regression.
   So the markup must be `class="btn white"` (NO `solid`) AND the hover state
   must restate the background, colour and opacity. All three matter. */
.btn.white { background: #fff; color: var(--ink); border-color: transparent }
.btn.white:hover {
  background: #fff;                 /* restated — see note above */
  color: var(--ink);                /* restated */
  opacity: 1;                       /* cancels a:hover{opacity:.75} */
  transform: translateY(-2px);
  box-shadow: var(--shadow);
}

/* ADDITION (spec 00 §D.6) — outlined button on an ink ground.
   .btn.line:hover would set border-color:var(--text), which on --ink is
   near-invisible, so the hover is restated to brighten to white instead. */
/* .28 alpha over --ink measured ~1.9:1 — under the 3:1 WCAG 1.4.11 wants for a
   control edge, and the pill read as floating text. .45 measures ~3.2:1 and is
   still clearly the quieter of the two buttons on the band. */
.btn.line.on-dark { color: #fff; border-color: rgba(255,255,255,.45) }
.btn.line.on-dark:hover { color: #fff; border-color: #fff; opacity: 1 }

/* --- 3.3 Card ------------------------------------------------------------- */

.card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--r);
  transition: border-color .3s, box-shadow .3s, transform .3s;
}
.card:hover {
  border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
  box-shadow: var(--shadow);
  transform: translateY(-4px);
}
/* .card carries NO padding — every consumer supplies its own (36px on service
   and testimonial cards, 28px on article/project card bodies). */

/* data-cards is stamped on <html> by PHP. "bordered" (the shipped default) has
   NO selector at all — it is simply the bare .card above. These two variants
   are never active at the shipped defaults but are ported for completeness. */
[data-cards="flat"]   .card { border-color: transparent; background: var(--surface-2) }
[data-cards="flat"]   .card:hover { background: var(--surface); border-color: var(--border); box-shadow: var(--shadow) }
[data-cards="shadow"] .card { border-color: transparent; box-shadow: var(--shadow) }
[data-cards="shadow"] .card:hover { box-shadow: var(--shadow-lg); transform: translateY(-6px) }

/* --- 3.4 Reveal on scroll ------------------------------------------------- */
/* ORDER IS LOAD-BEARING: .rv MUST be declared after .card.
   Service cards and article cards carry BOTH classes. `transition` is a
   shorthand, both selectors are specificity (0,1,0), so the later one fully
   REPLACES the other's transition list. With .rv last (as here, matching the
   prototype's source order) the observed behaviour on those cards is:
     - the hover lift eases over .8s cubic-bezier(.16,1,.3,1), not .3s;
     - border-color and box-shadow on hover snap instantly (untransitioned).
   Move .rv above .card and the hover feel changes visibly. Don't.

   Second consequence, also preserved: .rv.in{opacity:1;transform:none} is
   (0,2,0) and declared after .card:hover (also (0,2,0)), so on a revealed
   card the hover translateY(-4px) is SUPPRESSED and a:hover{opacity:.75} is
   suppressed too. Revealed cards get border + shadow only; cards without .rv
   (the featured article card) get the lift and the dim. Spec 06 §3.4
   documents the difference — it is a cascade quirk we are keeping. */
.rv {
  opacity: 0;
  transform: translateY(28px);
  transition: opacity .8s cubic-bezier(.16,1,.3,1),
              transform .8s cubic-bezier(.16,1,.3,1);
}
.rv.in { opacity: 1; transform: none }

/* ADDITION — server-rendered safety net. In the React prototype JS was a hard
   dependency so opacity:0 was safe. On CI4, if JS fails or is off, every .rv
   element would be permanently invisible. The layout puts class="no-js" on
   <html> and an inline head script removes it, so this rule only survives when
   the script never ran. See also the reduced-motion block in §8. */
.no-js .rv { opacity: 1; transform: none; transition: none }

/* --- 3.5 Form fields ------------------------------------------------------ */
/* .fld is a <label> wrapping <span>Label</span> then the control — implicit
   association, so no for/id pairing is needed (ids are still needed to POST). */

.fld { display: block; position: relative }
.fld > span {
  display: block;
  font-size: 13.5px;
  font-weight: 500;
  color: var(--text);
  margin-bottom: 8px;
}
/* TWO NON-COSMETIC VALUES HERE.
   font-size:16px — NOT the design's 15px. Mobile Safari zooms the whole page
   when a text field under 16px receives focus, and it does not zoom back out,
   so every visitor who tapped a field on /contact was left on a horizontally
   scrolled page. 16px is the documented threshold; the visual difference at
   1px is negligible next to that. Applied to every public input for the same
   reason (.filterbar__search, .nlband, .apply-field, the comment form).
   border-color — --border-strong, not --border. WCAG 1.4.11 asks 3:1 for the
   boundary of a control; --border is 1.23:1 on white, i.e. the input had no
   visible edge at all until it was focused. */
.fld input, .fld textarea, .fld select {
  width: 100%;
  padding: 14px 16px;
  font-family: var(--font-body);
  font-size: 16px;
  color: var(--text);
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: 12px;
  outline: none;
  transition: border-color .2s, box-shadow .2s;
}
.fld textarea { resize: vertical; min-height: 132px }
.fld input:focus, .fld textarea:focus, .fld select:focus {
  border-color: var(--accent);
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 12%, transparent);
}
.fld input::placeholder, .fld textarea::placeholder { color: var(--muted) }

/* --- 3.6 Chip ------------------------------------------------------------- */
/* Three jobs: portfolio/article filters (clickable, .on = selected), contact
   form multi-select (clickable, .on), and decorative labels in the About block
   (given cursor:default by .about-chips). */

.chip {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 8px 16px;
  border-radius: 999px;
  font-size: 14px;
  font-weight: 500;
  background: var(--surface-2);
  color: var(--text);
  /* --border-strong: a filter chip is a CONTROL, and WCAG 1.4.11 wants 3:1 for
     its boundary. --border is 1.23:1 on white — against the chip's own
     --surface-2 fill it was 1.1:1, i.e. the /careers and /work filter rows read
     as loose text with no pill around them until one was selected. */
  border: 1px solid var(--border-strong);
  cursor: pointer;
  transition: all .2s;
  /* An admin-authored tag can be one long unbroken token; the row it sits in is
     overflow-x:hidden, so without this the word is amputated with no ellipsis. */
  overflow-wrap: anywhere;
}
/* var(--on-accent), not #fff — see the ::selection note in §2. In dark theme
   --accent lightens to #4da6f0 and white on it is 2.62:1; --on-accent resolves
   to #06121f there (~9:1) and to #ffffff in light theme, so light rendering is
   byte-identical. */
.chip.on { background: var(--accent); color: var(--on-accent); border-color: var(--accent) }
.chip:hover:not(.on) { border-color: var(--text) }
/* On an .alt band an inactive chip is --surface-2 on --surface-2, i.e. it reads
   as outline-only. That is the intended look (spec 06 §3.2). */

/* --- 3.7 Eyebrow ---------------------------------------------------------- */

.eyebrow {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 7px 15px;
  border-radius: 9px;
  background: var(--accent-soft);
  font-size: 12.5px;
  font-weight: 700;
  letter-spacing: .06em;
  text-transform: uppercase;    /* store sentence case in the CMS; CSS uppercases */
  color: var(--accent);
  margin-bottom: 20px;
  white-space: nowrap;
  max-width: 100%;
}
.eyebrow i { display: none }     /* prototype quirk: an <i> slot that is never shown */
.eyebrow.plain {
  background: transparent;
  padding: 0;
  text-transform: none;
  letter-spacing: .02em;
  font-size: 13px;
  font-weight: 600;
}
/* ADDITION (spec 00 §B.6 / §C.3) — the Eyebrow primitive's `light` prop, for
   eyebrows sitting on --ink (the WhyUs band). */
.eyebrow.light { color: #fff; background: rgba(255,255,255,.12) }

/* --- 3.8 Grids and splits ------------------------------------------------- */

.grid { display: grid; gap: calc(24px * var(--space)) }
.g3 { grid-template-columns: repeat(3, 1fr) }
.g4 { grid-template-columns: repeat(4, 1fr) }
.g2 { grid-template-columns: repeat(2, 1fr) }
/* ADDITION — the n=1 case. A fixed .g3/.g4 has no single-item state: one tile in
   repeat(3, 1fr) is a third of the band with two dead columns beside it, which
   reads as two failed loads rather than as one item. Every consumer of a fixed
   grid on this site can legitimately have one row (a case study with one metric,
   a gallery with one shot, a pricing page with one tier), so the views pick their
   grid class from the item count — see about.php's org chart for the original of
   the pattern — and this is the class they pick for one.

   Capped and centred rather than stretched: a lone card at full band width is a
   different component, not the same component with fewer siblings. 520px is close
   to a single g2 column at max width, so one item and two items are near enough
   the same size and the band does not visibly change scale with its content. */
.g1-centre { grid-template-columns: minmax(0, 1fr); max-width: 520px; margin-inline: auto }
/* .grid supplies display+gap ONLY — always pair it with .g1-centre/.g2/.g3/.g4.
   Note .g2 is NOT in the 1040px rule: 2-up survives until 720px. */

.split { display: grid; gap: clamp(40px, 5vw, 72px); align-items: center }
.split.a { grid-template-columns: 1fr 1.05fr }      /* hero media, about, featured article */
.split.b { grid-template-columns: 1.02fr 1fr }      /* home hero */
.split.c { grid-template-columns: 1fr 1.15fr }      /* WhyUs */
.split.d { grid-template-columns: .85fr 1.3fr; align-items: start }  /* FAQ (sticky head) */
.split.e { grid-template-columns: .78fr 1.4fr; align-items: start }  /* contact */
.split.f { grid-template-columns: 1.5fr auto }      /* CTA band */
.split.g { grid-template-columns: 1.3fr 1fr }       /* newsletter */
.split.h { grid-template-columns: 1.55fr .75fr; align-items: start; gap: 64px }
/* .split.h wins its gap/align over .split on SPECIFICITY (0,2,0 vs 0,1,0),
   not source order — same for .d and .e's align-items:start. */

/* MIN-WIDTH:0 ON EVERY GRID ITEM. A grid (and flex) item defaults to
   `min-width: auto`, which means it REFUSES to shrink below the min-content
   width of what is inside it. That silently defeats every overflow container
   nested in it: a `.prose table { overflow-x: auto }` inside .split.h (the
   article body column) still pushed its column to the table's intrinsic width,
   documentElement.scrollWidth went to 521 against a 390 clientWidth, and because
   body is overflow-x:hidden the excess was clipped rather than scrollable —
   the reader simply lost the right-hand columns with no way to reach them.
   Measured on /articles/{slug} at 390px; the same page inside a plain block
   container (/page/{slug}) already behaved correctly, which is what identified
   the grid item as the cause rather than the table rule.
   min-width:0 lets the column shrink and hands the scrolling to the element that
   asked for it. */
.split > *, .grid > * { min-width: 0 }

.fcols { display: grid; grid-template-columns: 1.7fr 1fr 1fr 1fr 1.2fr; gap: 40px }
.priceline { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap }

/* --- 3.9 Prose (CMS rich-text article body) ------------------------------- */
/* Sanitise on output. The tag set the seed content needs is exactly:
   h2 h3 p ul ol li strong em a blockquote img. */

/* THE MEASURE LIVES HERE, NOT IN A VIEW.
   page.php carried `style="max-width:75ch"` inline, which meant exactly one of
   the five templates that render .prose had a measure at all — /careers,
   case_study.php and partials/job_detail.php ran the full column width, so an
   article body on a 1440px screen was a ~900px line: roughly 120 characters,
   about double the 45-75 that a reader can track without losing their place at
   the line return. 68ch on this 17.5px face is ~800px, which is the top of that
   range and keeps the existing pages close to where they render today.

   ch, not px, so the measure follows the type: it stays ~68 characters if the
   font size ever changes, where a px cap silently gets longer or shorter.

   THE OVERFLOW GUARDS BELONG WITH IT. A .prose body is admin-authored, so it can
   contain a wide table, a long code block or an embed — and the containing
   <body> is overflow-x:hidden (§2), which does not stop the overflow, it just
   makes it invisible and un-scrollable. Anything that cannot be wrapped scrolls
   INSIDE its own element instead: the reader can still reach column five of the
   table, and documentElement.scrollWidth never leaves clientWidth. */
.prose {
  max-width: 68ch;
  font-size: 17.5px;
  line-height: 1.85;
  color: var(--body);
}
.prose table  { display: block; width: 100%; overflow-x: auto }
.prose pre    { overflow-x: auto; max-width: 100% }
.prose iframe { max-width: 100% }
.prose img    { max-width: 100%; height: auto }
/* A pasted URL or an unbroken identifier in running text has no break
   opportunity of its own and would push the column wider than its measure. */
.prose p, .prose li, .prose h2, .prose h3, .prose blockquote { overflow-wrap: anywhere }
.prose > * + * { margin-top: 26px }          /* (0,1,0) — the element rules below
                                                are (0,1,1) and override it */
/* var(--fs-h2), like the other four h2 declarations — but the TOKEN is re-declared
   on .prose, because an in-document subhead is not a band head. .prose is capped at
   68ch (~818px) and the article's own <h1> is clamp(30px, 4.2vw, 52px), so the
   band-level 42px ceiling would put a subhead within a few pixels of the headline
   above it at 1440 and exactly level with it at 390 — a subhead that reads as a
   title. Scoping the token instead of hardcoding a second literal keeps ONE
   declaration pattern (`font-size: var(--fs-h2)`) across all five sites, and keeps
   the relationship between the two sizes in one place. */
.prose { --fs-h2: clamp(24px, 2.2vw, 30px) }
.prose h2 { font-size: var(--fs-h2); margin-top: 52px }
.prose h3 { font-size: 21px; margin-top: 40px }
.prose ul { padding-left: 22px; margin-top: 20px }
.prose li { margin-bottom: 10px }
.prose strong { color: var(--text); font-weight: 600 }
.prose blockquote {
  margin: 40px 0;
  padding: 28px 32px;
  border-left: 3px solid var(--accent);
  background: var(--surface-2);
  border-radius: 0 16px 16px 0;             /* square on the accent edge only */
  font-family: var(--font-display);
  font-size: 21px;
  line-height: 1.55;
  color: var(--text);
  font-weight: 500;
  letter-spacing: -.02em;
}
/* The FIRST child of .prose gets no top margin (every seeded article starts
   with a <p>), so the gap under the article hero image is exactly that image's
   own margin-bottom:44px — see .art-hero in §6.13. */

/* --- 3.10 Nav / drawer utilities ----------------------------------------- */

.navlinks { display: flex; align-items: center; gap: 2px }
.navlinks a { white-space: nowrap }
.navlinks a {
  padding: 8px 15px;
  font-size: 15px;
  font-weight: 500;
  border-radius: 999px;
  color: var(--body);                 /* inactive */
  background: transparent;
  transition: all .2s;
}
.navlinks a:hover { opacity: 1; color: var(--text) }
/* "YOU ARE HERE" NEEDS A REAL CUE. The pill was --surface-2 (#f7f8f8) on a
   #ffffff header — 1.04:1, i.e. no perceptible fill — with the label at --text,
   the same colour every other nav link takes on hover. So the current page was
   marked by nothing a visitor could see, and a keyboard user had no orientation
   at all. --accent-soft is the site's own pale-blue wash (already the .eyebrow
   and .svc-ico background) and --accent on it measures ~5.5:1, so the active
   item now differs from its neighbours in BOTH fill and label colour rather than
   in a colour difference alone. */
.navlinks a.on { color: var(--accent); background: var(--accent-soft) }
/* Active detection is server-side: the layout compares a $activePage slug. */

.navcta { display: inline-flex }      /* wrapper round the header "Get started"
                                        button; hidden at <=1180px */

.burger {
  display: none;                      /* flipped to flex at <=1180px */
  width: 44px;
  height: 44px;
  border-radius: 12px;
  /* --border-strong. The burger is the ONLY way into the navigation below
     1180px, and at 1.23:1 its box was invisible — the control was three bars
     floating in the corner with no affordance saying "button". */
  border: 1px solid var(--border-strong);
  background: var(--surface);
  color: var(--text);
  align-items: center;
  justify-content: center;
  cursor: pointer;
}

.drawer {
  position: fixed;
  inset: 0;
  z-index: 70;                        /* ABOVE the header (60) — the drawer
                                         deliberately covers the header. */
  background: color-mix(in srgb, var(--ink) 55%, transparent);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  animation: fadeIn .2s ease;
}
.drawer[hidden] { display: none }
/* The prototype mounted/unmounted the drawer, which is what replayed the
   fadeIn every time. CI4 renders the markup up front, so JS toggles [hidden]
   and forces a reflow to replay the animation. */

.drawer-panel {
  position: absolute;
  top: 0; right: 0; bottom: 0;
  width: min(340px, 86vw);
  background: var(--bg);
  border-left: 1px solid var(--border);
  padding: 24px;
  display: flex;
  flex-direction: column;
  gap: 6px;
  overflow-y: auto;
}
.drawer-panel a {
  padding: 14px 16px;
  border-radius: 12px;
  font-size: 17px;
  font-weight: 500;
  color: var(--text);
}
.drawer-panel a:hover { background: var(--surface-2); opacity: 1 }
.drawer-panel a.on { background: var(--surface-2); color: var(--accent) }
/* ODDITY PRESERVED: the drawer's active link is GREEN (--accent) while the
   desktop nav's active link is --text. Spec 00 §D.2 flags the inconsistency;
   it is faithful to the prototype, so we keep it. */

.drawer-hd {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 18px;
}
.drawer-close {
  /* 44x44, was 40x40. The burger that OPENS the drawer is already 44 (the WCAG
     2.5.5 / platform minimum); the button that closes it was 40, so the one
     control a phone user needs to get out of the drawer was the smallest target
     in the chrome. Both ends of the same interaction now measure the same.
     --border-strong for the 1.4.11 reason shared with .burger and .fld. */
  width: 44px;
  height: 44px;
  border-radius: 12px;
  border: 1px solid var(--border-strong);
  background: transparent;
  color: var(--text);
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
}
.drawer-cta { margin-top: 16px }

/* --- 3.11 Icon font ------------------------------------------------------ */
/* Every icon in the site is
   <span class="material-symbols-outlined">ligature_name</span>
   with the size set inline (16/17/19/20/21/22/23/26 are the values used).   */

.material-symbols-outlined {
  font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 24;
  user-select: none;
  line-height: 1;
  font-style: normal;   /* ADDITION: the specs' HTML snippets sometimes use <i>
                           for brevity. Without this, a browser synthesises
                           oblique on every ligature glyph. */
}
/* WHY THESE TWO CLASSES EXIST INSTEAD OF INLINE font-variation-settings:
   the property is NOT additive. The prototype's Icon primitive wrote only
   'FILL' x,'wght' y inline, which silently reset GRAD to 0-default and opsz to
   the font's own default on every single icon. Restating all four axes in a
   class keeps the axis set intact. */
.material-symbols-outlined.ico-fill  { font-variation-settings: 'FILL' 1, 'wght' 300, 'GRAD' 0, 'opsz' 24 }
.material-symbols-outlined.ico-w500  { font-variation-settings: 'FILL' 0, 'wght' 500, 'GRAD' 0, 'opsz' 24 }
/* ico-w500 -> the logo mark's graphic_eq glyph.
   ico-fill -> PlayButton's play_arrow, the pricing check_circle, the contact
               success banner's task_alt. */

/* --- 3.12 Media --------------------------------------------------------- */
/* The prototype used an <image-slot> custom element (65KB of placeholder JS,
   not shipped). Every slot becomes a real <img> fed from the media library. */

image-slot { display: block; width: 100%; height: 100% }
.img-slot  { display: block; width: 100%; height: 100%; object-fit: cover }
/* Always give the <img> width/height or an aspect-ratio wrapper to prevent CLS,
   loading="lazy" everywhere except the hero photo. */

/* --- 3.13 Keyframes ----------------------------------------------------- */

@keyframes ticker    { to { transform: translateX(-50%) } }
@keyframes fadeIn    { from { opacity: 0 } to { opacity: 1 } }
@keyframes floaty    { 0%, 100% { transform: translateY(0) } 50% { transform: translateY(-12px) } }
@keyframes ringPulse { 0% { transform: scale(1); opacity: .55 } 100% { transform: scale(1.7); opacity: 0 } }
/* ticker    — the home logo marquee. See .ticker-track in §6.1 for the
                duplication/translate fix.
   fadeIn    — drawer scrim (.2s), back-to-top entrance (.3s), project cards
                and the contact success banner.
   floaty    — the hero floating stat card (6s).
   ringPulse — the expanding ring behind PlayButton (2.6s). */


/* ============================================================================
   4. PRIMITIVES (ui.jsx -> CSS)
   ============================================================================ */

/* --- 4.1 SectionHead ----------------------------------------------------- */
/* CANONICAL NAMES ARE SPEC 00's: .sechead / .sechead.center / .sechead.has-cta
   / .sechead-txt / .sechead.light / .sechead-ctaonly.

   The per-page specs each invented their own parallel names for the SAME
   structure, because they were written independently:
     spec 02: .sechead-left  .sechead-center  .sechead-row  .sechead-row-txt
     spec 03: .sechead--center  .sechead--left-cta  .sechead-txt
     spec 04: .sec-head  .sec-head--cta
     spec 05: .faq-head
   Structure is byte-identical in every case (same wrapper, same children), so
   these are pure NAME aliases and are safe to fold into the same rules — there
   is no specificity puzzle. They are listed here so a view written against any
   spec renders correctly. Rejected alternative: shipping only spec 00's names
   and renaming five view files; that is the tidier end state, so the rename is
   raised in the handoff — but a stylesheet that only works for one spec's
   vocabulary would break pages that other agents are writing right now. */

.sechead,
.sechead-left, .sechead-row, .sechead--left-cta,
.sec-head, .sec-head--cta,
.sechead-center, .sechead--center,
.faq-head {
  display: block;
  align-items: flex-end;              /* inert until display:flex (has-cta) */
  justify-content: space-between;
  gap: 40px;
  flex-wrap: wrap;
  text-align: left;
  max-width: none;
  /* 56px, was 44px — see the .sechead.center note below for why the two now
     match. */
  margin: 0 0 calc(56px * var(--space));
}

/* centred variant — the default `align` of the primitive. `max` defaults to
   720px and is a per-call override: emit an inline max-width ONLY when it
   differs from 720.

   THE BOTTOM MARGIN NOW MATCHES THE LEFT-ALIGNED HEAD (56px, was 64px against
   the base rule's 44px). Alignment is a typographic choice; it is not a reason
   for the gap between a heading and the content it introduces to change by 20px.
   On /services the three bands each pick a different variant, so the same
   heading-to-content relationship rendered at three different sizes down one
   page and the rhythm read as accidental. 56px is the midpoint, so no band moves
   far from where it was designed. */
.sechead.center, .sechead-center, .sechead--center {
  text-align: center;
  max-width: 720px;
  margin: 0 auto calc(56px * var(--space));
}

/* SECTION HEADS THAT ARE NOT THE FIRST CHILD NEED THEIR OWN TOP MARGIN.
   The primitive ships `margin-top: 0` because it was designed as the first thing
   inside a `.sec`. Views stack them: /work/{slug} puts "Built with" directly
   after the metrics grid, and /pricing and /about do the same thing — and §2 of
   this file records that nothing in this layout produces margin collapse to lean
   on, so the head started at the exact y the previous block ended. A measured 0px
   gap between a paragraph and the next section's h2 is not a tight rhythm, it is
   two sections touching.

   80px matches the internal spacing of a `.sec` and is multiplied by --space like
   every other vertical value here. :not(:first-child) means a head that IS the
   first child is untouched, so no existing page shifts. The alias list is the
   same one as the base rule above — a view written against any of the four spec
   vocabularies has to behave identically. */
.sechead:not(:first-child),
.sechead-left:not(:first-child), .sechead-row:not(:first-child), .sechead--left-cta:not(:first-child),
.sec-head:not(:first-child), .sec-head--cta:not(:first-child),
.sechead-center:not(:first-child), .sechead--center:not(:first-child),
.faq-head:not(:first-child) {
  margin-top: calc(80px * var(--space));
}

/* with a cta node on the right — the head becomes a wrapping flex row and the
   text block is capped at 640px. BOTH are required: dropping either changes
   the line breaks. */
.sechead.has-cta, .sechead-row, .sechead--left-cta, .sec-head--cta {
  display: flex;
}
.sechead.has-cta .sechead-txt,
.sechead-row .sechead-row-txt,
.sechead-row .sechead-txt,
.sechead--left-cta .sechead-txt,
.sec-head--cta > div:first-child {
  max-width: 640px;
}

.sechead h2,
.sechead-left h2, .sechead-row h2, .sechead--left-cta h2,
.sec-head h2, .sec-head--cta h2,
.sechead-center h2, .sechead--center h2,
.faq-head h2 {
  /* var(--fs-h2), was clamp(30px, 3.6vw, 46px) — one of the five h2 declarations
     that each carried their own size. See the --fs-h2 note in §1.1. */
  font-size: var(--fs-h2);
  color: var(--text);
  margin-bottom: 18px;
}
/* Spec 00 models this as margin-bottom:0 by default plus `.sechead.has-sub h2
   {margin-bottom:18px}`. Every section head that ships has a sub, and all four
   page specs transcribe 18px unconditionally — so 18px is the DEFAULT here and
   the two modifier classes below exist for the edge cases. Getting it this way
   round means a view that forgets `has-sub` still looks right. */
.sechead.has-sub h2 { margin-bottom: 18px }      /* redundant, kept for spec 00 parity */
.sechead.no-sub h2  { margin-bottom: 0 }         /* the sub-less branch */

.sechead p,
.sechead-left p, .sechead-row p, .sechead--left-cta p,
.sec-head p, .sec-head--cta p,
.sechead-center p, .sechead--center p,
.faq-head p {
  font-size: 17px;
  line-height: 1.75;
  color: var(--body);
}

/* `light` — heads sitting on --ink (WhyUs). */
.sechead.light h2, .sechead-left.light h2 { color: #fff }
.sechead.light p,  .sechead-left.light p  { color: rgba(255,255,255,.72) }

/* hide + cta -> the primitive renders ONLY the right-aligned cta. hide with no
   cta renders nothing at all (that is the `bare` prop on About/Services/
   Portfolio/Pricing/Contact — the view must emit no wrapper). */
.sechead-ctaonly {
  display: flex;
  justify-content: flex-end;
  margin-bottom: calc(40px * var(--space));
}

/* --- 4.2 PlayButton ----------------------------------------------------- */
/* CANONICAL STRUCTURE IS SPEC 00's: the pulsing ring is a ::before on the
   .playbtn-ring wrapper, so the markup is
     .playbtn > .playbtn-ring > .playbtn-disc > span.material-symbols-outlined.ico-fill
   Spec 02 instead makes .playbtn-ring-wrap the wrapper and .playbtn-ring an
   absolutely-positioned CHILD. That is a STRUCTURAL disagreement, not a name
   one, and supporting both would need a three-rule specificity puzzle that no
   view author can read off the page — so spec 00 wins outright and the About
   view must use the markup above. Raised in the handoff. */

.playbtn {
  display: inline-flex;
  align-items: center;
  gap: 16px;
  color: var(--text);
}
.playbtn-ring {
  position: relative;
  width: 56px;
  height: 56px;
  flex-shrink: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
.playbtn-ring::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 50%;
  border: 1px solid var(--border);
  animation: ringPulse 2.6s ease-out infinite;
}
.playbtn-disc {
  position: relative;                 /* stacks above the pulsing ring */
  width: 56px;
  height: 56px;
  border-radius: 50%;
  background: var(--accent);
  color: #fff;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
.playbtn-disc .material-symbols-outlined { font-size: 26px }
.playbtn-label { font-size: 15.5px; font-weight: 500 }

.playbtn.light { color: #fff }
.playbtn.light .playbtn-ring::before { border-color: rgba(255,255,255,.35) }

/* --- 4.3 Avatar --------------------------------------------------------- */
/* Initials on a colour derived from the name. BOTH algorithms are load-bearing
   and must be reimplemented exactly in PHP or the colours drift:

     initials = name.split(" ")
                    .filter(w => /^[A-Za-zA-yNn]/u.test(w))   // honorifics KEPT
                    .map(w => w[0]).join("").slice(0,2).toUpperCase()
     h = 0; for each UTF-16 code unit c of the FULL name: h = (h*31 + c) % 360

   Honorifics are NOT stripped, so "Engr. JP Villanueva" -> EJ, "Dr. Lorna
   Mendoza" -> DL, "Atty. Grace Lim" -> AG. Unit-test hues: Mariel Reyes 14,
   Engr. JP Villanueva 87, Dr. Lorna Mendoza 258, Ronnie Chua 16,
   Atty. Grace Lim 190.

   The oklch() pair is per-name so it CANNOT be a static class — the view emits
     style="--av-h:212;background:oklch(0.92 0.05 var(--av-h));color:oklch(0.42 0.13 var(--av-h))"
   Size/radius are emitted inline only when they differ from the 48/14 default
   (52/14 in testimonials, 46/14 on the article byline, 40/12 on the featured
   article byline). font-size is size * 0.38. */

.avatar {
  flex-shrink: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 48px;
  height: 48px;
  border-radius: 14px;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 18.24px;                 /* 48 * 0.38 */
  letter-spacing: -.02em;
  border: 1px solid var(--border);
}
img.avatar { object-fit: cover }      /* real headshot from the media library,
                                         same box, preferred where one exists */

/* --- 4.4 ArchLine ------------------------------------------------------- */
/* SPEC RECONCILIATION: spec 00 §C.7 describes .archline as the SVG itself
   (width:100%;height:54px;overflow:visible) while specs 01/02/03 all show
   <div class="archline"><svg .../></div> and give the wrapper's absolute
   positioning. Spec 00 also says the class "is applied by the consumer, not by
   this component", which settles it: .archline is the WRAPPER, and the SVG
   sizing moves to a descendant selector. Both sets of values ship. */

.archline {
  position: absolute;
  left: 58%;
  top: -6px;
  width: 84%;
  opacity: .9;
  pointer-events: none;
}
.archline svg { width: 100%; height: 54px; overflow: visible }
.archline.flip svg, .archline.is-flip svg { transform: scaleY(-1) }
/* `flip` (spec 00) and `is-flip` (spec 03) are the same modifier; both spelled
   so either view renders. Applied on odd step indexes, i.e. the connector
   after step 2 arcs DOWNWARD while 1 and 3 arc up.
   .archline is display:none at <=1040px (§7) — the arcs only make sense while
   the four steps are on one row. */

/* --- 4.5 Counter -------------------------------------------------------- */
/* PHP renders the FINAL value as the element text so it is correct with JS off;
   the script resets to 0 on first intersection and animates
   (IntersectionObserver threshold .4, once, 1800ms, ease-out cubic
   1-(1-p)^3, 2 decimals iff the target is fractional).
   No visual styling of its own — the surrounding .fact-n / .hero-stat-num
   supplies the type. This rule exists only as a JS hook and a reminder. */
.counter { font-variant-numeric: tabular-nums }
/* tabular-nums is an ADDITION: without it the digits change width while
   counting and the number visibly jitters. */


/* ============================================================================
   5. SITE CHROME (shell.jsx)
   ============================================================================ */

/* --- 5.1 Header --------------------------------------------------------- */
/* HEIGHT MATTERS AND IS WHY EVERY FIRST SECTION CARRIES ITS OWN TOP PADDING.
   The header is position:fixed and out of flow. Its height is padding + the
   tallest child, and the tallest child CHANGES at the 1180px breakpoint:

     > 1180px : tallest child = .navcta's .btn, ~49px  -> 20+49+20 = ~89px
                                                          (scrolled 12+49+12 = ~73px)
     <=1180px : .navcta is hidden, tallest = .burger 44px -> ~84px / ~68px

   ~89px is the number every clearance value is sized off:
     .banner        padding-top 150px  (126px at <=720px)
     home hero      padding-top 148px
     .nf-sec (404)  padding-top 120px
     [id]           scroll-margin-top 100px
     sticky asides  top 120px
   Change the header height and all five need revisiting. */

.site-header {
  position: fixed;
  inset: 0 0 auto 0;
  z-index: 60;                        /* under the drawer scrim (70), over .btt (50) */
  padding: 20px 0;
  background: transparent;
  border-bottom: 1px solid transparent;   /* declared so the scrolled state can
                                             transition the COLOUR, not the width
                                             (a width change would jump layout) */
  transition: all .3s cubic-bezier(.4,0,.2,1);
}
.site-header.is-scrolled {
  padding: 12px 0;
  background: color-mix(in srgb, var(--bg) 86%, transparent);
  -webkit-backdrop-filter: blur(16px);
  backdrop-filter: blur(16px);
  border-bottom-color: var(--border);
}
/* JS toggles .is-scrolled at window.scrollY > 20 and MUST run once on load —
   otherwise a page restored mid-scroll renders the transparent header over
   content. */

.site-header .wrap, .header-bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 28px;
}

/* --- 5.2 Logo ----------------------------------------------------------- */

.logo { display: flex; align-items: center; gap: 10px; color: inherit }

/* THE MARK IS THE REAL LOGO IMAGE, not an icon glyph on a coloured tile.
   The prototype had no logo asset, so spec 00 §D.3 specified a Material Symbols
   `graphic_eq` glyph, white on a 34px --accent tile with a 10px radius. The
   actual Artitech monogram arrived later, so:

     - background / border-radius / colour are GONE. The monogram is supplied as
       a PNG with a TRANSPARENT background (the white was knocked out), which is
       what lets one file serve both the white header and the navy footer. A
       coloured tile behind it would box in a mark that is already a shape.
     - display:flex is kept only to stop the img's inline-baseline gap.
     - 34px is retained, because it is what sets the header's height budget
       (spec 00 §D.1 measures the bar at ~89px unscrolled off the 49px button,
       not off the mark) — so changing it does not change the header height, but
       it does change the optical weight against the 21px wordmark.

   object-fit:contain matters: the monogram is wider than it is tall after the
   crop, and `cover` would clip its point. */
/* 28px, down from the design's 34px, with the wordmark scaled to match (see
   .logo-word). The prototype sized the mark as a solid coloured tile; the real
   monogram is an open shape on transparency, which reads LARGER at the same box
   size because nothing frames it. Shrinking both together keeps their relative
   proportion and gives the nav back some room.

   This does NOT change the header's height: spec §D.1 measures the bar off the
   49px "Get started" button, not off the mark, so 34 -> 28 is purely optical. */
.logo-mark {
  width: 28px;
  height: 28px;
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
}
.logo-mark img {
  width: 100%;
  height: 100%;
  object-fit: contain;
  display: block;
}
/* 18px, down from the design's 21px, in step with the 34 -> 28px mark above so
   the lockup keeps its proportion instead of the wordmark suddenly dominating. */
.logo-word {
  font-family: var(--font-display);
  font-size: 18px;
  font-weight: 800;
  letter-spacing: -.035em;
  color: var(--text);
}
/* footer + drawer, over --ink. The logo IMAGE needs no light variant — the
   monogram's blues read against both #ffffff and the navy --ink — but the
   wordmark is live text and does. */
.logo.light .logo-word { color: #fff }
/* The anchor still needs aria-label="Artitech — home": the <img> inside carries
   alt="" because the adjacent .logo-word already names the link, and a mark plus
   a wordmark both announcing "Artitech" is a stutter for a screen reader. */

/* --- 5.3 Drawer --------------------------------------------------------- */
/* see §3.10 — .drawer, .drawer-panel, .drawer-hd, .drawer-close, .drawer-cta */

/* --- 5.4 Page banner (inner pages) -------------------------------------- */

.banner {
  position: relative;
  overflow: hidden;                   /* clips the :after glow */
  padding: 150px 0 calc(64px * var(--space));
  /* 150px top is PURE HEADER CLEARANCE (see §5.1). If the header is ever made
     static, this becomes 150px of dead space. Drops to 126px at <=720px. */
  background: var(--surface-2);
  border-bottom: 1px solid var(--border);
}
.banner::after {
  content: "";
  position: absolute;
  top: -260px;
  right: -160px;                      /* off-canvas: contained by body{overflow-x:hidden} */
  width: 600px;
  height: 600px;
  border-radius: 50%;
  background: radial-gradient(circle,
              color-mix(in srgb, var(--accent) 12%, transparent), transparent 68%);
  pointer-events: none;
}
/* the content wrap must be position:relative to stack above ::after */
.banner-inner, .banner > .wrap { position: relative }

.banner h1 {
  font-size: clamp(34px, 4.6vw, 58px);
  font-weight: 800;                   /* overrides the global h1 weight 700 */
  margin: 0 0 18px;                   /* every shipping banner has a sub; see
                                         the .sechead h2 note in §4.1 for why
                                         18px is the default rather than 0 */
  max-width: 880px;
}
.banner.has-sub h1 { margin-bottom: 18px }   /* spec 00 parity, redundant */
.banner.no-sub h1  { margin-bottom: 0 }
.banner p { font-size: 18px; line-height: 1.75; max-width: 620px }

.crumbs {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 14px;
  color: var(--muted);
  margin-bottom: 18px;
}
.crumbs a { color: var(--muted) }
.crumbs a:hover { color: var(--accent); opacity: 1 }
.crumbs .cur { color: var(--text) }
/* The first crumb is always a hardcoded Home -> /. Each following crumb is
   preceded by a chevron_right icon at 16px. The final crumb has no href and
   takes .cur so it reads as "you are here". */

/* --- 5.5 CTA band ------------------------------------------------------- */
/* NOT part of the layout — each page composes it (home puts it MID-page, after
   HowItWorks; about/services/work/pricing put it last; articles/article/404/
   contact omit it entirely). */

.ctaband-sec { padding-top: 0 }
/* padding-top:0 on top of .sec, so the ink panel butts straight against the
   preceding section's bottom padding with no gap, and keeps a full
   --section-pad below. On home the section above is .sec.alt, so the grey band
   ends exactly at the panel's section boundary. */

.ctaband, .cta-band {
  position: relative;
  overflow: hidden;                   /* clips the glow to the 28px radius */
  border-radius: 28px;
  background: var(--ink);
  padding: clamp(40px, 5vw, 68px);
}
.ctaband-glow, .cta-glow {
  position: absolute;
  top: -120px;
  right: -60px;
  width: 380px;
  height: 380px;
  border-radius: 50%;
  background: radial-gradient(circle,
              color-mix(in srgb, var(--accent) 40%, transparent), transparent 70%);
  pointer-events: none;
}
.ctaband-txt, .cta-band-txt { position: relative }
/* var(--fs-h2), was clamp(26px, 3.1vw, 40px) — see §1.1. #fff stays a literal:
   this h2 sits on the --ink panel, whose background does not change between
   themes, so var(--on-accent) would be wrong here. */
.ctaband h2, .cta-band h2 { font-size: var(--fs-h2); color: #fff; margin: 0 0 16px }
.ctaband p,  .cta-band p  { font-size: 17px; color: rgba(255,255,255,.7); max-width: 640px; margin: 0 }

.ctaband-actions, .ctaband-btns, .cta-actions {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 12px;
}
/* NAME ALIASES: spec 00 says .ctaband-actions, specs 02/03 say .ctaband-btns,
   spec 04 says .cta-band/.cta-glow/.cta-actions. Identical structure in all
   three, so they share the rules. Spec 00's names are canonical; see handoff.

   DO NOT add align-items:flex-start here. The default `stretch` is what makes
   both buttons the width of the wider one ("Send a brief" renders exactly as
   wide as "+63 917 555 0142") above 1040px, and full-width once .split.f
   collapses. That is the intended look, not a bug. */

/* the two buttons: markup is `class="btn white"` and `class="btn line on-dark"`
   — see the long note on .btn.white in §3.2 for why. */

/* --- 5.6 Newsletter band ------------------------------------------------ */

.nlband { padding-bottom: calc(72px * var(--space)) }
/* NO top padding and NO .sec class — it butts directly against whatever
   precedes it (usually the CTA band or the last section). Adding top padding
   here changes the spacing on every page. */
.nlband-panel {
  border-radius: 26px;
  background: var(--surface-2);
  border: 1px solid var(--border);
  padding: clamp(34px, 4vw, 54px);
}
/* var(--fs-h2), was clamp(24px, 2.6vw, 34px) — see §1.1. */
.nlband h2 { font-size: var(--fs-h2); margin-bottom: 12px }
.nlband p  { font-size: 16.5px; line-height: 1.7; max-width: 520px }
/* flex-wrap so the email pill can claim its 240px minimum and push Subscribe
   onto a second line, rather than squeezing the pill until its placeholder is
   unreadable. Wrapping is the right failure mode for a two-control row: both
   controls stay usable, they just stack. */
.nlband form { display: flex; flex-wrap: wrap; gap: 10px }
.nlband input {
  flex: 1;
  /* WAS min-width:0, which let the pill shrink to whatever the Subscribe button
     left over. In the .split.g column that is 178px — narrow enough that the
     placeholder "Enter your email address" was clipped mid-word on every page,
     so the one field in the footer did not say what it wanted. 240px is the
     width that shows the whole placeholder; the form wraps rather than overflows
     when the column cannot give it (see .nlband form above). */
  min-width: 240px;
  padding: 14px 18px;
  font-family: var(--font-body);
  /* 16px, not the design's 15px: below 16px mobile Safari zooms the whole page
     on focus and never zooms back out. Same call, same reason, as .fld — see
     the note there. */
  font-size: 16px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--text);
  outline: none;
}
.nlband input:focus {
  border-color: var(--accent);
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 12%, transparent);
}
.nlband-status { margin-top: 12px; font-size: 14px; color: var(--body) }
/* ADDITION. The prototype's form was fake (preventDefault, a "Subscribed"
   label for 5s, no POST). CI4 does a real POST with csrf_field() and needs a
   place to render the flash message — give it aria-live="polite". */

/* --- 5.7 Footer -------------------------------------------------------- */
/* The footer is ALWAYS dark regardless of data-theme: it paints var(--ink) and
   hardcodes rgba(255,255,255,.6x) text, so it does not follow the theme the
   way the rest of the page does. */

.site-footer {
  background: var(--ink);
  color: rgba(255,255,255,.62);
  padding-top: 72px;
}
.site-footer .fcols { padding-bottom: 56px }

.f-brand-logo { margin-bottom: 20px }
.f-blurb { font-size: 15px; line-height: 1.8; color: rgba(255,255,255,.6); max-width: 300px }

/* h3 AND h4. The footer column headings were raised from h4 to h3 (the band
   above them emits an h2, so h4 skipped a level on all eleven pages); the h4
   selector is kept so nothing that still emits one loses its styling. The size
   is set here, not by the tag, which is why the semantic change is invisible. */
.f-col h3, .f-col h4 { font-size: 15px; font-weight: 700; color: #fff; margin-bottom: 18px }
.f-col a  { display: block; padding: 6px 0; font-size: 14.5px; color: rgba(255,255,255,.6) }
.f-col a:hover { color: #fff; opacity: 1 }
/* DELIBERATE ADDITION, not a transcription: the prototype had NO footer hover
   rules at all, so footer links inherited a:hover{opacity:.75} and FADED.
   Brightening reads better on a dark ground. Rejected alternative: delete this
   rule and the .f-social one below for byte-exact prototype fidelity. */

.f-contact { font-size: 14.5px; line-height: 1.85; color: rgba(255,255,255,.6); margin-bottom: 20px }
/* BLOCK, at every width, and site_footer.php emits NO <br> after these anchors.
   The email and phone rows each need their own line and — on a phone — their own
   44px tap target, which an inline anchor cannot carry (an inline box ignores
   min-height). Making it block at all widths rather than only at <=720px keeps ONE
   markup shape: with a width-conditional display, the <br> the desktop layout
   needs becomes a stray blank line the moment the anchor turns block. The address
   lines above are bare text nodes and keep their <br>s. */
.f-contact a { display: block; width: fit-content }

.f-social { display: flex; gap: 9px }
.f-social a {
  width: 36px;
  height: 36px;
  border-radius: 10px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(255,255,255,.08);
  color: #fff;
}
.f-social a:hover { background: rgba(255,255,255,.16); opacity: 1 }   /* addition, as above */
.f-social .material-symbols-outlined { font-size: 17px }

.f-legal {
  display: flex;
  justify-content: space-between;
  align-items: center;
  flex-wrap: wrap;
  gap: 14px;
  padding: 24px 0;
  border-top: 1px solid rgba(255,255,255,.10);
  font-size: 14px;
}
.f-status { display: flex; align-items: center; gap: 8px }
.f-status-dot { width: 7px; height: 7px; border-radius: 99px; background: #12b76a }
/* #12b76a is a hardcoded "healthy" green, not a token — the same literal is
   reused by the hero stat card's "live" badge. */

/* --- 5.8 Back to top --------------------------------------------------- */

.btt {
  position: fixed;
  right: 26px;
  bottom: 26px;
  z-index: 50;                        /* under the header (60) and drawer (70) */
  width: 46px;
  height: 46px;
  border-radius: 14px;
  border: none;
  cursor: pointer;
  background: var(--accent);
  color: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: var(--shadow-lg);
  animation: fadeIn .3s ease;
}
.btt[hidden] { display: none }
/* Rendered in the layout with [hidden]; JS toggles the attribute at
   scrollY > 700. The prototype had NO initial call, so on a restored scroll
   position the button only appeared after the first scroll event — call the
   handler once on load to fix that. */


/* ============================================================================
   6. PAGE SECTIONS
   ----------------------------------------------------------------------------
   These come BEFORE the media queries on purpose. Several of the grids below
   (.tier-grid, .steps, .factgrid, .stack-grid, .included-grid) restate
   grid-template-columns at the same specificity as .g3/.g4. Because the 1040px
   and 720px rules live LATER in the file, the media queries win and those grids
   collapse correctly. Move §7 above §6 and every one of them stays 3- or 4-up
   on a phone.
   ============================================================================ */

/* --- 6.1 Home: hero ----------------------------------------------------- */
/* The home page renders NO .banner — the hero replaces it, so the hero owns
   its own header clearance. Class names in this block are INVENTED: spec 01
   transcribes the hero entirely as inline style objects and names only
   .hero-stat. Raised in the handoff. */

.hero {
  position: relative;
  overflow: hidden;                   /* clips BOTH the glow and the floating
                                         stat card, which sits at left:-14px */
  padding-top: 148px;                 /* header clearance — see §5.1. A hard
                                         literal: it does NOT shrink at 720px. */
  padding-bottom: 72px;               /* also a literal, also unchanged */
}
.hero-glow {
  position: absolute;
  top: -260px;
  right: -180px;
  width: 640px;
  height: 640px;
  border-radius: 50%;
  pointer-events: none;
  background: radial-gradient(circle,
              color-mix(in srgb, var(--accent) 14%, transparent), transparent 68%);
}
.hero-inner { position: relative }     /* stacks the .wrap above the glow */
.hero .split.b { text-align: left }

.hero h1 {
  font-size: clamp(34px, 4.3vw, 58px);
  font-weight: 800;
  line-height: 1.06;                  /* tighter than the global 1.14 */
  margin-bottom: 24px;
  max-width: 620px;
}
.hero h1 .accent { color: var(--accent) }
/* The headline is stored as two CMS fields (headline_lead + headline_accent)
   and the view wraps the second in <span class="accent"> — rather than one
   HTML-capable field, which would let an editor inject arbitrary markup. */

.hero .eyebrow { margin-bottom: 26px }         /* overrides the class's 20px */
.hero-lead { font-size: 18.5px; line-height: 1.75; max-width: 540px; margin: 0 0 36px }
.hero-btns { display: flex; gap: 12px; flex-wrap: wrap; justify-content: flex-start; margin-bottom: 52px }
.hero-trust {
  font-size: 12.5px;
  color: var(--accent);
  margin-bottom: 18px;
  font-weight: 700;
  letter-spacing: .06em;
  text-transform: uppercase;
}
.hero-logos { display: flex; gap: 30px; flex-wrap: wrap; align-items: center }
.hero-logos span {
  font-family: var(--font-display);
  font-size: 19px;
  font-weight: 700;
  color: var(--text);
  letter-spacing: -.03em;
  opacity: .72;
}
/* Client logos are TEXT WORDMARKS, not images, in both the hero and the
   ticker. Giving client_logos an image field is a design change — flag it
   before doing it. */

.hero-media { position: relative; padding-bottom: 40px; padding-left: 30px }
.hero-frame {
  border-radius: 26px;
  overflow: hidden;
  aspect-ratio: 4 / 4.4;              /* portrait */
  box-shadow: var(--shadow-lg);
  background: var(--surface-3);       /* shows while the image loads */
}
.hero-frame img { display: block; width: 100%; height: 100%; object-fit: cover }
/* the hero photo is the one image on the site that must be
   loading="eager" fetchpriority="high" */

.hero-stat {
  position: absolute;
  left: -14px;                        /* deliberately overhangs; clipped by
                                         .hero{overflow:hidden} */
  bottom: 8px;
  width: 268px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 18px;
  padding: 20px;
  box-shadow: var(--shadow-lg);
  animation: floaty 6s ease-in-out infinite;
}
.hero-stat-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px }
.hero-stat-label { font-size: 12.5px; font-weight: 600; color: var(--muted) }
.hero-stat-live {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  font-size: 11.5px;
  font-weight: 600;
  color: #12b76a;                     /* same hardcoded green as .f-status-dot */
}
.hero-stat-dot { width: 7px; height: 7px; border-radius: 99px; background: #12b76a }
.hero-stat-num {
  font-family: var(--font-display);
  font-size: 34px;
  font-weight: 800;
  color: var(--text);
  letter-spacing: -.04em;
  line-height: 1;
}
.hero-stat-cap { font-size: 13px; color: var(--body); margin-bottom: 16px }

.hero-spark { display: flex; align-items: flex-end; gap: 4px; height: 42px }
.hero-spark span {
  flex: 1;
  border-radius: 3px;
  background: color-mix(in srgb, var(--accent) 24%, transparent);
}
.hero-spark span.hi { background: var(--accent) }
/* Ten bars; the per-bar height is a percentage emitted inline by the view
   (52 68 44 78 60 88 72 95 80 100) and the last three carry .hi so they paint
   full accent. aria-hidden="true" on the container — it is decoration. */

/* --- 6.1b Home: logo ticker -------------------------------------------- */

.ticker {
  padding: 34px 0;
  border-top: 1px solid var(--border);
  border-bottom: 1px solid var(--border);
  overflow: hidden;
}
.ticker-track {
  display: flex;
  gap: 76px;
  width: fit-content;
  align-items: center;
  animation: ticker 40s linear infinite;
}
.ticker-track span {
  font-family: var(--font-display);
  font-size: 19px;
  font-weight: 700;
  letter-spacing: -.02em;
  color: var(--muted);
  white-space: nowrap;
  opacity: .7;
}
/* BUG FIXED ON PORT (spec 01 §2). The prototype rendered the six wordmarks
   THREE times (18 spans) but animated translateX(-50%), i.e. half of 18 = 9
   items = 1.5 pattern cycles, so the loop visibly jumped on restart. Emit
   exactly TWO copies (12 spans) and -50% loops seamlessly. Rejected
   alternative: keep three copies and animate -33.3333% — same result, but two
   copies is less markup. */

/* --- 6.2 Shared: About block ------------------------------------------- */
/* Rendered on home (with its section head) and on /about (with `bare`, head
   suppressed — the page banner carries the heading instead). */

.about-photo {
  border-radius: 24px;
  overflow: hidden;
  aspect-ratio: 4 / 4.6;
  box-shadow: var(--shadow);
  background: var(--surface-3);
}
.about-photo img { display: block; width: 100%; height: 100%; object-fit: cover }

.about-mission {
  position: absolute;
  right: -26px;                       /* OVERHANGS BY DESIGN. Nothing clips it
                                         except body{overflow-x:hidden}; the
                                         section has no overflow:hidden. On the
                                         desktop .split.a it lands over the
                                         column gutter. */
  bottom: -30px;
  width: 280px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 18px;
  padding: 24px;
  box-shadow: var(--shadow-lg);
}
/* h3 AND h4. This was `.about-mission h4` alone — an ELEMENT-QUALIFIED selector,
   which is exactly why home.php and about.php were forced to emit an h4 here and
   skip a heading level to keep the card looking right (home.php's header
   documents the compromise and names this rule as the reason). Accepting both
   frees the view to use the correct level; the type size is set here either way,
   so nothing moves a pixel. */
.about-mission h2, .about-mission h3, .about-mission h4 { font-size: 16px; font-weight: 700; margin-bottom: 8px }
.about-mission p  { font-size: 14px; line-height: 1.65 }

.about-lede  { font-size: 16.5px; line-height: 1.8; margin-bottom: 30px }
.about-oph   { font-size: 16px; font-weight: 700; margin-bottom: 16px }
.about-chips { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 34px }
.about-chips .chip { cursor: default }   /* these are decorative labels, not
                                            filters: no .on state, no handler.
                                            .chip:hover:not(.on) still fires —
                                            faithful to the prototype. */
.about-chips .chip .material-symbols-outlined { font-size: 16px; color: var(--accent) }

/* --- 6.3 Shared: WhyUs (dark band) ------------------------------------- */

.whyus {
  background: var(--ink);
  padding: 104px 0;                   /* A HARD LITERAL, not .sec: this band
                                         does NOT shrink to 72px at <=720px the
                                         way every .sec does. Preserved. */
  position: relative;
  overflow: hidden;
}
.whyus-dots {
  position: absolute;
  inset: 0;
  background-image: radial-gradient(rgba(255,255,255,.055) 1px, transparent 1px);
  background-size: 30px 30px;
}
.whyus-inner { position: relative }    /* stacks the .wrap above the dot grid */

.whyus-grid, .whyus .grid.g2 {
  gap: 1px;                           /* overrides .grid's 24px */
  background: rgba(255,255,255,.10);
  border-radius: 20px;
  overflow: hidden;
}
/* THE "BORDERS" ARE THE 1px GAP SHOWING THE PARENT BACKGROUND — there are no
   border declarations on the cells at all. Note this is NOT the same recipe as
   .factgrid / .stack-grid below: those sit on --border with var(--bg) cells,
   radius 22 and a real outer border; this one sits on --ink with --ink cells,
   an rgba white divider, radius 20 and NO outer border. */
.whyus-cell { background: var(--ink); padding: 32px }
.whyus-ico {
  width: 46px;
  height: 46px;
  border-radius: 12px;
  margin-bottom: 20px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(255,255,255,.08);
  color: #fff;
}
.whyus-ico .material-symbols-outlined { font-size: 23px }
.whyus-cell h3 { font-size: 18.5px; color: #fff; margin-bottom: 10px }
.whyus-cell p  { font-size: 14.5px; line-height: 1.7; color: rgba(255,255,255,.62) }
/* .g2 is not in the 1040px rule, so this stays 2x2 on tablet and only stacks
   at 720px. */

/* --- 6.4 Shared: HowItWorks (steps + facts) ---------------------------- */

.steps {
  gap: 26px;                          /* overrides .grid's 24px */
  margin-bottom: 80px;                /* literal — survives every breakpoint */
}
.step { position: relative }          /* the ArchLine anchors to this. NOT
                                         overflow:hidden — the arc overflows
                                         the step box deliberately. */
.step-n {
  width: 52px;
  height: 52px;
  border-radius: 14px;
  margin-bottom: 22px;
  background: var(--surface);
  /* --border-strong: the step number is a bordered tile carrying the whole
     visual structure of the process band. At 1.23:1 the tile had no edge and the
     numerals floated. */
  border: 1px solid var(--border-strong);
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-display);
  font-size: 20px;
  font-weight: 800;
  color: var(--accent);
  box-shadow: var(--shadow-sm);
}
/* ADDITION — the step number and its DURATION on one line.
   process_steps.duration is a labelled column that no view rendered, so the
   timings the copy already mentions in passing stayed buried in a paragraph
   ("we usually turn a proposal around in three working days") instead of being
   scannable. Surfacing it beside the number is the whole point of a numbered
   process band: a reader wants to know how long each stage takes without
   reading four paragraphs.

   .step-head is a flex row so the duration sits BESIDE the tile rather than
   under it; the tile's own 22px bottom margin moves to the row, so a step with
   no duration renders at exactly the height it did before. */
.step-head { display: flex; align-items: center; gap: 12px; margin-bottom: 22px }
.step-head .step-n { margin-bottom: 0 }
/* Quiet by design: the duration is supporting detail, not the headline. Two of
   the four steps have no duration stored and render no pill at all, which is
   correct — an empty pill would claim a timing that does not exist. */
.step-dur {
  font-size: 12.5px;
  font-weight: 600;
  letter-spacing: .02em;
  color: var(--muted);
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: 999px;
  padding: 5px 12px;
  white-space: nowrap;
}
.step h3 { font-size: 20px; margin-bottom: 12px }
.step p  { font-size: 15px; line-height: 1.72 }

.facts, .factgrid {
  gap: 1px;                           /* the hairline trick again */
  background: var(--border);
  border: 1px solid var(--border);    /* this one HAS an outer border */
  border-radius: 22px;
  overflow: hidden;
}
/* NAME ALIAS: spec 02 calls the container .facts, spec 03 calls it .factgrid.
   Identical structure, so both are spelled. Spec 03's is the tidier name (it
   does not collide visually with the .fact child); see handoff. */
.fact { background: var(--bg); padding: 38px 30px; text-align: center }
/* var(--bg) (white in light theme) inside an .alt (--surface-2) section, so the
   strip reads as a white card floating on the grey band. In DARK theme --bg is
   darker than --surface-2, so it recedes instead — the deliberate inversion
   documented in §1.2. */
.fact-n {
  font-family: var(--font-display);
  font-size: clamp(30px, 3.4vw, 44px);
  font-weight: 800;
  color: var(--text);
  letter-spacing: -.045em;
  line-height: 1;
}
.fact-l { font-size: 14.5px; color: var(--body); margin-top: 10px }

/* --- 6.5 Shared: Services grid ---------------------------------------- */

.svc-card, #services .card { padding: 36px }
.svc-ico {
  width: 54px;
  height: 54px;
  border-radius: 14px;
  margin-bottom: 26px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--accent-soft);
  color: var(--accent);
  transition: all .3s;
}
.svc-ico .material-symbols-outlined { font-size: 26px }
.card:hover .svc-ico { background: var(--accent); color: var(--on-accent) }
/* The prototype tracked hover in React state; this is the pure-CSS rewrite.
   The .3s here is on a CHILD element so it survives the .rv transition
   shorthand clash described in §3.4. */
/* FLEX COLUMN so "Read more" can be pinned to the foot of the card. The grid
   already equalises the row to the tallest card, but the CTA sat wherever the
   description happened to stop — a measured 54px stagger across row two on
   /services, so the eye had no line to travel along and the row read as three
   unrelated boxes rather than one row of choices. Same treatment as
   .pf-card-body in §6.9 and .article-card__body below; the pattern is "the
   action line is the card's floor", applied everywhere it appears. */
#services .card { display: flex; flex-direction: column }
#services .card h3 { font-size: 21px; margin-bottom: 14px }
#services .card p  { font-size: 15.5px; line-height: 1.75; margin-bottom: 24px }

.svc-more {
  display: inline-flex;
  align-items: center;
  /* pinned to the card's floor — see #services .card above */
  margin-top: auto;
  align-self: flex-start;
  gap: 7px;
  font-size: 15px;
  font-weight: 600;
  color: var(--accent);
}
.svc-more .material-symbols-outlined { font-size: 18px; transition: transform .25s }
.card:hover .svc-more .material-symbols-outlined { transform: translateX(4px) }
/* The reveal stagger (0/55/110/165/220/275ms) is emitted per card as an inline
   transition-delay — it is an index-derived value, so it cannot be a class. */

/* --- 6.6 Shared: TechStack -------------------------------------------- */

.stack-grid, .stackgrid {
  gap: 1px;
  background: var(--border);
  border: 1px solid var(--border);
  border-radius: 22px;
  overflow: hidden;
}
.stack-cell, .stackcell { background: var(--bg); padding: 30px }
.stack-cell h3, .stackcell h3 {
  font-size: 13px;
  font-weight: 700;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--muted);
  margin-bottom: 18px;
}
.stack-items, .pillrow { display: flex; flex-wrap: wrap; gap: 8px }
.stack-pill, .pillrow .pill {
  font-size: 14px;
  padding: 6px 14px;
  border-radius: 999px;
  border: 1px solid var(--border);
  color: var(--text);
  background: var(--surface-2);
}
/* NAME ALIASES: spec 02 says .stack-grid/.stack-cell/.stack-items/.stack-pill,
   spec 03 says .stackgrid/.stackcell/.pillrow/.pill. Identical structure.
   Spec 02's hyphenated set is canonical (`.pill` alone is far too generic a
   name to own in a shared stylesheet); see handoff.
   The pills are STATIC labels — no hover, no click, and deliberately not
   .chip. */

/* --- 6.7 Shared: Team ------------------------------------------------- */

.team-photo {
  border-radius: 20px;
  overflow: hidden;
  aspect-ratio: 1 / 1.12;
  background: var(--surface-3);
  margin-bottom: 20px;
}
.team-photo img { display: block; width: 100%; height: 100%; object-fit: cover }
.team-card h3 { font-size: 19px; margin-bottom: 5px }
.team-role  { font-size: 14.5px; color: var(--accent); font-weight: 500; margin-bottom: 8px }
.team-focus { font-size: 14.5px; line-height: 1.6; margin-bottom: 14px }
.team-links { display: flex; gap: 8px }
.team-links a {
  width: 34px;
  height: 34px;
  border-radius: 10px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 1px solid var(--border);
  color: var(--body);
  transition: all .2s;
}
.team-links .material-symbols-outlined { font-size: 17px }
/* The prototype wires NO hover beyond transition:all .2s, so these only get
   the global a:hover{opacity:.75} fade. Reproduced as-is; adding
   border-color:var(--text) needs designer sign-off. */

/* --- 6.8 Shared: Testimonials rail ----------------------------------- */

.ts-rail, .rail {
  display: flex;
  gap: 24px;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;              /* Firefox; WebKit uses .noscroll */
  padding: 4px calc(max(26px, (100vw - var(--maxw)) / 2 + 26px)) 8px;
}
/* THE PADDING FORMULA IS LOAD-BEARING. The rail is a SIBLING of .wrap, not a
   child: it spans the full viewport so cards can bleed off the right edge,
   while the left pad reproduces the .wrap gutter exactly ((100vw-1220px)/2
   + 26px above 1220px, clamped to 26px below) so card 1 lines up with the
   section head above it. Quote it exactly.
   Known 6px mismatch: .wrap drops to 20px at <=1000px but this max() stays at
   26px. Faithful to the prototype; switch both to 20px only deliberately.
   NAME ALIAS: spec 01 says .rail, spec 04 says .ts-rail (canonical). */

.ts-card {
  flex: 0 0 min(430px, 82vw);         /* already fluid; no media query needed */
  padding: 36px;
  scroll-snap-align: start;
  background: var(--bg);              /* white card on the --surface-2 band */
}
.ts-quotemark {
  font-family: var(--font-display);
  font-size: 60px;
  line-height: .8;
  color: var(--accent);
  opacity: .35;
  margin-bottom: 12px;
}
.ts-quote {
  font-size: 16.5px;
  line-height: 1.75;
  color: var(--text);
  margin: 0 0 30px;
  min-height: 140px;                  /* equalises card heights across the rail */
}
.ts-person {
  display: flex;
  align-items: center;
  gap: 14px;
  padding-top: 24px;
  border-top: 1px solid var(--border);
}
.ts-avatar { width: 52px; height: 52px; border-radius: 14px; overflow: hidden; flex-shrink: 0 }
.ts-who  { font-family: var(--font-display); font-size: 16.5px; font-weight: 700; color: var(--text) }
.ts-role { font-size: 13.5px; color: var(--muted) }

.ts-nav { display: flex; gap: 10px }
.ts-nav button {
  width: 46px;
  height: 46px;
  border-radius: 50%;
  border: 1px solid var(--border);
  background: var(--surface);
  cursor: pointer;
  color: var(--text);
  display: flex;
  align-items: center;
  justify-content: center;
  transition: all .2s;
}
.ts-nav .material-symbols-outlined { font-size: 22px }
/* Arrows do rail.scrollBy({left: dir * rail.clientWidth * 0.62}). No autoplay,
   no dots, no wrap-around. The prototype's aria-labels were literally the icon
   names — use "Previous testimonial" / "Next testimonial".

   THE END STATE IS NEW, and the "no disabled state" this note used to claim was
   the defect rather than the design: at the first item, "Previous" was focusable,
   looked identical to an active control and did nothing, so the visitor read the
   page as broken instead of reading the rail as finished. initTestimonialRail()
   in artitech.js sets aria-disabled + .is-end from a scroll listener.

   NOT `[disabled]` and not `pointer-events:none` — the button must stay in the tab
   order and stay announceable (see the JS note). It goes quiet, not absent: the
   dimming says "nothing that way" while the control is still there to try. The
   border is held at --border-strong so the pill's own edge does not fall below the
   3:1 that WCAG 1.4.11 asks of a control boundary just because it is inert. */
.ts-nav button.is-end {
  opacity: .4;
  cursor: default;
  border-color: var(--border-strong);
  background: var(--surface-2);
}
.ts-nav button.is-end:hover { transform: none }

/* --- 6.9 Work: portfolio grid + modal -------------------------------- */

/* LEFT-ALIGNED, WAS justify-content: center.
   /work now carries a section head above this row (work.php — the page used to go
   banner <h1> straight to card <h3>), and everything else in the band is
   left-aligned: the banner, the head, and the card grid itself. A centred control
   row between a left-aligned heading and a left-aligned grid was the only centred
   element in the column, so the chips read as belonging to nothing. /articles'
   equivalent `.filterbar` chip row is already left-aligned, so this also makes the
   site's two filter rows agree with each other. */
.pf-filters { display: flex; justify-content: flex-start; gap: 8px; margin-bottom: 44px; flex-wrap: wrap }

.pf-card { overflow: hidden; cursor: pointer; animation: fadeIn .4s ease }
.fade-in { animation: fadeIn .4s ease }
/* .fade-in is an ADDITION for the JS filter: a display toggle does not replay
   a CSS animation, so the script removes the class, forces a reflow and adds
   it back — but ONLY for cards going hidden -> visible. React keyed the cards,
   so a card surviving a filter change never re-animated; replaying it on every
   visible card produces a whole-grid flash the prototype never showed. */

.pf-card-media { aspect-ratio: 16 / 11; background: var(--surface-3); position: relative }
.pf-card-media > img { display: block; width: 100%; height: 100%; object-fit: cover }
.pf-card-tag {
  position: absolute;
  top: 14px;
  left: 14px;
  z-index: 2;
  pointer-events: none;               /* the whole card is the click target */
  font-size: 12px;
  font-weight: 600;
  padding: 5px 13px;
  border-radius: 999px;
  background: var(--surface);
  color: var(--text);
  box-shadow: var(--shadow-sm);
}
/* FLEX COLUMN so the metrics strip and the "Read the case study" line can be
   pinned to the foot of the card. The grid already equalises every card in a row
   to the tallest one, but the CONTENT inside a short card stopped where its text
   stopped, so the metrics rules and the action lines sat at three different
   heights across one row and the eye had nothing to travel along. margin-top:auto
   on the metrics strip pushes it and everything after it down, so the horizontal
   rule above the metrics lines up across the whole row whatever the blurb length.
   The card itself must stretch for this to have any height to distribute — see
   `.pf-card { height: 100% }`. */
.pf-card-body { padding: 28px; display: flex; flex-direction: column; flex: 1 }
/* THE CARD ITSELF MUST BE THE FLEX COLUMN, not just its body. `height: 100%` makes
   the card fill the row the grid equalised, but a block-level .pf-card-body inside
   it is only as tall as its content — so `flex: 1` on the body had no parent to
   stretch against and the `margin-top: auto` below had no free space to consume.
   Measured before this line: three cards 526px tall with their action lines at
   1048 / 1148 / 1176. The media keeps `flex: 0 0 auto` so its aspect-ratio box is
   never squashed to make room. */
.pf-card { height: 100%; display: flex; flex-direction: column }
.pf-card-media { flex: 0 0 auto }
.pf-card-client { font-size: 13.5px; font-weight: 600; color: var(--accent); margin-bottom: 10px }
.pf-card-body h3 {
  /* Above .pf-card-metrics .v (26px). The metric numbers were the largest thing
     in the card, so the eye read "82k" before it read whose project this is —
     the project name has to lead its own card. */
  font-size: 22px;
  line-height: 1.3;
  margin: 0 0 12px;
  overflow-wrap: anywhere;
}
.pf-card-body p  { font-size: 15px; line-height: 1.72; margin: 0 0 22px; color: var(--body) }
.pf-card-metrics { display: flex; gap: 24px; padding-top: 20px; border-top: 1px solid var(--border); margin-top: auto }
.pf-card-metrics .v {
  font-family: var(--font-display);
  font-size: 20px;
  font-weight: 800;
  color: var(--text);
  letter-spacing: -.03em;
}
.pf-card-metrics .l { font-size: 12.5px; color: var(--muted) }

/* ADDITION — the card's route to /work/{slug}. Modelled on .svc-more so the two
   "keep reading" affordances on the site look like one thing. It is underlined
   because it is a link inside a card body, not a button (see §2's inline-link
   rule): colour alone is not an allowed distinction. min-height 44px carries the
   tap target on a phone without changing the type size. */
.pf-card-more {
  display: inline-flex;
  align-items: center;
  align-self: flex-start;
  gap: 7px;
  margin-top: 20px;
  /* see the :has() rule below for why this is not `margin-top: auto` */
  min-height: 44px;
  font-size: 15px;
  font-weight: 600;
  color: var(--accent);
  text-decoration: underline;
  text-underline-offset: 3px;
  text-decoration-thickness: 1px;
}
.pf-card-more .material-symbols-outlined { font-size: 18px; transition: transform .25s }
.pf-card:hover .pf-card-more .material-symbols-outlined { transform: translateX(4px) }

/* WHICHEVER BLOCK IS LAST GETS PINNED TO THE FLOOR.
   `.pf-card-metrics { margin-top: auto }` above aligns the metrics rules across a
   row — but every project's metric columns are nullable, and the card partial
   DROPS a metric pair with an empty value, so a card can legitimately have no
   metrics strip at all. When it does not, nothing carries the auto margin and the
   action line floats up to wherever the description happened to stop: measured on
   /work with the metrics blanked, card 1's "Read the case study" sat 62px above
   cards 2 and 3's. So the action line takes the auto margin in exactly the case
   where there is no metrics strip to take it. Only one of the two ever does, which
   is what keeps the free space in one place instead of splitting it. */
.pf-card-body:not(:has(.pf-card-metrics)) .pf-card-more { margin-top: auto }
/* The link text is "Read the case study" for a sighted reader — six of those in
   a link list tell a screen-reader user nothing, so the project name is appended
   to the accessible name and clipped visually. Same clip idiom as .skip-link. */
.pf-card-more-sr {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

.pf-modal-backdrop {
  position: fixed;
  inset: 0;
  z-index: 90;                        /* above the drawer (70) */
  background: rgba(11,16,32,.6);      /* A HARDCODED LITERAL, deliberately NOT
                                         var(--ink) (#101314) — it is a cooler
                                         blue-black. Do not "fix" it. */
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  animation: fadeIn .2s ease;
}
.pf-modal-backdrop[hidden] { display: none }
.pf-modal {
  background: var(--surface);
  border-radius: 26px;
  box-shadow: var(--shadow-lg);
  max-width: 820px;
  width: 100%;
  max-height: 88vh;
  overflow: auto;
  position: relative;
}
.pf-modal-close {
  position: absolute;
  top: 16px;
  right: 16px;
  z-index: 3;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  border: 1px solid var(--border);
  background: var(--surface);
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--text);
}
.pf-modal-media { aspect-ratio: 16 / 8; background: var(--surface-3) }
.pf-modal-media > img { display: block; width: 100%; height: 100%; object-fit: cover }
.pf-modal-body { padding: 38px }
.pf-modal-client { font-size: 14px; font-weight: 600; color: var(--accent); margin-bottom: 10px }
.pf-modal-body > h3 { font-size: 31px; margin: 0 0 16px }
.pf-modal-body > p  { font-size: 16.5px; line-height: 1.8; margin: 0 0 28px; color: var(--body) }
.pf-modal-services { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 28px }
.pf-modal-services .chip { cursor: default }
.pf-modal .grid { gap: 14px }         /* the metric tiles override .grid's 24px */
.pf-modal-metric { padding: 20px; border-radius: 16px; background: var(--surface-2); text-align: center }
.pf-modal-metric .v {
  font-family: var(--font-display);
  font-size: 26px;
  font-weight: 800;
  color: var(--text);
  letter-spacing: -.04em;             /* note: -.04em here vs -.03em on the card */
}
.pf-modal-metric .l { font-size: 13px; color: var(--muted) }
.pf-modal-actions { margin-top: 30px; display: flex; gap: 12px }

/* --- 6.10 Pricing: tiers + included panel --------------------------- */

.tier-grid {
  /* `grid-template-columns: repeat(3, 1fr)` USED TO BE DECLARED HERE AND IS GONE.
     It was pure redundancy — .g3 already supplies exactly that, and pricing.php's
     own header note says so ("`.tier-grid` only restates repeat(3,1fr) at the same
     specificity"). Redundant is not harmless: both are (0,1,0), .tier-grid comes
     later in the file, so it WON, and pricing.php's count-derived grid class had no
     effect at all — a two-tier page still laid out in three columns with a dead
     third, which is the bug the count logic exists to prevent. Verified in the
     browser: with `class="grid g2 tier-grid"` the computed columns read
     `374px 374px 374px` before this line was removed.
     The responsive collapse is unaffected: the 1040px and 720px rules target .g3,
     which is still on the element. */
  gap: 22px;                          /* overrides .grid's 24px */
  margin-bottom: 26px;                /* the ONLY gap between the tiers and the
                                         Included panel */
}
.tier {
  border-radius: 24px;
  padding: 38px;
  background: var(--surface);
  border: 1px solid var(--border);
  position: relative;
  overflow: hidden;                   /* clips the featured blob */
  box-shadow: none;
}
/* Tier cards are deliberately NOT .card — so they get no hover lift, no border
   tint, and the [data-cards] variants must never target them. */
.tier.featured {
  background: var(--ink);
  border-color: transparent;
  box-shadow: var(--shadow-lg);
}
.tier.featured::before {
  content: "";
  position: absolute;
  top: -100px;
  right: -60px;
  width: 300px;
  height: 300px;
  border-radius: 50%;
  background: radial-gradient(circle,
              color-mix(in srgb, var(--accent) 45%, transparent), transparent 70%);
  pointer-events: none;
}
.tier-body { position: relative }      /* stacks the content above the blob */

.tier-namerow { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px }
.tier-namerow h3 { font-size: 22px; color: var(--text) }
.tier.featured .tier-namerow h3 { color: #fff }
.tier-badge {
  font-size: 12px;
  font-weight: 600;
  padding: 5px 12px;
  border-radius: 999px;
  background: rgba(255,255,255,.14);
  color: #fff;
}
.tier-sub { font-size: 15px; line-height: 1.7; min-height: 76px; color: var(--body) }
/* min-height:76px is what keeps the three PRICES optically aligned even though
   the descriptions differ in length (~3 lines at 15px/1.7). Drop it and the
   prices de-align. */
.tier.featured .tier-sub { color: rgba(255,255,255,.66) }

.tier .priceline { margin: 24px 0 28px }
.tier-price {
  font-family: var(--font-display);
  font-size: clamp(32px, 3.2vw, 44px);
  font-weight: 800;
  letter-spacing: -.05em;
  color: var(--text);
}
.tier-period { font-size: 15px; color: var(--muted) }
/* renders as "/ " + period — slash, SPACE, then the text: "/ fixed scope". */
.tier.featured .tier-price  { color: #fff }
.tier.featured .tier-period { color: rgba(255,255,255,.6) }

/* Featured tier CTA. In the prototype this was `class="btn solid"` with an
   INLINE background:#fff, which beat .btn.solid:hover and kept the button
   white on hover. The markup here uses `class="btn white full"` (see §3.2), so
   nothing more is needed — but the belt-and-braces override below also covers
   a view that still ships `btn solid`. */
.tier.featured .btn.solid,
.tier.featured .btn.solid:hover { background: #fff; color: var(--ink); opacity: 1 }

.included-panel {
  border-radius: 24px;
  border: 1px solid var(--border);
  background: var(--bg);              /* white inset panel on the grey .alt band */
  padding: 38px;
}
.included-panel > h3 { font-size: 18px; margin-bottom: 24px }
.included-grid { grid-template-columns: repeat(3, 1fr); gap: 18px 34px }  /* row 18 / col 34 */
.included-item { display: flex; gap: 12px; font-size: 15.5px; line-height: 1.6; color: var(--body) }
.included-item .material-symbols-outlined {
  font-size: 20px;
  color: var(--accent);
  flex-shrink: 0;
  margin-top: 3px;                    /* optically centres the glyph on the
                                         first line of a wrapping label */
}
/* the glyph is check_circle and needs .ico-fill (FILL 1) — see §3.11 */

/* --- 6.11 Pricing: FAQ accordion ---------------------------------- */

#faq > .wrap.split.d { gap: 80px }     /* overrides .split's clamp(40px,5vw,72px) */

.faq-left { position: sticky; top: 120px }
/* 120px clears the fixed header (~73-89px depending on scroll state) with room.
   The 1040px rule sets position:static !important on .split.d > div:first-child,
   which cancels this. */

.faq-item { border-bottom: 1px solid var(--border) }
.faq-item:first-child { border-top: 1px solid var(--border) }   /* only item 0 */

.faq-q {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
  padding: 26px 0;
  background: transparent;
  border: none;
  cursor: pointer;
  text-align: left;
  font-family: inherit;
}
.faq-q-text {
  font-family: var(--font-display);
  font-size: 19px;
  font-weight: 700;
  letter-spacing: -.02em;
  color: var(--text);
  transition: color .2s;
}
.faq-item.is-open .faq-q-text { color: var(--accent) }

.faq-q-toggle {
  width: 34px;
  height: 34px;
  border-radius: 50%;
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 1px solid var(--border);
  background: transparent;
  color: var(--text);
  transition: all .25s;
}
/* var(--on-accent) — see .chip.on. #fff on the dark theme's lighter --accent
   is 2.62:1; the token resolves to #06121f there and to #ffffff in light. */
.faq-item.is-open .faq-q-toggle { background: var(--accent); border-color: var(--accent); color: var(--on-accent) }
.faq-q-toggle .material-symbols-outlined { font-size: 19px }
/* the glyph text itself swaps add <-> remove in JS */

.faq-a-wrap { max-height: 0; overflow: hidden; transition: max-height .38s cubic-bezier(.4,0,.2,1) }
/* THE OPEN CAP CANNOT SILENTLY CUT AN ANSWER.
   max-height is required — the panel animates from 0, and `height: auto` is not
   an animatable value — but `overflow: hidden` means anything past the cap is
   AMPUTATED MID-SENTENCE with no scrollbar and no ellipsis. The reader gets half
   a sentence and no signal that there is more.

   The 280px guard was chosen against a longest stored answer of 238 characters.
   That data is stale: the longest is now 284 characters, and faqs.answer has no
   length limit in the schema, so the next edit can exceed any fixed number.

   Two changes: the cap is raised to 60vh (a proportion of the viewport rather
   than a guess about the content), and overflow-y becomes auto when the item is
   open, so an answer longer than the cap SCROLLS inside the panel instead of
   being cut. overscroll-behavior keeps that scroll from chaining to the page. */
.faq-item.is-open .faq-a-wrap {
  max-height: 60vh;
  overflow-y: auto;
  overscroll-behavior: contain;
}
/* THE 280px CAP IS FAITHFUL BUT IS A CMS HAZARD. Measured: the longest seeded
   answer (~232 chars) reaches ~230px at a 320px viewport, so nothing clips
   today. Any future answer past roughly 250 characters clips silently, mid
   sentence, with no scrollbar and no affordance. Options recorded in spec 05
   §C.4: (a) keep this and add a ~250-char soft limit in the admin;
   (b) measure scrollHeight in JS; (c) grid-template-rows 0fr->1fr. We ship (a)
   — the faithful port — because the parent has not chosen otherwise. */
.faq-a { padding-bottom: 28px; font-size: 16px; line-height: 1.8; max-width: 640px; color: var(--body) }
/* Item 0 must render server-side with class="faq-item is-open" so there is no
   flash of a closed accordion. All-closed is a valid state: clicking the open
   item closes it. */

/* --- 6.12 Articles index ----------------------------------------- */

.sec--featured { padding-bottom: calc(56px * var(--space)) }
.articles-list-sec { padding-top: calc(64px * var(--space)) }
/* .articles-list-sec is an INVENTED name (spec 06 gives the value as an inline
   override on <section class="sec alt">). Raised in the handoff.
   Net section padding: featured 112/56 (72/56 at <=720px), list 64/112
   (64/72 at <=720px). */

.featured-card {
  gap: 0;                             /* overrides .split's clamp() */
  align-items: stretch;               /* overrides .split's center */
  overflow: hidden;
  color: inherit;                     /* kills the global a{color:var(--accent)} */
  padding: 0;
}
/* The featured card is an <a> that is ALSO a .card and a .split.a. It carries
   no .rv, so unlike the grid cards it gets the full .card:hover lift AND the
   global a:hover{opacity:.75} dim. That difference from the grid cards is a
   cascade quirk documented in spec 06 §3.4 and preserved here — see §3.4. */
.feat-media { background: var(--surface-3); min-height: 340px }
/* No aspect-ratio: align-items:stretch grows the media column to the height of
   the text column, floored at 340px. */
.feat-media img { display: block; width: 100%; height: 100%; object-fit: cover }

.feat-body { padding: clamp(30px, 4vw, 52px); display: flex; flex-direction: column; justify-content: center }
.feat-badges { display: flex; align-items: center; gap: 10px; margin-bottom: 18px }
.feat-flag {
  font-size: 12px;
  font-weight: 700;
  padding: 5px 13px;
  border-radius: 999px;
  background: var(--accent);
  color: var(--on-accent);            /* WAS the literal #fff "to match the
                                         prototype". The prototype had no dark
                                         theme; this build does, and in it
                                         --accent lightens to #4da6f0 where #fff
                                         measures 2.62:1 — a badge nobody can
                                         read. --on-accent is #ffffff in light
                                         theme, so light rendering is unchanged,
                                         and #06121f in dark (~9:1). */
}
.feat-cat { font-size: 13.5px; color: var(--muted) }
/* var(--fs-h2), was clamp(26px, 3vw, 38px) — see §1.1. */
.feat-body h2 { font-size: var(--fs-h2); margin-bottom: 18px; line-height: 1.2 }
.feat-body > p { font-size: 16.5px; line-height: 1.75; margin-bottom: 26px }
.feat-byline { display: flex; align-items: center; gap: 14px; font-size: 14px; color: var(--muted) }
.feat-byline strong { color: var(--text); font-weight: 600 }
/* .feat-badges/.feat-flag/.feat-cat/.feat-byline are INVENTED names — spec 06
   gives these as unnamed inline styles. Raised in the handoff. */

.filterbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 20px;
  flex-wrap: wrap;                    /* the search box drops below the chips on
                                         narrow viewports — no media query needed */
  margin-bottom: 40px;
}
.filterbar__chips { display: flex; gap: 8px; flex-wrap: wrap }
/* `flex: 1 1 260px`, not `min-width: 260px`. The .filterbar wraps, so on a phone
   the search pill drops onto a line of its own — and with only a minimum it kept
   its 260px content width there and left a ~90px dead gutter to its right, so the
   search field visibly failed to line up with the chip row above it. Growing to
   fill the line it wrapped onto costs nothing on desktop (the chips take the
   space first) and squares the two rows off on mobile. */
.filterbar__search { position: relative; flex: 1 1 260px }
.filterbar__search .material-symbols-outlined {
  position: absolute;
  left: 16px;
  top: 50%;
  transform: translateY(-50%);
  color: var(--muted);
  font-size: 19px;
}
.filterbar__search input {
  width: 100%;
  padding: 12px 16px 12px 46px;       /* 46px left clears the search glyph */
  font-family: var(--font-body);
  /* 16px — see .fld. Under 16px, focusing this field zooms mobile Safari into
     the page and leaves it zoomed, which on /articles and /careers is the first
     thing a phone visitor touches. */
  font-size: 16px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: var(--bg);
  color: var(--text);
  outline: none;
}
.filterbar__search input:focus-visible {
  border-color: var(--accent);
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 12%, transparent);
}
/* ADDITION. The prototype set outline:none with NO replacement focus style —
   a keyboard user had no visible focus at all. This mirrors .fld's focus ring.
   Also: keep the input as type="text", NOT type="search" (Chrome and Safari add
   a native clear affordance and UA styling that changes the pixels), and give
   it a visually-hidden <label>. */

.article-card {
  background: var(--bg);              /* OVERRIDES .card{background:var(--surface)}.
                                         Identical in light theme; in dark theme
                                         this makes the card DARKER than its
                                         --surface-2 band. That is the
                                         deliberate inversion from §1.2 — do not
                                         "fix" it to var(--surface). */
  overflow: hidden;                   /* clips the image to the 20px radius */
  display: block;
  color: inherit;
}
.article-card__media { aspect-ratio: 16 / 9; background: var(--surface-3); position: relative }
.article-card__media img { display: block; width: 100%; height: 100%; object-fit: cover }
.article-card__cat {
  position: absolute;
  top: 14px;
  left: 14px;
  z-index: 2;
  pointer-events: none;               /* the whole card is one <a> */
  font-size: 12px;
  font-weight: 600;
  padding: 5px 13px;
  border-radius: 999px;
  background: var(--surface);
  color: var(--text);
  box-shadow: var(--shadow-sm);
}
.article-card__body { padding: 28px }
.article-card__meta {
  display: flex;
  gap: 12px;
  font-size: 13px;
  color: var(--muted);
  margin-bottom: 14px;
  flex-wrap: wrap;
}
.article-card__meta .read { margin-left: auto }   /* read time pushed right */
/* overflow-wrap: an admin-authored headline can contain one long unbroken token
   (a product name, a pasted URL). .article-card is overflow:hidden to clip its
   image to the card radius, so without a break opportunity that word is
   amputated at the card edge with no ellipsis and no scroll — the reader sees a
   truncated headline and has no way to know it was truncated. */
.article-card h3 { font-size: 19.5px; line-height: 1.36; margin-bottom: 12px; overflow-wrap: anywhere }
.article-card p  {
  font-size: 15px;
  line-height: 1.7;
  margin-bottom: 18px;
  /* CLAMPED TO THREE LINES. posts.excerpt is varchar(500) against a current
     maximum of 144 characters, so today every card in a row is roughly the same
     height by luck. One 500-character excerpt would stretch its card past its
     row neighbours and break the grid's alignment for the whole row. The clamp
     makes the card height a function of the LAYOUT rather than of the content.
     The full excerpt is still in the DOM for a screen reader; the article page
     is one click away for the rest. */
  display: -webkit-box;
  -webkit-line-clamp: 3;
  line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.article-card__body { display: flex; flex-direction: column; flex: 1 }
.article-card { display: flex; flex-direction: column }
.article-card__more {
  display: inline-flex;
  align-items: center;
  /* pinned to the card's floor, same as .svc-more and .pf-card-metrics */
  margin-top: auto;
  align-self: flex-start;
  gap: 7px;
  font-size: 15px;
  font-weight: 600;
  color: var(--accent);
}
.article-card__more .material-symbols-outlined { font-size: 18px }
/* The whole card is one <a> — "Read article" is a <span>, never a nested
   anchor. The compact 16/10 media variant is dead code; not ported. */

.articles-empty { text-align: center; padding: 70px 0 }
.articles-empty h3 { font-size: 21px; margin: 16px 0 8px }
.articles-empty p  { font-size: 16px }

/* --- 6.12b CMS page (/page/{slug}) ------------------------------- */
/* .page-updated is the "Last updated: 8 Aug 2026" line at the head of a legal
   document. public/page.php has emitted the class since it was written and this
   stylesheet had NO rule for it at all, so it rendered as an ordinary .prose
   paragraph — indistinguishable from the first clause of the policy itself,
   which is exactly the wrong thing for a line whose whole job is to be metadata
   ABOUT the document rather than part of it.

   Demoted, not decorated: smaller, --muted, and separated from the body by a
   rule underneath. It reads as a stamp above the text, which is what it is. */
.page-updated {
  margin: 0 0 8px;
  padding-bottom: 20px;
  border-bottom: 1px solid var(--border);
  font-size: 14px;
  color: var(--muted);
}
.page-updated strong { color: var(--muted); font-weight: 600 }
/* .prose > * + * would add 26px above the first real paragraph; the border above
   already does the separating, so the pair is spaced once, not twice. */
.page-updated + * { margin-top: 26px }

/* --- 6.13 Article detail ---------------------------------------- */
/* Spec 07 names only .byline; every other class in this block is INVENTED
   because the spec transcribes the page as unnamed inline styles. Raised in
   the handoff so the view author has names to write against.
   NOTE this page does NOT reuse the shared banner partial: it hand-rolls its
   own banner with a narrower measure and an author byline. */

.artbanner { padding-bottom: calc(48px * var(--space)) }   /* on top of .banner */
.artbanner-inner {
  position: relative;                 /* stacks above .banner::after */
  max-width: 860px;                   /* NOT the global 1220px — this narrow
                                         measure is what makes the headline
                                         column read as an article rather than
                                         a marketing banner */
}
.artbanner h1 {
  font-size: clamp(30px, 4.2vw, 52px);
  font-weight: 800;
  margin: 0 0 24px;
  max-width: none;                    /* undoes .banner h1's 880px cap; the
                                         860px wrap already constrains it */
}
.byline { display: flex; align-items: center; gap: 14px; flex-wrap: wrap }
.byline-name { font-family: var(--font-display); font-size: 16px; font-weight: 700; color: var(--text) }
.byline-meta { font-size: 14px; color: var(--muted) }
/* renders "{role} · {date} · {read} read" — the literal " read" suffix is
   appended in this template only; the sidebar renders "{cat} · {read}". */

.artbody { padding-top: calc(56px * var(--space)) }   /* on top of .sec, so the
                                                         section is 56/112 */

.art-hero {
  border-radius: 22px;
  overflow: hidden;
  aspect-ratio: 16 / 8;               /* = 2/1, NOT 16/9 */
  background: var(--surface-3);
  margin-bottom: 44px;                /* this IS the gap to the first paragraph:
                                         .prose's first child gets no top margin */
}
.art-hero img { display: block; width: 100%; height: 100%; object-fit: cover }

.art-foot {
  margin-top: 52px;
  padding-top: 32px;
  border-top: 1px solid var(--border);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 20px;
  flex-wrap: wrap;
}
.art-foot span { font-size: 15px; color: var(--muted) }
.art-foot strong { color: var(--text); font-weight: 600 }
/* the "All articles" button's arrow_back icon has NO .mv, so it does not slide */

.art-aside { position: sticky; top: 120px; display: grid; gap: 20px }
/* sticky is cancelled at <=1040px by .split.h > aside{position:static
   !important} — write the element as <aside> so that rule can reach it. */

.aside-card { border-radius: 20px; border: 1px solid var(--border); padding: 28px }
.aside-card.filled { background: var(--surface-2) }
/* THE TWO SIDEBAR CARDS ARE DELIBERATELY ASYMMETRIC: the CTA card is filled,
   the "More articles" card is transparent over var(--bg). */
.aside-card h3 { font-size: 17px; margin-bottom: 12px }
.aside-card.more h3 { margin-bottom: 18px }
.aside-card p { font-size: 15px; line-height: 1.7; margin-bottom: 20px }

.more-list { display: grid; gap: 18px }
.more-item { display: block; color: inherit }
.more-item-meta { font-size: 12.5px; color: var(--muted); margin-bottom: 5px }
.more-item-title {
  font-family: var(--font-display);
  font-size: 15.5px;
  font-weight: 600;
  color: var(--text);
  line-height: 1.4;
}
/* No thumbnails, no avatars, no card per item — bare anchors. The global
   a:hover{opacity:.75} is the only hover treatment, which is why nothing is
   restated here. */

/* --- 6.14 Contact ---------------------------------------------- */

#contact .split.e { gap: 56px }        /* the inline 56px beats .split's clamp
                                          at EVERY width; at <=1040px it becomes
                                          the row gap */

.info-col { display: grid; gap: 14px }
.info-card {
  display: flex;
  gap: 16px;
  padding: 24px;
  border-radius: 18px;
  border: 1px solid var(--border);
  background: var(--surface);
}
/* Plain divs, NOT .card — no hover lift, and the [data-cards] variants must not
   reach them. Turning the phone/email lines into tel:/mailto: anchors is a
   recommended deviation, not the prototype. */
.info-ico {
  width: 44px;
  height: 44px;
  border-radius: 12px;
  flex-shrink: 0;
  background: var(--accent-soft);
  color: var(--accent);
  display: flex;
  align-items: center;
  justify-content: center;
}
.info-ico .material-symbols-outlined { font-size: 21px }
/* h3 AND h4 — same reason as .about-mission above. /contact's four info cards
   (Phone, Email, Address, Hours) sit under the page h1 and were h4s, an h1 -> h4
   skip; contact.php's own comment at :224 flagged it as a known compromise. */
.info-card h2, .info-card h3, .info-card h4 { font-size: 15px; font-weight: 700; margin-bottom: 5px }

/* ADDITION — "Get directions" under the Address card. An address with no route
   out of it is a copy-and-paste chore, so this is the one place on /contact that
   turns static text into an action. Underlined for the same reason every inline
   link is (colour alone is not a distinction), and given a 44px hit area up front
   rather than only at the mobile breakpoint, because it is the primary action of
   the card it sits in. */
.info-directions {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  margin-top: 10px;
  min-height: 44px;
  font-size: 14.5px;
  font-weight: 600;
  color: var(--accent);
  text-decoration: underline;
  text-underline-offset: 3px;
  text-decoration-thickness: 1px;
}
.info-directions .material-symbols-outlined { font-size: 18px }
.info-card > div > div { font-size: 14.5px; line-height: 1.6 }

.contact-form, #contact form {
  border-radius: 24px;
  border: 1px solid var(--border);
  background: var(--surface);
  padding: 40px;                      /* never shrinks at any breakpoint in the
                                         prototype. Reducing it on mobile is an
                                         intentional deviation — record it. */
}
.form-row { gap: 20px; margin-bottom: 20px }          /* on .grid.g2 */
.form-row--sep { margin-bottom: 26px }
.form-block { margin-bottom: 26px }
/* .form-row / .form-row--sep / .form-block are INVENTED names for spec 08's
   inline gap+margin overrides. DOM order is: row1 (Name/Company, mb 20) ->
   row2 (Email/Mobile, mb 26) -> CHIP GROUP (mb 26) -> row3 (Budget/Timeline,
   mb 26) -> textarea block (mb 26) -> .form-foot. The chip group sits between
   rows 2 and 3, not after row 3. */

.form-ok {
  margin-bottom: 26px;
  padding: 15px 20px;
  border-radius: 14px;
  background: color-mix(in srgb, var(--accent) 10%, transparent);
  border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
  display: flex;
  align-items: center;
  gap: 12px;
  font-size: 15px;
  color: var(--text);
  animation: fadeIn .3s ease;
}
.form-ok .material-symbols-outlined { font-size: 21px; color: var(--accent) }
/* the glyph is task_alt and needs .ico-fill. Give the banner role="status"
   aria-live="polite" — the prototype did not. */

.chip-prompt { font-size: 13.5px; font-weight: 500; color: var(--text); margin-bottom: 12px }
.chip-row { display: flex; flex-wrap: wrap; gap: 9px }
.chip-row .chip .material-symbols-outlined { font-size: 16px }
/* The chips are <button type="button"> — the type is LOAD-BEARING, without it
   they submit the form. When on, a `check` glyph is prepended inside the same
   button and the 7px .chip gap separates it from the label.
   The selected services live only in JS state in the prototype: there is NO
   input carrying them, so CI4 must add hidden services[] inputs or they are
   lost on submit. Add aria-pressed too. */

.form-foot { display: flex; align-items: center; justify-content: space-between; gap: 20px; flex-wrap: wrap }
.privacy { font-size: 13.5px; color: var(--muted); max-width: 400px; line-height: 1.6 }
/* No width override on the submit button, so it is content-width and pushed
   right by space-between; when the row wraps, the note takes a line and the
   button drops below it, left-aligned. */

/* --- 6.15 404 ------------------------------------------------- */

.nf-sec { padding: calc(120px * var(--space)) 0 calc(110px * var(--space)) }
/* This section does NOT use .sec, so it never consumes --section-pad and the
   720px override is INERT here: it keeps 120px top / 110px bottom down to
   320px. Preserved deliberately.
   The 120px top is this page's only header clearance (there is no .banner), so
   it is load-bearing — do not reduce it. The 110px bottom is the entire visual
   gap to the Newsletter band, which has no top padding of its own. */

.nf-grid {
  display: grid;
  grid-template-columns: 1.15fr .85fr;
  gap: calc(64px * var(--space));
  align-items: start;
}
/* .nf-grid is NOT .g3/.g4/.split, so the 1040px block is entirely inert on this
   page. Its real breakpoint is 900px (§7) — a width no other page cares about. */

.nf-code {
  font-family: var(--font-display);
  font-size: clamp(96px, 15vw, 190px);
  line-height: .82;
  font-weight: 800;
  letter-spacing: -.06em;
  color: var(--accent);
  opacity: .1;                        /* intentionally almost invisible — it is
                                         a ghost numeral, aria-hidden, and the
                                         <h1> is the accessible page title */
  margin-bottom: 10px;
  user-select: none;
}
.nf-h1 {
  font-size: clamp(34px, 4.6vw, 56px);   /* 56px ceiling, NOT the banner's 58 */
  line-height: 1.08;                     /* overrides the global 1.14 */
  letter-spacing: -.035em;               /* overrides the global -.032em */
  margin: 0 0 18px;
}
/* both line-height and letter-spacing MUST be restated: the inline styles in
   the prototype overrode the global h1 rule, and font-weight stays 700 here
   (the shared Banner uses 800). Do not copy the Banner values. */
.nf-lede { font-size: 17.5px; color: var(--body); max-width: 520px; margin: 0 0 12px }

.nf-path {
  display: inline-flex;
  align-items: center;
  gap: 9px;
  margin: 4px 0 0;
  padding: 8px 14px;
  border-radius: 10px;
  background: var(--surface-3);
  border: 1px solid var(--border);
  font-family: "Roboto Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 13px;
  color: var(--muted);
  max-width: 100%;
  overflow: hidden;
}
.nf-path > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
/* Scoped to :last-child rather than the spec's bare `.nf-path span`, which also
   matched the link_off icon span (harmless, but the intent is the URL text).
   The path is ATTACKER-CONTROLLED — esc() it server-side. */

.nf-actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 30px }
/* already wraps, so the two buttons stack below roughly 400px */

.nf-links {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 22px;                /* NOT var(--r)=20px — 22px */
  padding: 12px;
  box-shadow: var(--shadow);
}
/* Not .card, so the .card hover and the [data-cards] variants do not apply. */
.nf-links-hd {
  font-size: 12.5px;
  font-weight: 700;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--muted);
  padding: 14px 16px 12px;
}
.nf-link {
  display: flex;
  align-items: center;
  gap: 15px;
  padding: 15px 16px;                 /* rows are separated by padding alone —
                                         there are no dividers */
  border-radius: 15px;
  transition: background .18s ease;
}
.nf-link:hover { background: var(--accent-soft) }
/* No opacity:1 here on purpose: the prototype lets the whole tile fade slightly
   via a:hover{opacity:.75} on top of the accent-soft wash. Add
   .nf-link:hover{opacity:1} only if that reads as a bug to the designer. */
.nf-link b  { display: block; font-size: 16px; font-weight: 700; color: var(--text); letter-spacing: -.01em }
.nf-link em { display: block; font-style: normal; font-size: 13.5px; color: var(--muted); margin-top: 2px }
.nf-link-mid { flex: 1; min-width: 0 }   /* lets the text ellipsise instead of
                                            pushing the arrow out */
.nf-link-ico {
  flex: 0 0 auto;
  width: 42px;
  height: 42px;
  border-radius: 12px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--accent-soft);
  color: var(--accent);
}
.nf-link-ico .material-symbols-outlined { font-size: 21px }
.nf-link .mv { color: var(--muted); transition: transform .18s ease }
.nf-link:hover .mv { transform: translateX(4px); color: var(--accent) }
/* The two .mv rules are DISTINCT and both must exist: .btn:hover .mv moves
   4px only; .nf-link:hover .mv moves 4px AND recolours --muted -> --accent. */


/* ============================================================================
   7. RESPONSIVE
   ----------------------------------------------------------------------------
   Breakpoints in the prototype's documented order: 1180 / 1040 / 1000 / 900 /
   720. (In styles.css the 900px rule is written last, after 720; the selectors
   do not overlap so the cascade outcome is identical, and this order reads in
   descending width.)

     1180 — nav collapses to a burger, header CTA hidden
     1040 — the big reflow: .g3/.g4 to 2-up, all eight .split variants stack,
            sticky columns un-stick, footer to 2 cols, ArchLine hidden
     1000 — .wrap gutter 26 -> 20px
      900 — the 404 grid stacks (this width matters on no other page)
      720 — everything single column, section pad flat 72px, base font 16px
   ============================================================================ */

@media (max-width: 1180px) {
  .navlinks { display: none }
  .burger   { display: flex }
  .navcta   { display: none }
  /* Header height changes here too: the tallest child becomes the 44px burger
     instead of the ~49px button, so the header is ~84px instead of ~89px. The
     clearance values in §5.1 are sized off the taller figure, so they still
     clear. JS should also close the drawer when crossing back above 1180. */
}

@media (max-width: 1040px) {
  .g3 { grid-template-columns: repeat(2, 1fr) }
  .g4 { grid-template-columns: repeat(2, 1fr) }
  /* .g2 is deliberately NOT here — WhyUs stays 2x2 until 720px. */

  .split.a, .split.b, .split.c, .split.d,
  .split.e, .split.f, .split.g, .split.h { grid-template-columns: 1fr }

  /* un-stick the sticky columns. !important is required: it has to beat the
     inline position:sticky the prototype emitted, and the ported .faq-left /
     .art-aside classes are kept in sync with it. */
  .split.d > div:first-child,
  .split.e > div:first-child,
  .split.h > aside,
  .faq-left, .art-aside { position: static !important }

  .fcols { grid-template-columns: 1fr 1fr; gap: 36px }

  .archline { display: none }
  /* The arcs connect steps horizontally; once the steps are 2x2 they point at
     nothing, so they are hidden outright rather than repositioned. */

  /* THE MISSION CARD STOPS OVERHANGING ONCE THE SPLIT STACKS.
     Its desktop `right:-26px` (§6.2) is a deliberate overhang into the column
     GUTTER of `.split.a` — documented there, and correct while there are two
     columns. This block collapses every .split to one column, so there is no
     gutter left to hang into and the -26px hangs off the VIEWPORT instead:
     documentElement.scrollWidth measured 396 against a clientWidth of 390 on both
     / and /about, and because body is overflow-x:hidden (§2) the 6px was silently
     amputated — the card lost its right border and its rounded corner rather than
     producing a scrollbar anyone could see.

     Static, not `right:0`: at one column the card should read as the next block
     in the stack, not as a floating overlay on a photo that is now above it. The
     negative top margin keeps the designed overlap onto the photo. The desktop
     rule is untouched. */
  .about-mission {
    position: static;
    width: auto;
    right: auto;
    bottom: auto;
    margin-top: -30px;
  }
}

@media (max-width: 1000px) {
  .wrap { padding: 0 20px }
  /* The testimonial rail's max(26px, ...) padding does NOT follow, so the rail
     and the section head above it are 6px out of alignment below 1000px. That
     mismatch exists in the prototype; changing both to 20px is a deliberate
     decision, not a fix. */
}

@media (max-width: 900px) {
  .nf-grid { grid-template-columns: 1fr; gap: calc(40px * var(--space)) }
  /* The 404 links card drops below the left column. align-items:start is moot
     once stacked. */
}

@media (max-width: 720px) {
  body { font-size: 16px }

  .g3, .g4, .g2 { grid-template-columns: 1fr }
  .fcols { grid-template-columns: 1fr }

  :root { --section-pad: 72px }
  /* ODDITY PRESERVED: this is a FLAT value — it drops the * var(--space)
     multiplier that the :root declaration in §1 uses. If --space is ever
     changed from 1, mobile section padding will not follow. Spec 00 §B.13
     flags it and says not to change it silently, so it stays flat.
     Note this affects only .sec-derived padding. The hard literals stay put:
     hero 148/72, .whyus 104px, .nf-sec 120/110, steps margin-bottom 80px,
     .sechead 64/44px margins, .grid 24px gap. */

  .banner { padding-top: 126px }
  /* header clearance again — 126px for the ~84px burger-era header */

  /* ---- TAP TARGETS (WCAG 2.5.5 / platform minimum 44px) --------------------
     Every control below measured under 44px tall at 390px. They are the small
     text links the design treats as typography rather than as buttons — the six
     team-card contact links, the four social icons, the breadcrumb trail, the
     service cards' "Read more", the logo lockup, and the inline tel:/mailto:
     rows in the footer and on /contact and /careers. On a desktop with a mouse
     that is fine; on a phone a 17px glyph or a 15px line of text is a target a
     thumb misses, and the failure mode for a tel: link is dialling the wrong
     thing or hitting the row underneath.

     THE AREA IS CARRIED BY PADDING AND min-height, NEVER BY font-size. Growing
     the type would change the design's typographic hierarchy on mobile only,
     which is a different page rather than the same page with reachable
     controls. display:inline-flex + align-items:center is what lets an inline
     anchor honour min-height at all — an inline box ignores it.

     Scoped to <=720px on purpose: above that width the pointer is a mouse and
     the design's compact link rows are correct as drawn. */
  .team-links a,
  .f-social a,
  .crumbs a,
  .svc-more,
  .pf-card-more,
  .article-card__more,
  .logo,
  .info-card a,
  a[href^="tel:"],
  a[href^="mailto:"] {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
  }
  /* .f-contact a is EXCLUDED from the inline-flex list above and given its own
     block-level rule. It is display:block at every width (see §6.x) precisely so
     the email and phone rows each own a line with no <br> after them; inline-flex
     is an INLINE-level box, so it would put the two anchors side by side on a
     phone — the opposite of a reachable target. `flex` is the block-level
     equivalent and still centres the text inside the 44px. Note this must also
     beat `a[href^="mailto:"]` / `a[href^="tel:"]` above, which it does on
     specificity: (0,1,1) vs (0,1,0). */
  .f-contact a {
    display: flex;
    align-items: center;
    min-height: 44px;
  }
  /* Horizontal room too — a 44px-tall target 20px wide is still a miss. The
     icon-only controls get a square-ish box; the text links get side padding
     with a compensating negative margin so the visual left edge does not move
     and the text stays aligned with the copy above it. */
  .team-links a,
  .f-social a {
    min-width: 44px;
    justify-content: center;
  }
  .crumbs a,
  .f-contact a,
  .info-card a,
  a[href^="tel:"],
  a[href^="mailto:"] {
    padding-inline: 6px;
    margin-inline: -6px;
  }

}

@media (max-width: 520px) {
  .nlband form { flex-direction: column }
  /* ADDITION (spec 00 §D.7): the email pill + Subscribe button cannot both fit
     on one line at phone widths. Not a prototype breakpoint. */
}


/* ============================================================================
   8. REDUCED MOTION + NO-JS
   ============================================================================
   The prototype had no reduced-motion guard anywhere. This block is an
   ADDITION. It covers every animation in the file: the reveal transition, the
   logo marquee, the hero float, the PlayButton ring, the fadeIn entrances and
   smooth scrolling. Counters must also be skipped in JS (check the same media
   query with matchMedia) — CSS cannot stop a requestAnimationFrame loop.
   ---------------------------------------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto }

  .rv { opacity: 1; transform: none; transition: none }

  .ticker-track,
  .hero-stat,
  .playbtn-ring::before,
  .btt,
  .pf-card,
  .fade-in,
  .form-ok,
  .drawer { animation: none }

  /* Keep colour/opacity transitions (they do not induce motion) but drop the
     transforms that do. */
  .btn:hover, .card:hover { transform: none }
  .btn:hover .material-symbols-outlined.mv,
  .card:hover .svc-more .material-symbols-outlined,
  .pf-card:hover .pf-card-more .material-symbols-outlined,
  .nf-link:hover .mv { transform: none }

  /* THE CAREERS PANE STILL NEEDS A PROGRESS CUE, JUST NOT A SPINNING ONE.
     Turning the spinner off and stopping there would put a reduced-motion
     visitor back where they started: a pane that dims and says nothing. The
     rotation is dropped and the ring is replaced by a static word, which is the
     information the spinner was carrying anyway. §8.9.5 owns the animated
     version. */
  .jobpane.is-loading::after {
    content: "Loading…";
    animation: none;
    border: 0;
    border-radius: 0;
    width: auto;
    height: auto;
    margin-left: 0;
    transform: translateX(-50%);
    font-size: 14px;
    font-weight: 600;
    color: var(--body);
    white-space: nowrap;
  }
}


/* ============================================================================
   8.9  GAPS CLOSED AFTER THE PAGE VIEWS LANDED
   ============================================================================
   Every rule in this block was requested by a page view that could not add it
   itself (the views and this stylesheet had separate owners during the build).
   Each one was verified MISSING against the live pages before being written, and
   each says which page breaks without it.
   ---------------------------------------------------------------------------- */

/* ---- 8.9.-4  Case study "Built with" (tech stack) ---------------------------
   One row per catalogue group: a fixed-width caption on the left, the chips
   flowing on the right. Grouping is the point — fifteen chips in a heap says
   less than five groups of three — so the label column is what does the work.

   The 116px caption column is not arbitrary: it fits "Infrastructure", the
   longest group name in the seeded catalogue, on one line at 13px. A group name
   longer than that wraps to two lines rather than pushing the chips out of
   alignment, which is why it is a fixed track and not `auto`. */
.tech-built { display: flex; flex-direction: column; gap: 18px }
.tech-built-row {
  display: grid;
  grid-template-columns: 116px minmax(0, 1fr);
  gap: 16px;
  align-items: start;
}
.tech-built-label {
  font-size: 13px;
  font-weight: 700;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--muted);
  padding-top: 9px;        /* optically centres the caption on the first chip row */
}
.tech-built-chips { display: flex; flex-wrap: wrap; gap: 9px }

/* These chips are LABELS, not controls. `.chip` ships cursor:pointer and a hover
   border for the filter buttons it was written for; both would promise a click
   that does nothing here. */
.tech-built .chip { cursor: default }
.tech-built .chip:hover:not(.on) { border-color: var(--border) }

/* Below 720px the caption goes above its chips: a 116px column out of a ~350px
   viewport leaves the chips in a gutter. */
@media (max-width: 720px) {
  .tech-built-row { grid-template-columns: minmax(0, 1fr); gap: 8px }
  .tech-built-label { padding-top: 0 }
}

/* ---- 8.9.-3  Cookie consent banner -----------------------------------------
   partials/cookie_consent.php was inherited styled with Bootstrap utilities and
   palace.css's .btn-pl, none of which the public theme loads any more — so it
   would have rendered as unstyled text after the footer. These rules replace
   them; the partial's own docblock records the swap.

   z-index 90 puts it above the header (60), the drawer (70) and the apply modal
   (80). That ordering is deliberate: a consent notice the visitor cannot reach
   because a dialog is covering it is a notice that does not exist.

   `is-hidden` (not `hidden`, not display:none in the base rule) because the
   partial's script removes exactly that class to reveal it. It ships hidden so a
   no-JS browser sees no banner — correct, because without JS the buttons could
   not store a decision, and no tags run either. */
.cookie-banner {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 90;
  background: var(--ink);
  color: rgba(255, 255, 255, .78);
  border-top: 1px solid rgba(255, 255, 255, .12);
  box-shadow: 0 -8px 40px -12px rgba(0, 0, 0, .5);
  padding: 16px 0;
  animation: fadeIn .3s ease;
}
.cookie-banner.is-hidden { display: none }

.cookie-banner-inner {
  display: flex;
  align-items: center;
  gap: 20px;
  flex-wrap: wrap;
}
.cookie-banner-text {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  font-size: 14.5px;
  line-height: 1.6;
  margin: 0;
  flex: 1 1 320px;   /* takes the room, but wraps the buttons under it when tight */
}
.cookie-banner-text .material-symbols-outlined { font-size: 20px; flex: 0 0 auto; margin-top: 1px }
.cookie-banner-text a { color: #fff; text-decoration: underline; white-space: nowrap }
.cookie-banner-text a:hover { opacity: 1 }

.cookie-banner-actions { display: flex; gap: 10px; flex: 0 0 auto; margin-left: auto }

/* Below 560px the buttons go full width and stack, so neither is easier to hit
   than the other with a thumb — the same fairness the DOM order enforces. */
@media (max-width: 560px) {
  .cookie-banner-actions { margin-left: 0; width: 100%; flex-direction: column }
  .cookie-banner-actions .btn { width: 100% }
}
@media (prefers-reduced-motion: reduce) { .cookie-banner { animation: none } }

/* THE CHAT LAUNCHER MUST NOT SIT ON THE BANNER.
   partials/chat_widget.php ships its own chat.css with
   `.mv-chat__launch { position:fixed; bottom:76px; right:16px; z-index:1040 }`.
   1040 is far above this banner's 90, so the bubble paints over it — and on a
   narrow screen, where the banner stacks its buttons and grows to ~200px tall,
   the bubble lands squarely on top of Decline. A floating button covering half
   of a consent choice is not a cosmetic problem.

   So the launcher is lifted by the banner's height while the banner is on
   screen. Two details that make this work:

   - `body:has(...)` rather than a sibling selector: the two are rendered by
     different partials at the end of the layout and their DOM relationship is
     not something this stylesheet should depend on.
   - `body .mv-chat__launch` (0,1,1) rather than `.mv-chat__launch` (0,1,0),
     because chat.css is emitted by the widget partial AFTER this file loads and
     would otherwise win on source order. Specificity settles it without
     !important.

   --consent-h defaults to 0px, so when the banner is absent — or in a browser
   with no :has() support — the launcher keeps its designed position and nothing
   is worse than it was.

   DECLARED ON :root, NOT ON body. Custom properties inherit DOWNWARD only, and
   `html` is body's parent — a `body { --consent-h }` declaration is invisible to
   any rule on `html`, so `html { scroll-padding-bottom: var(--consent-h) }` would
   resolve to the guaranteed-invalid value and compute as `auto`. The rule would
   read as applied and do nothing. `:root:has(…)` matches on a descendant exactly
   the way `body:has(…)` did, so nothing about the banner detection changes.

   THE 88/208 LITERALS ARE THE NO-JS FALLBACK ONLY. The banner text is an editable
   settings row, so its real height is not knowable from CSS — the measured mobile
   height is 231px, not 208. artitech.js measures the live banner and writes the
   true pixel value to documentElement.style, which (being an inline style) wins
   over both literals. These stay as the floor for a browser that never runs it. */
:root { --consent-h: 0px }
:root:has(.cookie-banner:not(.is-hidden)) { --consent-h: 88px }
@media (max-width: 560px) {
  :root:has(.cookie-banner:not(.is-hidden)) { --consent-h: 208px }
}
body .mv-chat__launch { bottom: calc(76px + var(--consent-h, 0px)); transition: bottom .25s ease }
@media (prefers-reduced-motion: reduce) { body .mv-chat__launch { transition: none } }

/* RESERVE THE SPACE THE BANNER TAKES AWAY.
   The banner is position:fixed, so it is out of flow and the document does not
   know it is there. At 390x844 it covers the last ~231px of the viewport, which
   at the bottom of a scrolled page is the footer's "Privacy policy" link, the
   /contact tel: and mailto: rows and — because the hero is exactly one viewport
   tall — the hero's own "Explore services" CTA. Those controls were UNREACHABLE:
   scrolled to the very end of the document, they were still under the banner.

   padding-bottom on <body> gives the document that height back, so the page can
   scroll far enough for the banner to clear the last row of content.
   scroll-padding-bottom does the same for programmatic and anchor scrolling, so
   a #target never lands underneath the banner either. */
body:has(.cookie-banner:not(.is-hidden)) { padding-bottom: var(--consent-h) }
html { scroll-padding-bottom: var(--consent-h) }

/* ---- 8.9.-2  Client logo IMAGES beside the text wordmarks -------------------
   The design originally rendered every client as a styled TEXT WORDMARK (spec
   §13 decision 2), and the view still does that for any client with no artwork.
   Real logos are now used where they exist, so the two have to sit in one row
   without either looking like a mistake.

   HEIGHT-CONSTRAINED, NEVER WIDTH-CONSTRAINED. Client logos arrive at wildly
   different aspect ratios — Malcolm's lockup is 1394x402 (3.5:1), a square mark
   would be 1:1 — and sizing by width would make a wide lockup tower over a
   compact one. Capping the height and letting width follow is what makes a row
   of unrelated logos read as one row. max-width is a safety rail for an
   extremely wide banner, not the primary control.

   The heights are tuned to the text they sit next to, not chosen round: the hero
   wordmarks are 19px bold and the ticker's are 26px, and a logo matched to the
   CAP HEIGHT of its neighbour reads as the same size even though its box is
   taller. */
/* The hero trust row.
   36px, NOT the 26px that matching the neighbouring 19px wordmarks' cap height
   would suggest. Cap-height matching is the right rule for a single-word mark,
   but all three of these clients use a LOCKUP with a secondary line — "HARDWARE",
   "RICEMILL CORP", "CONSUMER GOODS TRADING" — set far smaller than the primary
   word. At 26px that second line collapsed into an illegible smudge, which looks
   like a rendering fault rather than a design choice. Sizing for the SMALLEST
   text in the artwork rather than the largest is what keeps a lockup readable. */
.hero-logos img {
  height: 36px;
  width: auto;
  max-width: 168px;
  object-fit: contain;
  flex: 0 0 auto;
}

.ticker-track img {
  height: 38px;
  width: auto;
  max-width: 190px;
  object-fit: contain;
  flex: 0 0 auto;
}

/* The ticker greys its wordmarks back so the marquee reads as texture rather
   than as six competing signs. A full-colour logo would break that, so it is
   desaturated to match and lifts to full colour on hover — the same treatment
   the text gets, expressed the only way an image can express it.
   Respects reduced-motion users' preference for less visual churn by simply
   staying at the resting state. */
.ticker-track img {
  filter: grayscale(1);
  opacity: .55;
  transition: filter .3s, opacity .3s;
}
.ticker:hover img { filter: grayscale(0); opacity: 1 }
@media (prefers-reduced-motion: reduce) {
  .ticker-track img { transition: none }
}

/* ---- 8.9.-1  Team org chart -------------------------------------------------
   The /about Team section is banded by rank rather than being one flat grid:
   the head alone, the deputy alone beneath, then the directors across one line.
   The view emits one `.team-band.team-band--t{tier}` per band and picks its own
   grid class from the band's SIZE (see the note there).

   Bands stack with a gap large enough to read as separate levels but not so
   large that the section falls apart — `.grid`'s own 24px gap governs WITHIN a
   row, this governs BETWEEN rows. */
.team-band + .team-band { margin-top: calc(40px * var(--space)) }

/* A band of one. Capped and centred so the head does not sit lonely against the
   left edge of a full-width row, which is what a single card in a `g4` does.
   The cap is close to a single `g4` column at max width, so the solo card and a
   director card are near enough the same size — the hierarchy should read from
   POSITION, not from one person's portrait being three times larger. */
.team-band--solo {
  grid-template-columns: minmax(0, 300px);
  justify-content: center;
}

/* The connector between bands: a short vertical hairline centred above each
   band after the first, so the three rows read as one chart rather than three
   unrelated groups.

   Drawn with a pseudo-element on the BAND, not as a border on the cards,
   because it has to sit in the gap BETWEEN rows where no element exists.
   `position:relative` on the band is what anchors it. */
.team-band + .team-band { position: relative }
.team-band + .team-band::before {
  content: "";
  position: absolute;
  left: 50%;
  top: calc(-40px * var(--space));
  width: 1px;
  height: calc(40px * var(--space));
  /* --border-strong at the solid end, not --border. This hairline is the only
     thing that says the three team bands are ONE chart; at --border's 1.23:1 it
     faded out before it reached the card below and the rows read as three
     unrelated groups. The transparent-to-30% ramp is kept — the line should
     emerge from the gap, not butt into the card above it. */
  background: linear-gradient(to bottom, transparent 30%, var(--border-strong));
  pointer-events: none;
}

/* Below 720px every grid collapses to one column, so the chart becomes a single
   stack and the connector is drawing a line between two cards that are already
   adjacent — more noise than signal. The bands keep their spacing. */
@media (max-width: 720px) {
  .team-band + .team-band::before { display: none }
  .team-band--solo { grid-template-columns: minmax(0, 1fr) }
}

/* ---- 8.9.0  Skip link -------------------------------------------------------
   layouts/public.php emits `<a class="skip-link" href="#main">Skip to content</a>`
   as the first thing in <body>, and this stylesheet had NO rule for it — so it
   rendered as a visible blue link in the top-left corner of every page, over the
   hero. Caught on a screenshot, not by any test.

   It has to exist: the header is `position:fixed` with seven nav items and a CTA
   before the content starts, so a keyboard user would otherwise tab through all
   of it on every page.

   POSITIONED OFF-SCREEN, NOT `display:none` or `visibility:hidden` — either of
   those removes it from the tab order, which defeats the entire purpose. On focus
   it flies into the top-left corner above everything (z-index 100 beats the
   header's 60, the drawer's 70 and the apply modal's 80, because a skip link that
   appears UNDER the fixed header is invisible exactly when it is being used). */
.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  z-index: 100;
  padding: 12px 20px;
  border-radius: 0 0 12px 0;
  background: var(--accent);
  color: #fff;
  font-size: 15px;
  font-weight: 500;
  text-decoration: none;
}
.skip-link:focus {
  left: 0;
  opacity: 1;   /* cancels the global a:hover/:focus opacity drop */
  outline: 2px solid var(--text);
  outline-offset: 2px;
}

/* ---- 8.9.1  Service chips: hide the native checkbox, keep it focusable ------
   /contact's chips are `<label class="chip"><input type="checkbox"
   name="services[]">` — real inputs, so the form POSTS WITH ZERO JAVASCRIPT.
   That is the right call (a chip that is only a styled button posts nothing when
   JS fails, and this is the page where a lost submission costs money), but it
   left the native checkbox VISIBLE inside every pill next to the `check` glyph.

   The technique here is load-bearing. `display:none`, `visibility:hidden` and the
   `hidden` attribute all remove the control FROM THE TAB ORDER, which would make
   the entire chip group unreachable by keyboard — worse than an ugly checkbox.
   Clipping a 1px absolutely-positioned box keeps it focusable and operable by
   Space while rendering nothing. */
.chip input[type="checkbox"] {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  padding: 0;
  border: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
}

/* With the box hidden the chip has NO focus indicator, and house rule 8 requires
   one. :focus-within puts it on the pill itself, mirroring .fld's focus ring so
   the two form idioms look related. */
.chip:focus-within {
  border-color: var(--accent);
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 12%, transparent);
}

/* ---- 8.9.2  Validation-failure panel ---------------------------------------
   The mirror of .form-ok (§8 above) for the bounce case. Same box metrics so the
   two swap without the form jumping, but deliberately NOT accent green — a
   failure tinted with the brand's success colour is a genuinely misleading
   signal. --form-err-* are local, not global tokens: nothing else needs them,
   and a red in :root would invite reuse as a brand colour. */
.form-err {
  --form-err-line: #d9534f;
  --form-err-tint: color-mix(in srgb, #d9534f 8%, var(--surface));
  margin-bottom: 26px;
  padding: 15px 20px;
  border-radius: 14px;
  display: flex;
  align-items: flex-start;
  gap: 12px;
  font-size: 15px;
  line-height: 1.6;
  color: var(--text);
  background: var(--form-err-tint);
  border: 1px solid color-mix(in srgb, var(--form-err-line) 45%, var(--border));
}
.form-err .material-symbols-outlined { font-size: 21px; color: var(--form-err-line); flex: 0 0 auto }
.form-err ul { margin: 6px 0 0; padding-left: 20px }
.form-err li + li { margin-top: 4px }
/* The view emits `.form-err .form-block`; .form-block's own 26px margin-bottom
   would double up inside the panel. */
.form-err .form-block { margin-bottom: 0 }

/* ---- 8.9.2b  Per-field validation message ----------------------------------
   The message that sits INSIDE a .fld label, beside the control that failed —
   the other half of contact.php's aria-describedby / aria-invalid pairing. The
   summary panel above tells you something is wrong; this tells you which field,
   at the field, which is where a visitor is looking after the first bounce.

   NOT --form-err-line (#d9534f). That red measures 3.96:1 on white and 3.72:1
   on the .alt band — fine as a 1px border and an icon inside the tinted panel,
   which is all §8.9.2 uses it for, but under AA's 4.5:1 for running text. This
   is running text, so it gets its own value: #c0392b measures 5.44:1 on #ffffff
   and 5.11:1 on #f7f8f8.

   COLOUR IS NOT THE ONLY CUE (WCAG 1.4.1). The control also carries
   aria-invalid, its border turns, and the message is a sentence naming the
   problem — a reader who cannot see the red still gets all three. */
.fld-err {
  --fld-err-ink: #c0392b;
  display: block;
  margin-top: 8px;
  font-size: 13.5px;
  line-height: 1.5;
  font-weight: 500;
  color: var(--fld-err-ink);
}
.fld [aria-invalid="true"] { border-color: var(--fld-err-ink, #c0392b) }
.fld [aria-invalid="true"]:focus {
  border-color: var(--fld-err-ink, #c0392b);
  box-shadow: 0 0 0 4px color-mix(in srgb, #c0392b 14%, transparent);
}
/* Dark theme: #c0392b drops to 3.22:1 on --surface #0f1a29. #ff8a80 is the
   light-on-dark partner at 7.67:1 and reads as the same signal. */
[data-theme="dark"] .fld-err { --fld-err-ink: #ff8a80 }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .fld-err { --fld-err-ink: #ff8a80 }
}

/* ---- 8.9.3  Breadcrumb chevron --------------------------------------------
   Spec 00 §D.5 sizes the chevron between crumbs at 16px, but .crumbs sets
   font-size:14px and the glyph inherits it. Affects EVERY page with a .banner,
   not just one — which is why /about's and /careers's views each asked for it
   independently and both were carrying an inline style until this landed. */
.crumbs .material-symbols-outlined { font-size: 16px }

/* ---- 8.9.4  404 grid overflow ----------------------------------------------
   .nf-path holds the requested URL in a nowrap span. A grid item's automatic
   minimum size is its content, so a long URL forced the whole .nf-grid wider
   than the viewport: measured 426px against a 390px viewport at 49 URL
   characters, and 1405px at 186. min-width:0 lets the track shrink so the span's
   own text-overflow:ellipsis can do its job. */
.nf-grid > * { min-width: 0 }

/* ---- 8.9.5  Careers two-pane job board ------------------------------------
   THE BOARD HAD NO LAYOUT AT ALL. Without these rules .jobboard computed
   display:block, so the list and the pane stacked: a card click swapped the pane
   correctly (verified — title changed, URL pushState'd, focus moved) while the
   pane sat thousands of pixels below the fold, so the visitor saw nothing happen.
   That is exactly the "dead board" careers.js's own comment warns about, arriving
   through absent CSS rather than mismatched CSS.

   992px IS A CROSS-FILE CONTRACT with `var BREAKPOINT = 992` in careers.js:23.
   Its comment: if the two disagree, either the pane is hidden while clicks are
   being swallowed (a dead page), or it is visible and never updates. DO NOT fold
   this into the theme's own 1040px reflow — change both files in one edit or
   neither. */
.jobboard { display: grid; grid-template-columns: 1fr; gap: 24px }

@media (min-width: 992px) {
  .jobboard { grid-template-columns: .95fr 1.25fr; align-items: start; gap: 28px }
  /* top offset clears the scrolled fixed header (~73px) plus breathing room */
  .jobpane { position: sticky; top: 110px; max-height: calc(100vh - 140px); overflow-y: auto }
}

/* .joblist already gets display:grid + a 24px gap from the .grid class the view
   puts on it. Restating a gap here would be a second (0,1,0) rule decided by
   source order — deliberately omitted. */

/* Compound selector on purpose: (0,2,0) is needed to beat .card:hover, which
   would otherwise lift and recolour the selected row on hover and lose the
   selection cue. transform:none for the same reason. */
.jobrow.is-selected {
  border-color: var(--accent);
  box-shadow: 0 0 0 1px var(--accent);
  transform: none;
}
.jobrow--featured { border-color: color-mix(in srgb, var(--accent) 30%, var(--border)) }

/* careers.css also declares .jobrow__title but that file is NOT LINKED on this
   build (the public layout loads artitech.css and chat.css only), so the class
   had no rule that reached the page. Sized here, and given a break opportunity:
   a job title is admin-authored and the row is a .card, so one long unbroken
   token would be amputated at the card edge with no ellipsis. */
.jobrow__title { font-size: 18px; line-height: 1.35; overflow-wrap: anywhere }

/* ---- The job pane's LOADING state -----------------------------------------
   careers.js sets aria-busy="true" and .is-loading on the pane while it fetches
   the next role, and clears both when the swap lands — that half was already
   correct and is not touched. What was missing was anything to LOOK at: the only
   feedback was the pane dimming to 45% opacity, which on a slow connection reads
   as "this went grey" rather than "this is loading", and gives no indication that
   it will ever finish. A screen reader got the aria-busy; a sighted visitor got
   nothing.

   The spinner is drawn on the PANE, not inside .jobpane__inner, so it survives
   careers.js replacing that element's contents mid-fetch.

   THE position:relative IS SCOPED TO <=991px AND MUST STAY THAT WAY.
   Above 992px the pane is already `position: sticky` (see the min-width:992px
   block above), which is a positioned box, so the ::after anchors to it with no
   help. A BARE `.jobpane { position: relative }` here would be (0,1,0) — the same
   specificity as the sticky rule — and later in the file, so it would WIN and the
   two-pane board would stop sticking at desktop width, which is the entire point
   of that layout. Media queries add no specificity; only the scoping keeps the two
   rules from colliding. Do not "simplify" this into an unscoped rule. */
@media (max-width: 991px) {
  .jobpane { position: relative }
}
.jobpane.is-loading { opacity: .55; transition: opacity .2s }
.jobpane.is-loading::after {
  content: "";
  position: absolute;
  top: 40px;
  left: 50%;
  margin-left: -14px;
  width: 28px;
  height: 28px;
  border-radius: 50%;
  border: 3px solid var(--border-strong);
  border-top-color: var(--accent);
  animation: jobspin .8s linear infinite;
  /* Above the dimmed content, and never a click target. */
  z-index: 2;
  pointer-events: none;
}
@keyframes jobspin { to { transform: rotate(360deg) } }
.jobpane__error { padding: 38px; text-align: center }
/* No size of its own otherwise — it would fall back to the UA's h2. */
.jobdetail__title { font-size: clamp(22px, 2.4vw, 30px) }

/* ---- 8.9.6  Apply modal ----------------------------------------------------
   ALSO ENTIRELY ABSENT, and Apply is the reason the careers page exists.
   careers-apply.js does `modal.hidden = false; modal.classList.add('is-open')`
   and scroll-locks <body> with position:fixed. With no position:fixed on the
   dialog, opening it rendered the form as a normal-flow block AFTER the footer
   while the page was frozen: the applicant saw a locked page and no dialog. The
   POST itself was verified working end to end throughout.

   z-index 80 sits above the header (60) and the drawer (70) — a dialog must win
   against both.

   THE BOTTOM PADDING CLEARS THE COOKIE BANNER. The modal is centred in the
   viewport, but the banner is fixed over the bottom ~231px of it at 390px wide.
   With the panel centred in the FULL viewport, "Send application" — the last
   control in the form — landed underneath the banner and could not be pressed on
   a first visit. The extra bottom padding shifts the centring box up by exactly
   the banner's height, and the panel's max-height (below) is reduced to match so
   a tall form still scrolls inside the panel instead of overflowing it. */
.apply-modal { position: fixed; inset: 0; z-index: 80; display: flex; align-items: center; justify-content: center; padding: 20px; padding-bottom: calc(20px + var(--consent-h, 0px)) }
.apply-modal[hidden] { display: none }
.apply-modal__scrim { position: absolute; inset: 0; background: color-mix(in srgb, var(--ink) 55%, transparent); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px) }
.apply-modal__panel {
  position: relative;
  width: min(560px, 100%);
  /* 100dvh, not 100vh: on mobile Safari/Chrome 100vh is the LARGEST viewport
     (URL bar retracted), so a panel sized to it is taller than what is actually
     on screen and its last control sits under the browser chrome. --consent-h is
     subtracted for the same reason .apply-modal pads its bottom — the panel must
     fit in the space the cookie banner leaves, not the space the viewport has. */
  max-height: calc(100dvh - 40px - var(--consent-h, 0px));
  overflow-y: auto;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 22px;
  box-shadow: var(--shadow-lg);
  padding: clamp(24px, 3vw, 34px);
  animation: fadeIn .2s ease;
}
.apply-modal__x {
  position: absolute; top: 16px; right: 16px;
  width: 40px; height: 40px; border-radius: 12px;
  border: 1px solid var(--border); background: transparent; color: var(--text);
  cursor: pointer; display: flex; align-items: center; justify-content: center;
}
.apply-modal__title { font-size: clamp(21px, 2.2vw, 27px); margin-bottom: 6px; padding-right: 48px }
.apply-modal__role { font-size: 14.5px; color: var(--muted); margin-bottom: 22px }
.apply-modal__msg:empty { display: none }
.apply-modal__msg { margin-bottom: 18px }
.apply-modal__actions { display: flex; gap: 10px; margin-top: 24px }
.apply-modal__note { font-size: 13px; color: var(--muted); margin-top: 14px }
/* NOTE the markup here is `<div class="apply-field"><label for=…>` — a real
   `for`/`id` pair, NOT the `.fld` idiom of a <label> wrapping its control. So the
   label is styled as `> label`, not `> span`: a `> span` rule (the obvious
   copy from .fld) matches nothing, because the only span in this block is the
   required-asterisk INSIDE the label. */
.apply-field { display: block; margin-bottom: 16px }
.apply-field > label { display: block; font-size: 13.5px; font-weight: 500; color: var(--text); margin-bottom: 8px }
.apply-field__opt { color: var(--muted); font-weight: 400 }
/* 16px on EVERY control here, including [type=file] — a file input inherits
   nothing and falls back to the UA's 13.33px, which is under the 16px threshold
   just like the text fields were. This is the apply form: a zoom that fires on
   the first field and never undoes itself makes the rest of the form a
   horizontal-scrolling exercise. --border-strong for the same 1.4.11 reason as
   .fld: these are control boundaries, not dividers. */
.apply-field input,
.apply-field input[type="file"],
.apply-field select,
.apply-field textarea {
  width: 100%; padding: 14px 16px; font-family: var(--font-body); font-size: 16px;
  color: var(--text); background: var(--surface); border: 1px solid var(--border-strong);
  border-radius: 12px; outline: none; transition: border-color .2s, box-shadow .2s;
}
.apply-field input[type="file"] { padding: 11px 16px }
.apply-field input:focus,
.apply-field select:focus,
.apply-field textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 12%, transparent) }
.apply-field input.is-invalid { border-color: #d9534f }
.apply-field__err { display: block; font-size: 13px; color: #d9534f; margin-top: 6px }
.apply-field__err:empty { display: none }
@media (max-width: 520px) { .apply-modal__actions { flex-direction: column } }


/* ============================================================================
   9. PRINT
   ============================================================================
   Not in the prototype at all — an ADDITION, because articles and pricing are
   the two things people actually print. Strategy: drop the fixed chrome and
   every interactive affordance, flatten the theme to ink on white, and let the
   content reflow to a single column.
   ---------------------------------------------------------------------------- */

@media print {
  :root {
    --bg: #fff; --surface: #fff; --surface-2: #fff; --surface-3: #fff;
    --text: #000; --body: #222; --muted: #555; --border: #bbb;
    --shadow-sm: none; --shadow: none; --shadow-lg: none;
    --section-pad: 24px;
  }

  .site-header, .drawer, .btt, .nlband, .ctaband-sec, .cta-band,
  .ts-nav, .pf-filters, .filterbar, .pf-modal-backdrop,
  .hero-glow, .whyus-dots, .ticker { display: none !important }

  .banner { padding-top: 0 }          /* no fixed header to clear */
  .banner::after, .ctaband-glow, .tier.featured::before { display: none }

  .site-footer { background: #fff; color: #000; padding-top: 24px }
  .f-col h3, .f-col h4, .f-col a, .f-blurb, .f-contact, .f-legal { color: #000 }

  .rv { opacity: 1 !important; transform: none !important }
  .faq-a-wrap { max-height: none !important; overflow: visible !important }
  /* an accordion that prints only its open item loses most of the page */

  .card, .tier, .nf-links, .aside-card, .info-card, .contact-form {
    box-shadow: none;
    break-inside: avoid;
  }
  .art-aside { position: static }

  a[href^="http"]::after { content: " (" attr(href) ")"; font-size: .85em; color: #555 }
  /* printed links are dead ends otherwise; internal/relative hrefs are skipped
     because they are meaningless off-site */
}
