/* AgileTech Vietnam Direction C "Soft Tinted" design system
   Tokens per AGENTS.md Design. 60% light / 30% charcoal / 10% red. */
@font-face{font-family:'Plus Jakarta Sans';src:url('/static/fonts/plus-jakarta-sans.woff2') format('woff2');font-style:normal;font-weight:400 800;font-display:swap}
/* Inter's weight axis is declared 100..900 because that is what the FILE
   contains, measured rather than assumed. This range used to read 400 600,
   which withheld outlines that were already shipping in the woff2.

   How it was measured. The same inter.woff2 was declared twice, once at
   400..600 and once at 100..900, and the advance width of one string was read
   at each weight. Under 400..600, weights 700, 750 and 800 all rendered at
   exactly 792.00px, identical to 600: the browser clamps a requested weight to
   the DECLARED range, so every bold request collapsed onto semibold. Under
   100..900 the same file rendered 801.00, 807.00 and 817.00px. The heavier
   outlines were on disk the whole time and unreachable. Widths kept increasing
   through 900 and plateaued at 950 and 1000, which is where the upper bound
   comes from; the lower bound is the axis minimum, and no rule asks below 400
   so it costs nothing to state it correctly.

   Why it mattered enough to touch a font declaration. 3,494 text-bearing
   elements across 13 sampled pages request a weight above 600 and resolve to
   Inter: 2,331 at 700, 1,002 at 750, 130 at 650, 31 at 800, totalling about
   30,000 characters. Every one of them painted at 600. That erases the
   distinction between a label and its emphasis sitewide and reads as flatness,
   which is exactly the "soft, not crisp" complaint this revision is answering.

   Plus Jakarta Sans is deliberately NOT changed. The same probe shows its file
   genuinely stops at 800, so its 400..800 declaration is already truthful, and
   widening it to 900 would invite the browser to synthesise a bold that does
   not exist. A declaration is a claim about the file, not a wish. */
@font-face{font-family:'Inter';src:url('/static/fonts/inter.woff2') format('woff2');font-style:normal;font-weight:100 900;font-display:swap}
:root{
  --brand-500:#E23A52;--brand-700:#9B1C31;--brand-50:#FBECEE;--brand-100:#F6D8DD;
  --ink-900:#26262E;--ink-500:#6B6B76;--surface-50:#F7F7F9;
  --grad:linear-gradient(135deg,#E23A52,#9B1C31);
  --r-sm:12px;--r-md:16px;--r-lg:20px;--r-xl:32px;
  --shadow-sm:0 2px 10px rgba(38,38,46,.05);--shadow-md:0 8px 30px rgba(38,38,46,.06);
  --shadow-lg:0 14px 36px rgba(38,38,46,.08);
}
*{margin:0;padding:0;box-sizing:border-box}
html{scroll-behavior:smooth}
body{font-family:'Inter',system-ui,sans-serif;color:var(--ink-900);background:#fff;line-height:1.7;font-size:16px;-webkit-font-smoothing:antialiased}
/* Rev 86 (Phase E, site-wide mobile audit): `overflow-wrap:break-word`.
   Measured on /company-news/agiletech-wins-techbehemoths-awards-2024/ at a
   320px viewport: the h1 column is 280px but the single word "TechBehemoths"
   needs 283px at 38px type, so the word overflowed its own box and its last
   letter was shaved off by the hero section's `overflow-x:hidden`. Neighbours
   for scale: "Vietnam's" 178px, "AgileTech" 168px, "Secures" 141px, so only
   this one proper noun is over the line.
   `break-word` only ever engages when a word cannot fit on a line of its own,
   so every heading that already fits is left byte-identical. Two alternatives
   were rejected: a smaller mobile font-size changes typography on all 185
   pages and is a tuning value that the next long product name invalidates,
   and `hyphens:auto` would insert hyphens into ordinary headings site-wide.
   This is a guarantee rather than a number that has to be re-tuned. */
h1,h2,h3,h4{font-family:'Plus Jakarta Sans',sans-serif;line-height:1.18;letter-spacing:-.015em;text-wrap:balance;overflow-wrap:break-word}
/* Rev 83 (ask 9): CTA titles are <p class="cta-heading"> now, not headings.
   They must keep the display face. This rule is the price of de-tagging and it
   was found by MEASURING, not by reading the diff: after the swap, three CTA
   families silently fell back to Inter because the selector above is the ONLY
   place the display face is assigned, and it is keyed on the tag. The sitewide
   CtaBanner never showed the fault because its own rule happens to repeat
   `font-family` by hand. Declared here, immediately beside the rule it
   substitutes for, so the two cannot drift. `text-wrap:balance` comes with it:
   these are short display lines and rule 16 (no widowed last word) applies to
   them exactly as it did when they were headings. */
.cta-heading{font-family:'Plus Jakarta Sans',sans-serif;line-height:1.18;letter-spacing:-.015em;text-wrap:balance}
img{max-width:100%;height:auto;display:block}
a{color:inherit;text-decoration:none}
.container{max-width:1120px;margin:0 auto;padding:0 24px}
.narrow{max-width:820px}

/* Buttons */
/* Ask 9 (Rev 66): was `transition:.2s`, i.e. EVERY animatable property on the
   default `ease` curve. Two separate costs. (a) `all` makes the browser watch
   every property, so an unrelated layout change can animate by accident -- a
   padding or width shift that should be instant crawls instead. (b) `ease` eases
   IN, so the button starts responding slowly to the cursor.
   The property list is not a guess: it is the union of what .btn's own state
   rules actually change, read out of the stylesheet. Colour swaps run on the
   fast tier and the physical lift on the standard tier, because a tint that
   takes as long as a movement reads as lag. */
.btn{display:inline-block;padding:13px 28px;border-radius:99px;font-weight:700;font-size:15px;font-family:'Plus Jakarta Sans',sans-serif;transition:background-color var(--dur-1) var(--ease-out),color var(--dur-1) var(--ease-out),border-color var(--dur-1) var(--ease-out),opacity var(--dur-1) var(--ease-out),transform var(--dur-2) var(--ease-out),box-shadow var(--dur-2) var(--ease-out);cursor:pointer;border:none}
.btn-primary{background:var(--grad);color:#fff}
.btn-primary:hover{opacity:.92;transform:translateY(-1px)}
.btn-soft{background:var(--brand-50);color:var(--brand-700)}
.btn-soft:hover{background:var(--brand-100)}
.btn-white{background:#fff;color:var(--brand-700)}
.btn-sm{padding:10px 22px;font-size:14px}

/* Top bar (corporate contact strip) */
.topbar{background:var(--ink-900);color:#C9C9D2;font-size:12.5px;padding:8px 0}
.topbar .container{display:flex;gap:22px;align-items:center;flex-wrap:wrap}
.tb-item{display:inline-flex;align-items:center;gap:6px}
.tb-item a{color:#E7E7EC}
.tb-item a:hover{color:#fff}
.tb-iso{margin-left:auto;color:#9C9CA8}
.ico-tb{width:13px;height:13px;color:var(--brand-500);flex:none}
@media(max-width:760px){.tb-wide,.tb-iso{display:none}}

/* Header */
.site-header{position:sticky;top:12px;z-index:50;padding:0 16px;margin-top:12px}
.navwrap{position:relative;background:rgba(255,255,255,.92);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1px solid var(--line);border-radius:99px;box-shadow:var(--shadow-md);display:flex;align-items:center;justify-content:space-between;height:62px;padding:0 10px 0 22px;max-width:1120px;margin:0 auto}
.logo{display:flex;align-items:center;gap:9px;font-family:'Plus Jakarta Sans',sans-serif;font-weight:800;font-size:20px}
.logo svg{width:30px;height:30px;color:var(--brand-500);flex:none}
.logo .t{color:var(--brand-500)}
.mainnav{display:flex;gap:2px;list-style:none;align-items:center}
/* Rev 36: the mega panel is anchored to the NAV BAR, not to its trigger.
   Anchoring to the <li> and centering with left:50%/translateX(-50%) pushed
   the 1020px panel off the left edge of the viewport whenever the trigger sat
   near the start of the bar (Services was clipped to "stom Software
   Development"). Making the li static and the navwrap the containing block
   centres every panel under the bar itself, which is already viewport-centred
   and never wider than 1240px, so overflow is impossible by construction. */
.mainnav>li{position:static}
.mainnav>li>a,.mainnav>li>button{display:flex;align-items:center;gap:5px;padding:9px 13px;border-radius:99px;font-size:14.5px;font-weight:500;color:var(--ink-500);background:none;border:none;cursor:pointer;font-family:'Inter',sans-serif}
.mainnav>li>a:hover,.mainnav>li>button:hover,.mainnav>li:hover>button{color:var(--brand-700);background:var(--brand-50)}
.caret{width:9px;height:9px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(45deg) translateY(-2px)}
/* Mega menu (Rev 36): two-pane index + preview.
   Left rail = short scan list. Right pane = the hovered/focused item's
   description, children and illustration. Panes share one grid cell so they
   stack without JS; only the active one is painted. The rail is scrollable
   because Technologies has 26 entries and Solutions 17. */
.mega{position:absolute;top:calc(100% + 14px);left:50%;transform:translateX(-50%) translateY(6px);background:#fff;border:1px solid var(--line);border-radius:var(--r-lg);box-shadow:0 24px 60px rgba(38,38,46,.14);padding:12px;opacity:0;visibility:hidden;transition:opacity var(--dur-2) var(--ease-out),transform var(--dur-2) var(--ease-out),visibility var(--dur-2) var(--ease-out);width:940px;max-width:calc(100vw - 48px)}
.mega.mega-wide{width:1020px}
/* Invisible bridge across the dead gap between the nav bar and the panel, so
   the pointer can travel from trigger to panel without :hover dropping out.

   Rev 37 BUGFIX, and it was a regression introduced by Rev 37's own predecessor.
   Rev 36 set `.mainnav>li{position:static}` so the panel would anchor to the
   navwrap instead of the trigger (which fixed off-viewport clipping). But that
   also moved the panel's reference edge from the LI's bottom down to the
   NAVWRAP's bottom, and the LI is vertically centred inside a taller navwrap.
   The real gap therefore became:

       (navwrap.bottom - li.bottom) + offset  =  14.45px + 10px  =  24.45px

   while this bridge was still the 20px sized for the old layout. The uncovered
   4.45px band dropped :hover the instant the pointer left the trigger, so the
   whole menu closed before the user could reach ANY link. Measured identical
   (24.45px) at 1920 / 1440 / 1280 / 1180 / 1024.

   28px covers it with margin. It may overlap the nav row by ~3.5px at the very
   bottom edge of the triggers, which is harmless: the triggers are 43px tall,
   so there is ample area left to hover a sibling and switch menus.
   Guarded by tools/check_nav_interaction.py, which drives a real pointer path
   and asserts the deepest child links actually navigate. */
.mega::before{content:'';position:absolute;left:0;right:0;top:-28px;height:28px}
/* NO-JAVASCRIPT FALLBACK ONLY. app.js sets .nav-js on <html>, which switches
   these pure-hover rules off wholesale and hands control to the latch layer
   further down.

   The two layers are mutually exclusive BY CONSTRUCTION rather than by tuned
   specificity, because trying to make them coexist is what caused two rounds of
   silent breakage: `.mega-rail:hover .mega-railitem.is-first .mega-pane`
   (specificity 0,4,0) outranks the latch's `.mega-railitem.is-open .mega-pane`
   (0,3,0), so a row could be correctly latched, visibly highlighted, and STILL
   compute display:none. Confirmed by reading getComputedStyle on a latched
   element. Scoping the fallback removes the whole class of conflict instead of
   re-balancing one selector at a time. */
html:not(.nav-js) .mainnav>li:hover .mega,.mainnav>li:focus-within .mega{opacity:1;visibility:visible;transform:translateX(-50%) translateY(0)}
.mega-2{display:grid;grid-template-columns:288px 1fr;gap:10px;align-items:start}
.mega-left{display:flex;flex-direction:column;min-width:0}
/* Rail height is a WHOLE MULTIPLE of the 37.89px row (12 rows = 454.7px).
   At the previous max-height:470px the viewport ended partway through row 13,
   so Services cut "RPA Development", Solutions cut "Odoo Software" and
   Technologies cut "Kotlin" horizontally through the glyphs. A half-row of
   sliced letters reads as a rendering fault, not as "there is more below".
   Ending exactly on a row boundary means the list is always cleanly cropped,
   and the scrollbar remains the affordance that more exists. */
.mega-rail{display:flex;flex-direction:column;gap:1px;max-height:454.7px;overflow-y:auto;overflow-x:visible;overscroll-behavior:contain;padding-right:4px}
@media(max-height:720px){.mega-rail{max-height:340.9px}}
.mega-railitem{position:static}
.mega-rail::-webkit-scrollbar{width:6px}
.mega-rail::-webkit-scrollbar-thumb{background:#DCDCE3;border-radius:99px}
.mega-railitem>a{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:9px 12px;border-radius:8px;font-size:14px;font-weight:600;color:var(--ink-900);line-height:1.35}
.mega-chev{color:transparent;font-size:17px;line-height:1;transition:color var(--dur-1) var(--ease-out);flex:none}
.mega-railitem:hover>a,.mega-railitem:focus-within>a{background:var(--brand-50);color:var(--brand-700)}
.mega-railitem:hover .mega-chev,.mega-railitem:focus-within .mega-chev{color:var(--brand-700)}
/* Panes are DOM children of their rail item (so :hover/:focus-within work
   with no JS), but must PAINT in the right-hand column. They are therefore
   positioned against .mega, which is the nearest positioned ancestor.
   grid-column:2 was tried first and is inert here: the panes are not direct
   children of .mega-2, so the grid never places them. Verified in a real
   browser, where the pane rendered at the rail's x and width, below the fold
   of the rail, rather than beside it. */
.mega-pane{position:absolute;left:310px;top:12px;right:12px;bottom:12px;display:none;gap:22px;align-items:center;background:var(--surface-50,#F7F8FA);border-radius:14px;padding:24px 26px;min-height:212px}
/* Rev 37 BUGFIX: the pane is a DOM child of its rail item, so under pure CSS it
   only stays open while `.mega-railitem:hover` holds. Between the rail item's
   right edge (x=507) and the pane's left edge (x=521) sat a 14px corridor
   belonging to .mega-2. Moving the pointer rightward toward the pane crossed
   that corridor, :hover dropped, the pane closed and the rail reverted to its
   first item, so the child links inside the pane ("Hire iOS Developers") could
   NEVER be clicked. Measured with a real pointer path, not reasoned about:
   elementFromPoint returned DIV.mega-2 for every x in 511..520.
   This is the bug reported as the menu "lagging" and not letting a sub-page
   be selected.

   This bridge extends the pane's hit area leftward across the corridor to meet
   the rail. It lies entirely OUTSIDE the pane's own box (left:-22px, width:22px)
   so it cannot overlap, and therefore cannot intercept clicks on, any link.
   An earlier attempt added z-index:-1 "to be safe"; that was the actual defect,
   because .mega carries a transform and so forms a stacking context, which sank
   the bridge behind .mega-2's own background and left the corridor dead. No
   z-index here is deliberate. */
.mega-railitem:hover .mega-pane::before,.mega-railitem:focus-within .mega-pane::before{
  content:'';position:absolute;left:-22px;top:0;bottom:0;width:22px}
.mega-railitem.is-first .mega-pane{display:flex}
html:not(.nav-js) .mega-rail:hover .mega-railitem.is-first .mega-pane,html:not(.nav-js) .mega-rail:focus-within .mega-railitem.is-first .mega-pane{display:none}
html:not(.nav-js) .mega-railitem:hover .mega-pane,.mega-railitem:focus-within .mega-pane{display:flex}
/* STICKY STATE LAYER (Rev 37). Everything above is the no-JavaScript fallback
   and depends on an unbroken :hover chain, which is exactly the fragility that
   produced this bug twice in a row (Rev 36 moved the panel's anchor and silently
   opened a 24.45px gap; the rail corridor above was a second instance of the
   same class). app.js therefore latches the open item as a CLASS on pointerenter
   and only clears it when the pointer genuinely leaves the whole menu subtree.
   Once latched, no dead pixel anywhere along the travel path can close the menu,
   because nothing about staying open is a function of pointer position any more.
   `.has-open` suppresses the is-first default preview once a real choice exists. */
.mainnav>li.is-open .mega{opacity:1;visibility:visible;transform:translateX(-50%) translateY(0)}
/* `:not(.is-open)` is load-bearing: without it this rule also suppressed the
   FIRST row when the first row was the one genuinely selected, so the row
   highlighted, the latch was set, and the pane still computed display:none. */
.mega-rail.has-open .mega-railitem.is-first:not(.is-open) .mega-pane{display:none}
.mega-railitem.is-open .mega-pane{display:flex}
.mega-pane-copy{flex:1 1 auto;min-width:0}
.mega-pane-title{margin:0 0 8px;font-size:17px;font-weight:750;color:var(--ink-950);letter-spacing:-.01em}
.mega-pane-text{margin:0;font-size:13.5px;line-height:1.6;color:var(--ink-700);max-width:46ch}
.mega-pane-links{display:flex;flex-wrap:wrap;gap:4px 6px;margin:12px 0 0}
.mega-pane-links a{display:inline-block;padding:4px 10px;border-radius:99px;background:#fff;border:1px solid var(--line,#E8E8EE);font-size:12.5px;font-weight:500;color:var(--ink-700);line-height:1.5}
.mega-pane-links a:hover{border-color:var(--brand-500);color:var(--brand-700);background:var(--brand-50)}
.mega-pane-cta{display:inline-block;margin-top:14px;font-size:13.5px;font-weight:700;color:var(--brand-700)}
.mega-pane-cta:hover{text-decoration:underline}
.mega-pane-media{flex:0 0 210px;align-self:center}
.mega-pane-media img{width:100%;height:150px;object-fit:cover;border-radius:12px;display:block}
/* Rev 58: Technologies panes paint the real vendor mark instead of a stock desk
   photograph that repeated ten times in one menu. A logo must be CONTAINED and
   centred on a light mat, never `cover`, which would crop and stretch a mark and
   distort someone else's trademark. Same 210x150 box as the photographs so the
   pane geometry is unchanged and no pane grows or shrinks as the reader moves
   down the rail. Mirrors the .hub-feature-logo treatment already used for the
   technology hub cards, at nav scale. */
.mega-pane-logo{display:grid;place-items:center;height:150px;background:linear-gradient(145deg,#fff,var(--surface-50));border:1px solid var(--line);border-radius:12px}
.mega-pane-logo img{width:auto;height:64px;max-width:76%;object-fit:contain;border-radius:0}
/* Pinned under the rail, outside its scroll box, so it never scrolls away. */
.mega-hub{display:block;flex:none;margin-top:8px;padding:11px 12px 3px;border-top:1px solid var(--line);color:var(--brand-700);font-weight:700;font-size:13.5px}
.mega-hub:hover{color:var(--brand-500);text-decoration:underline;text-underline-offset:2px}
@media(max-width:1120px){
  .mega,.mega.mega-wide{width:calc(100vw - 48px)}
  .mega-2{grid-template-columns:250px 1fr}
  .mega-pane-media{display:none}
}
/* Mobile nav */
.menu-btn{display:none;background:none;border:none;cursor:pointer;padding:10px;margin-right:4px}
/* The hamburger's bars rotate and one fades (.menu-btn.open span:nth-child(n)
   below), so transform and opacity are the only properties involved. */
.menu-btn span{display:block;width:22px;height:2px;background:var(--ink-900);margin:5px 0;border-radius:2px;transition:transform var(--dur-2) var(--ease-out),opacity var(--dur-1) var(--ease-out)}
.menu-btn.open span:nth-child(1){transform:translateY(7px) rotate(45deg)}
.menu-btn.open span:nth-child(2){opacity:0}
.menu-btn.open span:nth-child(3){transform:translateY(-7px) rotate(-45deg)}
.mobile-nav{display:none}
@media(max-width:1020px){
  .mainnav,.nav-cta{display:none}
  .menu-btn{display:block}
  /* Positioned absolute under the sticky header: the header's backdrop-filter makes it
     the containing block for fixed descendants, which broke the old position:fixed panel. */
  .mobile-nav{display:none;position:absolute;top:calc(100% + 10px);left:16px;right:16px;max-height:calc(100vh - 150px);max-height:calc(100dvh - 150px);background:#fff;border:1px solid var(--line);border-radius:var(--r-lg);box-shadow:0 24px 60px rgba(38,38,46,.28);z-index:49;overflow-y:auto;padding:18px;-webkit-overflow-scrolling:touch}
  .mobile-nav.open{display:block}
  .mobile-nav details{border-bottom:1px solid var(--line)}
  .mobile-nav summary{font-family:'Plus Jakarta Sans',sans-serif;font-weight:700;font-size:16px;padding:15px 6px;cursor:pointer;list-style:none;display:flex;justify-content:space-between;align-items:center}
  /* Ask 20 (Rev 49): the top-level Contact row. Matches the <summary> rows
     exactly so the nav reads as one consistent list, but it is a plain link,
     so it navigates on the first tap. */
  .mobile-nav .m-top{display:block;font-family:'Plus Jakarta Sans',sans-serif;font-weight:700;font-size:16px;padding:15px 6px;color:var(--ink-950);border-bottom:1px solid var(--line)}
  .mobile-nav .m-top:hover{color:var(--brand-700)}
  .mobile-nav summary::-webkit-details-marker{display:none}
  .mobile-nav summary::after{content:"+";color:var(--brand-500);font-size:20px}
  .mobile-nav details[open] summary::after{content:"−"}
  .mobile-nav a{display:block;padding:9px 14px;font-size:14.5px;color:var(--ink-500);border-radius:10px}
  .mobile-nav a:hover{background:var(--brand-50);color:var(--brand-700)}
  .mobile-nav a.m-sub{padding-left:30px;font-size:13.5px;position:relative}
  .mobile-nav a.m-sub::before{content:"";position:absolute;left:16px;top:50%;width:6px;height:1px;background:var(--ink-500)}
  .mobile-nav .m-cta{margin:18px 6px 6px;display:block;text-align:center;color:#fff}
  .mobile-nav .m-cta:hover{background:var(--brand-700);color:#fff}
}

/* Hero */
.hero{padding:84px 0 64px;text-align:center;position:relative;overflow:hidden;background:radial-gradient(60% 55% at 50% 0%,var(--brand-50) 0%,rgba(251,236,238,0) 75%)}
.hero::before{content:"";position:absolute;inset:0;background-image:linear-gradient(rgba(38,38,46,.028) 1px,transparent 1px),linear-gradient(90deg,rgba(38,38,46,.028) 1px,transparent 1px);background-size:56px 56px;mask-image:radial-gradient(70% 65% at 50% 0%,#000 0%,transparent 80%);-webkit-mask-image:radial-gradient(70% 65% at 50% 0%,#000 0%,transparent 80%);pointer-events:none}
/* Ask 6 (Rev 66): `.hero .motif-l` / `.hero .motif-r` are RETIRED, not restyled.

   The polygon-network mark was painted into 22 hero sections (20 in index.tsx,
   2 in legal.tsx) at 9% opacity. The client's objection was the repetition
   rather than the mark: "I've seen a repeated logo on the background of every
   hero section page, so remove it." Turning down the opacity would have kept the
   repetition and just made it harder to see, so all 22 call sites are gone.

   The rules are deleted rather than left as dead selectors, because a CSS rule
   for an element that no longer renders is a trap: it reads as live styling and
   the next person to add a `.motif-l` gets 9% crimson from nowhere.

   The mark SURVIVES in two places, deliberately:
     - `.cta-banner .motif` (line ~509) - ONE instance per page, inside the dark
       CTA band, at 16%. That is a single accent, not a repeated background, so
       the client's objection does not reach it. It is also the identity anchor
       AGENTS.md names, so removing it everywhere would strip the brand geometry
       from the site entirely.
     - `MotifDefs()` in components.tsx still ships the <defs> the CTA <use>
       resolves against. It is a zero-size hidden <svg>, so this costs nothing.

   `.hero::before` (the 2.8% grid texture directly above) is NOT the logo and
   stays. It was checked against the ask: the client named a repeated LOGO, and
   the grid is a neutral surface texture with no brand geometry in it. */

.pill{display:inline-block;background:#fff;border:1px solid var(--brand-100);color:var(--brand-700);font-size:13px;font-weight:600;padding:8px 18px;border-radius:99px;margin-bottom:22px;box-shadow:0 2px 10px rgba(226,58,82,.08)}
.hero h1{font-size:54px;font-weight:800;max-width:17ch;margin:0 auto}
.hero h1 em{font-style:normal;background:var(--grad);-webkit-background-clip:text;background-clip:text;color:transparent}
.hero .sub{max-width:58ch;color:var(--ink-500);font-size:17px;margin:22px auto 32px}
/* Rev 83. `.ctas` never had a BASE rule. It was laid out only by container
   rules: `.hero .ctas`, `.cta-split .cta-copy .ctas`, `.ev-copy .ctas`. Used
   anywhere else it fell back to `display:block`, so its buttons became inline
   boxes separated by a 4 to 5px word space, touching each other, and unable to
   wrap as a unit. A census of every `.ctas` on all 185 routes found exactly one
   such context, `.container.narrow.center-head > .ctas` on the 7 legal pages,
   but the absent base is the defect, not those 7 pages: the next `.ctas` placed
   outside a hero would have broken the same way. Specificity keeps the existing
   container rules in charge, so this changes nothing else: `.hero .ctas` is
   (0,2,0) against this rule's (0,1,0), and it wins wherever both apply. */
.ctas{display:flex;flex-wrap:wrap;gap:14px;align-items:center}
.center-head .ctas{justify-content:center}
.hero .ctas{display:flex;gap:14px;justify-content:center;flex-wrap:wrap}
.hero.hero-page{padding:72px 0 56px}
.hero.hero-page h1{font-size:44px}
/* Breadcrumb */
.crumbs{display:flex;gap:8px;justify-content:center;font-size:13.5px;color:var(--ink-500);margin-bottom:20px;flex-wrap:wrap}
.crumbs a:hover{color:var(--brand-700)}
.crumbs .sep{color:var(--brand-100)}

/* Hero vendor mark (Rev 77, ask 2).
   A pill carrying the technology's own logo above the H1 on the 27 /technologies/*
   pages. It exists because the Technologies mega menu previews each vendor mark, and
   a menu may only preview an image the destination actually paints; before this, none
   of the 27 pages carried its own mark anywhere.
   The label beside the logo is the technology name, so the pill is not a bare
   decorative glyph, and the logo therefore takes real alt text rather than alt="". */
.hero-mark{display:inline-flex;align-items:center;gap:9px;margin-bottom:14px;padding:6px 14px 6px 10px;border:1px solid var(--line);border-radius:99px;background:#fff;box-shadow:var(--shadow-sm);font-size:13.5px;font-weight:600;color:var(--ink-900);line-height:1}
.hero-mark img{width:22px;height:22px;object-fit:contain;display:block}

/* Stats */
.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:20px;margin-top:56px}
.stat{background:#fff;border:1px solid var(--line);border-radius:var(--r-md);padding:28px;box-shadow:var(--shadow-md);text-align:center}
.stat b{font-family:'Plus Jakarta Sans',sans-serif;font-size:38px;font-weight:800;color:var(--brand-700);display:block}
.stat span{color:var(--ink-500);font-size:14px;margin-top:4px;display:block}

/* Sections */
.section{padding:84px 0}
.section.tight{padding:56px 0}
.section.alt{background:var(--surface-50)}
.center-head{text-align:center;max-width:680px;margin:0 auto 52px}
.center-head h2,.center-head .cta-heading{font-size:34px;font-weight:800}
.center-head p{color:var(--ink-500);margin-top:12px}
/* ...and the de-tagged CTA title in a .center-head is still a <p>, so it was
   inheriting the muted lede colour and the 12px lede offset from the rule
   directly above. Measured at rgb(104,104,115) against the rgb(37,37,44) the
   <h2> had. Restated rather than reordered, because the lede rule has to keep
   applying to the real lede paragraph that follows it. */
.center-head .cta-heading{color:var(--ink-900);margin-top:0}

/* Tiles / cards */
.tiles{display:grid;grid-template-columns:repeat(3,1fr);gap:20px}
.tiles.cols-2{grid-template-columns:repeat(2,1fr)}
.tiles.cols-4{grid-template-columns:repeat(4,1fr)}
.tile{background:var(--surface-50);border-radius:var(--r-lg);padding:30px 26px;transition:background-color var(--dur-2) var(--ease-out),border-color var(--dur-2) var(--ease-out),box-shadow var(--dur-2) var(--ease-out),transform var(--dur-2) var(--ease-out);border:1px solid transparent;display:block}
.tile:hover{background:#fff;border-color:var(--brand-100);box-shadow:var(--shadow-lg);transform:translateY(-3px)}
.section.alt .tile{background:#fff;border-color:var(--line)}
.section.alt .tile:hover{border-color:var(--brand-100)}
.tile .ic{width:48px;height:48px;border-radius:14px;background:#fff;color:var(--brand-500);display:flex;align-items:center;justify-content:center;margin-bottom:18px;box-shadow:var(--shadow-sm);overflow:hidden;transition:background var(--dur-1) var(--ease-out),color var(--dur-1) var(--ease-out),box-shadow var(--dur-2) var(--ease-out)}
.tile .ic img{width:30px;height:30px;object-fit:contain}
.tile:hover .ic{background:var(--grad);color:#fff}
.tile h3,.tile h2{font-size:17.5px;font-weight:700;margin:0 0 7px}
.tile p{font-size:14px;color:var(--ink-500)}
.tile .more{color:var(--brand-700);font-size:13.5px;font-weight:600;display:inline-block;margin-top:12px}
.tile-center{text-align:center}
.tile-center .ic{margin-left:auto;margin-right:auto}
/* Icons */
.ico{width:22px;height:22px}
.ico-sm{width:16px;height:16px;vertical-align:-3px;color:var(--brand-500)}
/* Hero trust row */
.trust-row{display:flex;gap:26px;justify-content:center;flex-wrap:wrap;margin-top:34px}
.trust{font-size:14px;color:var(--ink-500);display:flex;align-items:center;gap:7px}
.trust b{color:var(--ink-900);font-family:'Plus Jakarta Sans',sans-serif;font-weight:800}

/* Content bands (detail pages): alternating white / tinted full-width sections */
.sec-band{padding:64px 0;scroll-margin-top:96px}
.sec-band.alt{background:var(--surface-50)}
.sec-band .narrow{max-width:820px}
.sec-head{text-align:center;max-width:760px;margin:0 auto 34px}
.sec-head h2{font-size:30px;font-weight:800}
.sec-body p{margin:0 auto 16px;color:#3A3A44;font-size:16px;max-width:760px}
.sec-body p.lead{font-size:18px;color:var(--ink-900);font-weight:500}
.sec-body h3{margin:30px auto 12px;font-size:20px;max-width:760px}
.sec-body .fig{margin:26px auto;max-width:820px}
.sec-body .fig img{border-radius:var(--r-lg);box-shadow:var(--shadow-md);margin:0 auto}
/* Chip nav (jump-to) */
.chipnav{background:#fff;border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:14px 0}
.chipnav .container{display:flex;gap:8px;align-items:center;flex-wrap:wrap;justify-content:center}
.chipnav-t{font-size:12.5px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--ink-500);margin-right:6px}
.chip{display:inline-block;background:var(--surface-50);border:1px solid var(--line);border-radius:99px;padding:7px 15px;font-size:13px;font-weight:600;color:var(--ink-500);transition:background-color var(--dur-1) var(--ease-out),border-color var(--dur-1) var(--ease-out),color var(--dur-1) var(--ease-out)}
.chip:hover{background:var(--brand-50);border-color:var(--brand-100);color:var(--brand-700)}
/* Checklist */
.checklist{list-style:none;max-width:760px;margin:12px auto 20px}
.checklist.cols-2{display:grid;grid-template-columns:1fr 1fr;gap:0 32px}
.checklist li{padding-left:36px;position:relative;margin-bottom:14px;font-size:15.5px;color:#3A3A44}
.checklist li::before{content:"";position:absolute;left:0;top:2px;width:22px;height:22px;border-radius:50%;background:var(--brand-50)}
.checklist li::after{content:"✓";position:absolute;left:6px;top:0;color:var(--brand-700);font-weight:700;font-size:13px}
.checklist li strong{color:var(--ink-900)}
/* Content cards */
.cards-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:18px;margin:10px 0 8px}
.cards-grid.g2{grid-template-columns:repeat(2,1fr);max-width:900px;margin-left:auto;margin-right:auto}
.card{background:#fff;border:1px solid var(--line);border-radius:var(--r-lg);padding:28px 24px;box-shadow:var(--shadow-sm);transition:background-color var(--dur-1) var(--ease-out),color var(--dur-1) var(--ease-out),border-color var(--dur-2) var(--ease-out),box-shadow var(--dur-2) var(--ease-out),transform var(--dur-2) var(--ease-out)}
.card:hover{box-shadow:var(--shadow-lg);transform:translateY(-3px);border-color:var(--brand-100)}
.sec-band:not(.alt) .card{background:var(--surface-50);border-color:transparent;box-shadow:none}
.sec-band:not(.alt) .card:hover{background:#fff;border-color:var(--brand-100);box-shadow:var(--shadow-lg)}
.card .ic{width:46px;height:46px;border-radius:14px;background:var(--brand-50);color:var(--brand-500);display:flex;align-items:center;justify-content:center;margin-bottom:16px;overflow:hidden}
.card .ic img{width:28px;height:28px;object-fit:contain}
.card:hover .ic{background:var(--grad);color:#fff}
.card h3{font-size:16.5px;font-weight:700;margin:0 0 8px !important}
.card p{font-size:14px !important;color:var(--ink-500) !important;margin:0 !important}
/* Steps: numbered card rail */
.steps{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:18px;margin:10px 0 8px}
.step{background:#fff;border:1px solid var(--line);border-radius:var(--r-lg);padding:26px 24px;box-shadow:var(--shadow-sm);position:relative;transition:background-color var(--dur-1) var(--ease-out),border-color var(--dur-2) var(--ease-out),box-shadow var(--dur-2) var(--ease-out),transform var(--dur-2) var(--ease-out)}
.step:hover{box-shadow:var(--shadow-lg);transform:translateY(-3px);border-color:var(--brand-100)}
.sec-band:not(.alt) .step{background:var(--surface-50);border-color:transparent;box-shadow:none}
.sec-band:not(.alt) .step:hover{background:#fff;border-color:var(--brand-100);box-shadow:var(--shadow-lg)}
.step .no{display:inline-flex;width:44px;height:44px;border-radius:14px;background:var(--grad);color:#fff;align-items:center;justify-content:center;font-family:'Plus Jakarta Sans',sans-serif;font-weight:800;font-size:16px;margin-bottom:16px}
.step h3{font-size:16.5px;font-weight:700;margin:0 0 7px !important}
.step p{font-size:14px !important;color:var(--ink-500) !important;margin:0 !important}

/* FAQ */
.faq{max-width:800px;margin:0 auto}
.faq details{background:#fff;border:1px solid var(--line);border-radius:var(--r-md);margin-bottom:12px;overflow:hidden}
.faq summary{cursor:pointer;list-style:none;padding:20px 24px;font-family:'Plus Jakarta Sans',sans-serif;font-weight:700;font-size:16px;display:flex;justify-content:space-between;align-items:center;gap:16px}
.faq summary h3{font:inherit;letter-spacing:inherit;color:inherit;margin:0}
.faq summary::-webkit-details-marker{display:none}
.faq summary::after{content:"+";color:var(--brand-500);font-size:22px;flex:none}
.faq details[open] summary::after{content:"−"}
.faq .a{padding:0 24px 20px;color:var(--ink-500);font-size:15px}

/* Verified reviews (Rev 25f): dark showcase band + platform logos + 3x3 slider.
   Rev 80: .csf-section, the homepage case-study band, shares these three
   declarations rather than restating them. It is the same fill by design ("match
   the client review section design"), and the accessible-button window derived at
   the bottom of this file is derived FROM that fill, so a second copy of the colour
   would be a second thing to keep in step with a contrast proof. */
.rev-section,.csf-section{background:#26262E}
.rev-section .center-head h2,.csf-section .center-head h2{color:#fff}
.rev-section .center-head p,.csf-section .center-head p{color:rgba(255,255,255,.72)}
.rev-agg{display:flex;justify-content:center;gap:20px;margin:0 0 38px;flex-wrap:wrap}
.rev-agg-card{display:flex;flex-direction:column;align-items:center;gap:10px;background:#fff;border:1px solid var(--line,#EFEFF2);border-radius:14px;padding:20px 34px;box-shadow:0 10px 34px rgba(0,0,0,.28);transition:box-shadow var(--dur-2) var(--ease-out),transform var(--dur-2) var(--ease-out);min-width:250px}
.rev-agg-card:hover{transform:translateY(-3px);box-shadow:0 16px 44px rgba(0,0,0,.36)}
.rev-agg-logo{height:30px;width:auto;max-width:170px;object-fit:contain}
.rev-agg-rating{display:flex;align-items:baseline;gap:10px}
.rev-agg-score{font-family:'Plus Jakarta Sans',sans-serif;font-weight:800;font-size:30px;line-height:1;color:var(--ink-900)}
.rev-agg-score small{font-size:14px;font-weight:700;color:var(--ink-500)}
.rev-agg-stars{color:var(--brand-500);letter-spacing:2px;font-size:15px}
.rev-agg-meta{font-size:13px;color:var(--ink-500);text-align:center;display:flex;flex-direction:column;gap:3px}
.rev-agg-cta{color:var(--brand-500);font-weight:700;font-family:'Plus Jakarta Sans',sans-serif}
.rev-agg-card:hover .rev-agg-cta{text-decoration:underline}
.rev-agg-meta .ext{color:var(--brand-500)}
.rev-wrap{position:relative}
.rev-track{display:grid;grid-auto-flow:column;grid-auto-columns:100%;gap:24px;overflow-x:auto;scroll-snap-type:x mandatory;padding:6px 2px 14px;scrollbar-width:none}
.rev-track::-webkit-scrollbar{display:none}
.rev-slide{scroll-snap-align:start;display:grid;grid-template-columns:repeat(3,1fr);grid-auto-rows:1fr;gap:18px}
.rev-card{display:flex;flex-direction:column;background:#fff;border:1px solid rgba(255,255,255,.08);border-radius:14px;padding:22px 22px 20px;box-shadow:0 6px 24px rgba(0,0,0,.22)}
.rev-top{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}
.rev-stars{display:flex;align-items:center;gap:8px;color:var(--brand-500);letter-spacing:3px;font-size:13px}
.rev-stars b{font-family:'Plus Jakarta Sans',sans-serif;font-size:12.5px;letter-spacing:0;color:var(--ink-900);background:var(--brand-50);border-radius:99px;padding:2px 8px}
.rev-date{font-size:12px;color:var(--ink-500)}
.rev-quote{font-family:'Plus Jakarta Sans',sans-serif;font-weight:700;font-size:14.5px;line-height:1.5;color:var(--ink-900)}
.rev-body-wrap{margin-top:10px}
.rev-body{font-size:13.5px;line-height:1.65;color:#4A4A54}
.js .rev-body.clamped{display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden}
.rev-more{background:none;border:0;padding:0;margin-top:6px;font-size:12.5px;font-weight:700;font-family:'Plus Jakarta Sans',sans-serif;color:var(--brand-500);cursor:pointer}
.rev-more:hover{color:var(--brand-700);text-decoration:underline}
.rev-metric{display:flex;align-items:flex-start;gap:8px;margin-top:12px;font-size:12.5px;font-weight:600;color:var(--brand-700);background:var(--brand-50);border-radius:8px;padding:9px 11px;line-height:1.5}
.rev-metric .ico-sm{margin-top:2px;flex:none}
.rev-foot{margin-top:auto;padding-top:14px;border-top:1px solid var(--line,#EFEFF2);display:flex;flex-direction:column;gap:10px}
.rev-cite{display:block;font-style:normal}
.rev-cite .who{font-weight:700;font-family:'Plus Jakarta Sans',sans-serif;font-size:13.5px}
.rev-cite small{display:block;margin-top:2px;color:var(--ink-500);font-size:11.5px;font-weight:400}
.rev-verify{display:inline-flex;align-items:center;gap:6px;font-size:12.5px;font-weight:700;font-family:'Plus Jakarta Sans',sans-serif;color:var(--brand-500);width:max-content;transition:color var(--dur-1) var(--ease-out)}
.rev-verify .ico-sm{flex:none}
.rev-verify:hover{color:var(--brand-700);text-decoration:underline}
.rev-nav{display:flex;justify-content:center;align-items:center;gap:16px;margin-top:14px}
.rev-btn{width:44px;height:44px;border-radius:50%;border:1px solid rgba(255,255,255,.28);background:transparent;font-size:17px;cursor:pointer;color:#fff;transition:background-color var(--dur-1) var(--ease-out),border-color var(--dur-1) var(--ease-out);display:flex;align-items:center;justify-content:center}
.rev-btn:hover{background:var(--brand-500);border-color:var(--brand-500)}
.rev-dots{display:flex;gap:8px}
.rev-dot{width:9px;height:9px;border-radius:50%;border:0;background:rgba(255,255,255,.3);cursor:pointer;padding:0;transition:background-color var(--dur-1) var(--ease-out),transform var(--dur-2) var(--ease-out)}
.rev-dot.on{background:var(--brand-500);transform:scale(1.25)}

/* Badges wall */
.badges{display:grid;grid-template-columns:repeat(6,1fr);gap:18px;justify-items:center;align-items:center;margin-top:8px;max-width:1080px;margin-left:auto;margin-right:auto}
/* Ask 10 (Rev 51). Two separate defects were measured here, and only the second
   one is the defect the client actually reported.
   ------------------------------------------------------------------
   1. DOUBLE FRAME. The selector used to be `.badges img,.badges .badge-link`,
      and `.badges img` ALSO matched the <img> nested inside each
      <a class="badge-link">. So the three linked badges (Clutch, GoodFirms,
      TechBehemoths) received the full tile treatment TWICE: a padded, bordered,
      shadowed box inside another identical one. Both boxes are white with the
      same hairline, so the doubling was invisible AS a frame and surfaced only
      as an inconsistent row. Measured in the browser: the three linked badges
      painted their artwork at 106px while the three unlinked ones painted at
      128px, so half the wall rendered 17% smaller than the other half. A vision
      pass at 3x confirmed it independently: "left is smaller" and "the LEFT
      card has TWO concentric borders".
      The fix is a DIRECT-CHILD selector, not a more specific override. `>` means
      the tile is always the grid item, whatever element that happens to be, so
      the chrome cannot nest again the next time a badge gains or loses a link.
   2. NO FIGURE/GROUND. The tile fill was #fff on a --surface-50 (#F6F6F8) band
      with an #EFEFF2 hairline and a 1px 4%-alpha shadow. Measured in CIELAB
      that is only dL* 3.06 between card and band and dL* 5.48 for the border,
      which is why the wall read as "weak, washed out, and flat" rather than as
      six premium objects. Deepening the BAND was rejected: on the homepage the
      next section is also #F6F6F8 and on /services/ and /industries/ the
      previous one is, so a deeper recognition band would have put a faint
      dL* 3 seam between two adjacent greys, which looks like a rendering bug
      rather than a decision. Instead the CARD gets real definition: a #DFDFE8
      hairline (dL* 10.94 from the fill, 7.88 from the band) and the real
      --shadow-md lift, so it sits ON the band instead of dissolving into it.
      Tiles also grow 150 -> 165px, which the 1240px container has room for. */
.badges>img,.badges>.badge-link{height:auto;width:100%;max-width:165px;aspect-ratio:1/1;object-fit:contain;border-radius:14px;background:#fff;padding:12px;border:1px solid #DFDFE8;box-shadow:var(--shadow-md);transition:border-color var(--dur-2) var(--ease-out),box-shadow var(--dur-2) var(--ease-out),transform var(--dur-2) var(--ease-out);box-sizing:border-box}
/* The <img> inside a link fills its tile and carries no chrome of its own.
   The 8px radius is for the one badge whose artwork is an opaque rectangle
   (TechBehemoths, measured mode=RGB with near-black corners): it rounds that
   plate so it reads as a deliberate inset rather than a hard-edged screenshot
   dropped into a rounded card. It is a genuine 2024 award, so it is framed
   honestly rather than recoloured or hidden. On the five badges with
   transparent corners the radius is a no-op. */
.badges>.badge-link>img{width:100%;height:100%;object-fit:contain;border:0;padding:0;background:none;box-shadow:none;border-radius:8px}
/* Ask 10 (Rev 51): the ONE badge whose artwork is an opaque dark rectangle
   rather than a transparent vector mark, flagged `dark` in media.ts and
   measured there (mode=RGB, 79.1% of pixels below luminance 40).
   At equal scale it filled its card top to bottom while the five roundels kept
   natural whitespace around them, so it carried far more visual mass and, in a
   vision pass, read as "an out-of-place screenshot pasted in". Extra inset
   gives the white card a visible reveal on all four sides, so the card frames
   the plate like a mounted plaque instead of being covered by it. Cheaper and
   more honest than editing a third party's award artwork: the credential is
   real (2024 TechBehemoths, Mobile App Development) and is shown as issued. */
.badges>.badge-plate{padding:22px}
/* The 8px radius on the rule above cannot round THIS badge's artwork on its own.
   `object-fit:contain` letterboxes a 400x467 portrait image inside a square box
   (measured: 119x139 painted inside 139x139), and border-radius clips the BOX,
   whose left and right corners are transparent padding. So the plate kept hard
   90-degree corners inside a 14px-rounded card - a vision pass called them
   "sharp rectangular corners nested inside a rounded card".
   Sizing the box to the artwork instead makes the radius bite on the artwork
   itself. `aspect-ratio:auto` is required: the width/height attributes are a
   square CLS hint, and with one axis auto the browser would otherwise derive a
   1:1 ratio from them and re-letterbox the image. */
.badges>.badge-plate>img{width:auto;height:100%;aspect-ratio:auto;margin:0 auto;display:block}
.badges>img:hover,.badges>.badge-link:hover{transform:translateY(-3px);box-shadow:var(--shadow-lg);border-color:#CFCFDA}
.recognition{background:var(--surface-50)}
@media(max-width:1020px){.badges{grid-template-columns:repeat(3,1fr);max-width:600px}}
@media(max-width:640px){.badges{grid-template-columns:repeat(3,1fr);gap:12px;max-width:none}}

/* Split hero (company pages: copy left, photo right) */
.hero-split{text-align:left;padding:64px 0 60px}
.hero-split-grid{display:grid;grid-template-columns:11fr 9fr;gap:52px;align-items:center;position:relative;z-index:1}
.hero-split .crumbs{justify-content:flex-start}
.hero-split h1{margin:0;max-width:none}
.hero-split .sub{margin:20px 0 30px}
.hero-split .ctas{justify-content:flex-start}
.hs-media img{border-radius:var(--r-xl);box-shadow:0 24px 60px rgba(38,38,46,.16);aspect-ratio:4/3;object-fit:cover;width:100%}
@media(max-width:900px){.hero-split-grid{grid-template-columns:1fr;gap:32px}.hero-split{text-align:center}.hero-split .crumbs,.hero-split .ctas{justify-content:center}}

/* Photo gallery */
.gallery{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}
.gallery.g2{grid-template-columns:repeat(2,1fr)}
.gal{position:relative;border-radius:var(--r-lg);overflow:hidden;box-shadow:var(--shadow-sm);margin:0}
.gal img{width:100%;aspect-ratio:3/2;object-fit:cover;transition:transform var(--dur-3) var(--ease-out)}
.gal:hover img{transform:scale(1.04)}
.gal figcaption{position:absolute;left:0;right:0;bottom:0;padding:26px 18px 14px;font-size:13.5px;font-weight:600;color:#fff;background:linear-gradient(transparent,rgba(28,28,34,.82))}
@media(max-width:900px){.gallery,.gallery.g2{grid-template-columns:1fr}}
@media(min-width:640px) and (max-width:900px){.gallery{grid-template-columns:1fr 1fr}}

/* CEO profile */
.profile-grid{display:grid;grid-template-columns:2fr 3fr;gap:52px;align-items:start}
.profile-photo{margin:0;position:sticky;top:110px}
.profile-copy p{max-width:none}
@media(max-width:900px){.profile-grid{grid-template-columns:1fr}.profile-photo{position:static;max-width:380px;margin:0 auto}}

/* Rev 70: structured profile header + contact card. The header replaces the
   name/title prose lines that duplicated the H1; the card replaces the raw
   "Email:" checklist items with working links. Portrait now uses the shared
   .story-frame mat, so the old bare-img radius/shadow rule is gone. */
.profile-head{margin-bottom:20px}
.profile-name{font-size:clamp(26px,2.6vw,32px);font-weight:800}
.profile-name span{font-weight:600;color:var(--ink-500)}
.profile-role{margin:6px 0 0;font-size:16px;font-weight:700;color:var(--brand-500);letter-spacing:.01em}
.profile-contact{margin-top:30px;padding:22px 24px;background:var(--brand-50);border:1px solid var(--brand-100);border-radius:var(--r-md)}
.profile-contact h3{font-size:19px;font-weight:800;margin-bottom:14px}
.profile-contact-actions{display:flex;flex-wrap:wrap;gap:12px}
.profile-contact-actions .btn{display:inline-flex;align-items:center;gap:8px}
.profile-contact-actions .btn .ico-sm{width:16px;height:16px}
@media(max-width:640px){.profile-contact-actions .btn{width:100%;justify-content:center}}

/* Rev 71: the CEO hero H1 must stay on ONE line. The shared .hero h1 carries
   max-width:17ch (0,1,1), which wrapped the old 47-char name H1 to two lines,
   and .hero.hero-page h1 (0,2,1) sets clamp(38px,4vw,56px). This scoped rule
   (0,3,1) removes the width cap and sizes by viewport: 38 chars at 3.6vw
   (51.8px at 1440) measures about 990px against the 1072px container, and
   stays one line down to tablet. Below roughly 500px no readable size fits
   38 chars on one line, so phone wraps to two by design. */
.hero.hero-page.hero-ceo h1{max-width:none;font-size:clamp(30px,3.6vw,52px)}

/* Contact awards mini */
.awards-mini{display:flex;gap:10px;flex-wrap:wrap}
.awards-mini img{width:86px;height:auto;border-radius:10px;background:#fff;padding:5px;border:1px solid var(--line)}

/* CTA banner */
.cta-banner{background:var(--ink-900);border-radius:var(--r-xl);color:#fff;padding:64px 56px;text-align:center;position:relative;overflow:hidden}
.cta-banner .motif{position:absolute;right:-60px;bottom:-90px;width:360px;color:var(--brand-500);opacity:.16;pointer-events:none}
.cta-banner .cta-heading{color:#fff;font-family:'Plus Jakarta Sans',sans-serif;font-size:34px;font-weight:800;line-height:1.18;letter-spacing:-.015em}
.cta-banner p{color:#B9B9C2;max-width:54ch;margin:14px auto 30px}

/* Soft band (industries / hub sections) */
.band{background:var(--surface-50);border-radius:var(--r-xl);padding:64px 40px}

/* Contact */
.contact-grid{display:grid;grid-template-columns:7fr 5fr;gap:44px;align-items:start}
.form-card{background:#fff;border:1px solid var(--line);border-radius:var(--r-lg);padding:36px;box-shadow:var(--shadow-md)}
.field{margin-bottom:18px}
.field label{display:block;font-size:13.5px;font-weight:600;margin-bottom:7px;font-family:'Plus Jakarta Sans',sans-serif}
.field input,.field textarea,.field select{width:100%;border:1.5px solid #E7E7EC;border-radius:12px;padding:12px 15px;font-size:15px;font-family:'Inter',sans-serif;color:var(--ink-900);background:#fff;transition:border-color var(--dur-1) var(--ease-out),box-shadow var(--dur-1) var(--ease-out)}
.field input:focus,.field textarea:focus,.field select:focus{outline:none;border-color:var(--brand-500);box-shadow:0 0 0 3px var(--brand-50)}
.info-card{background:var(--surface-50);border-radius:var(--r-lg);padding:30px;margin-bottom:18px}
.info-card h3{font-size:16px;margin-bottom:10px;display:flex;align-items:center;gap:8px}
.info-card p{font-size:14.5px;color:var(--ink-500);margin-bottom:6px}
.info-card a{color:var(--brand-700);font-weight:600}
.form-msg{padding:14px 18px;border-radius:12px;font-size:14.5px;margin-bottom:16px;display:none}
.form-msg.ok{background:#EDF9F0;color:#1B7A3D;display:block}
.form-msg.err{background:var(--brand-50);color:var(--brand-700);display:block}

/* Case studies */
.cases{display:grid;grid-template-columns:repeat(3,1fr);gap:20px}
.case{border-radius:var(--r-lg);overflow:hidden;background:#fff;border:1px solid var(--line);transition:border-color var(--dur-2) var(--ease-out),box-shadow var(--dur-2) var(--ease-out),transform var(--dur-2) var(--ease-out);display:block}
.case:hover{box-shadow:var(--shadow-lg);transform:translateY(-3px);border-color:var(--brand-100)}
.case img{width:100%;aspect-ratio:16/9;object-fit:cover}
.case .body{padding:20px 22px}
.case h3{font-size:16.5px;font-weight:700}
.case p{font-size:13.5px;color:var(--ink-500);margin-top:6px}

/* Footer */
.site-footer{background:var(--ink-900);color:#A9A9B4;margin-top:84px;border-radius:var(--r-xl) var(--r-xl) 0 0}
.footer-main{display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:40px;padding:60px 0 44px}
/* Ask 11 (Rev 51): the client asked for bigger footer trust badges because the
   space is there, and measurement agreed - the six badges occupied 362px of a
   648px column at 1440px, using 56% of the track.
   The naive fix (bump height 52 -> 64) would have re-created the exact bug Rev
   42 fixed: a wrapping flex row needs 6x64 + 5x10 = 434px, wider than the track
   available on small tablets and large phones, so the sixth badge would strand
   alone on a second row again above the 400px clamp below. Rev 42 solved that
   by CLAMPING the height, which capped the badges on every screen to protect
   the narrowest one.
   This removes the wrap instead: a 6-column grid cannot orphan at any width,
   because there is never a second row to orphan onto. Badges then size to the
   track, so they are as large as the space genuinely allows rather than as
   large as the smallest viewport tolerates. Square cells with
   object-fit:contain also make the six read as one even row despite
   TechBehemoths being 400x467 portrait artwork, which the old height-driven
   flex row rendered 45.67px wide against its neighbours' 52px. Measured sizes
   are verified in the browser after this change, not assumed here. */
.f-badges{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:10px;margin-top:18px;max-width:460px}

.f-badges img{width:100%;height:auto;aspect-ratio:1/1;object-fit:contain;border-radius:8px;background:#fff;padding:5px;opacity:.92;box-sizing:border-box}
/* Ask 11 (Rev 51): the one badge whose artwork is an opaque dark rectangle
   (flagged `dark` in media.ts, measured there). On the light Recognition band a
   thin reveal was enough; on the --ink-900 footer a dark plate inside a 5px
   white edge reads as a hole punched in the row rather than as an award. More
   inset gives the white tile a reveal wide enough to frame it, and full opacity
   stops the dark artwork sinking further into the dark band. The linked badges
   are wrapped in a bare <a>, so the padding has to land on the <img> that
   actually paints the tile, not on the grid cell. */
.f-badges .f-plate img,.f-badges img.f-plate{padding:9px;opacity:1}
/* Rev 51: the height clamp this block used to carry is gone, because the grid
   above makes wrapping impossible and the cells already shrink with the track.
   What is still worth tightening on the narrowest phones is the GAP, which buys
   the artwork back a few px per badge.
   MUST stay AFTER the .f-badges rule above. This stylesheet resolves these by
   source order, not specificity - the same trap as its two :root blocks - and
   Rev 42's first attempt placed an equivalent block earlier, where the later
   rule silently overrode it. */
@media(max-width:400px){
  .f-badges{gap:6px}
  .f-badges img{padding:3px}
}
.site-footer .footer-heading{color:#fff;font-family:'Plus Jakarta Sans',sans-serif;font-size:14px;font-weight:700;margin-bottom:16px;letter-spacing:.04em;line-height:1.18}
.site-footer ul{list-style:none}
.site-footer li{margin-bottom:9px}
.site-footer a{font-size:14px;color:#A9A9B4}
.site-footer a:hover{color:#fff}
.f-brand .logo{color:#fff;margin-bottom:16px}
/* Rev 42: was max-width:34ch, which capped this paragraph at 306px inside a
   710px grid column and left a 444px dead gutter in the middle of the band -
   the "unbalanced design" the client objected to elsewhere, made obvious once
   the newsletter gave the right column real weight. 52ch keeps the measure
   inside the 45-75ch comfortable-reading range while letting the text occupy
   its column, so the two halves of the band now carry comparable mass. */
.f-brand p{font-size:14px;max-width:52ch;margin-bottom:6px}
.f-brand a{color:#E4B9C0}
.footer-bar{border-top:1px solid #383842;padding:20px 0;font-size:13.5px;display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap}

/* Footer newsletter + agency credit (Rev 42, asks 9 and 10).

   NOTE ON COLOUR: .site-footer is a DARK band (--ink-900). Every colour below
   is therefore an explicit light value, never an --ink-* token, which would
   render near-invisible here. Contrast measured against #25252C:
     #FFFFFF label ~14.2:1, #A9A9B4 body ~6.6:1, #E4B9C0 link ~8.3:1,
     #C9C9D2 consent text ~9.0:1  - all clear of the 4.5:1 AA floor.

   Visually-hidden utility: the email input has a real <label> for screen
   readers, hidden visually because the placeholder and the button already make
   the field's purpose obvious. The clip-path+1px technique is used rather than
   display:none so the label stays in the accessibility tree. */
.vh{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}

/* The right footer column stacks newsletter over contact (client's ask 10). */
.f-side{display:flex;flex-direction:column;gap:30px}
.f-news-lede{font-size:14px;line-height:1.55;color:#A9A9B4;margin-bottom:14px;max-width:40ch}
/* Input and button share one row on desktop and wrap to two on narrow columns.
   min-width on the input stops flexbox shrinking it below a usable size before
   the wrap kicks in - without it the field collapses to ~60px next to the
   button at the 1020px breakpoint, which is where the footer becomes 1-column. */
.f-news-row{display:flex;gap:10px;flex-wrap:wrap}
/* Ask 12 (Rev 55): the border was #4A4A55, which MEASURES 2.04:1 against the
   footer's painted #17171D and therefore failed WCAG 1.4.11, whose floor for
   the boundary of a user interface component is 3:1. That is a pre-existing
   defect, found while adding two more fields to this form; shipping two more
   controls at the same value would have tripled it. #70707E measures 3.66:1,
   which clears the floor with real margin rather than sitting on it.

   The footer background was verified by measurement, not read off the
   stylesheet: two `.site-footer` rules set different backgrounds, and the
   painted value is #17171D at 1440, 900 and 390px, so the earlier --ink-900
   rule never wins here. */
.f-news-row input,.f-news-field input,.f-news-field select{min-width:0;padding:13px 16px;border-radius:8px;border:1px solid #70707E;background:#1D1D23;color:#fff;font-family:inherit;font-size:15px}
.f-news-row input{flex:1 1 200px}
.f-news-row input::placeholder,.f-news-field input::placeholder{color:#9A9AA6}
.f-news-row input:focus-visible,.f-news-field input:focus-visible,.f-news-field select:focus-visible{outline:2px solid var(--brand-500);outline-offset:1px;border-color:var(--brand-500)}
.f-news-row .btn{flex:0 0 auto;padding:13px 24px}

/* Ask 12 (Rev 55): optional name and interest fields, side by side above the
   email row so the required field stays adjacent to the button it submits. */
/* Measured, not guessed: the newsletter column caps at 480px wide even at a
   1920px viewport, so a two-up split gives each field ~235px, leaving a 179px
   text channel. The longest option, "Custom Software Development", needs
   217px of text plus 56px of padding and border. It is derived from SERVICES
   and protected by a startup assertion, so it cannot be shortened to fit.
   Three of eight options clipped at 1440px and four at 1200px. One column is
   the only split that holds at every width: 480 - 56 = 424px of channel
   against a 246px widest option, 178px of headroom, and no breakpoint to get
   wrong later. */
.f-news-grid{display:grid;grid-template-columns:1fr;gap:10px;margin-bottom:10px}
.f-news-field{display:flex;flex-direction:column;gap:6px;min-width:0}
.f-news-field label{font-size:12.5px;line-height:1.4;color:#C9C9D2;font-weight:600}
/* "(optional)" is visible rather than implied by the absence of an asterisk.
   #9A9AA6 measures 6.41:1 on #17171D, so the qualifier is readable and not a
   decorative whisper the visitor has to hunt for. */
.f-news-opt{font-weight:400;color:#9A9AA6}
.f-news-field select{appearance:none;cursor:pointer;padding-right:38px;
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%23C9C9D2' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
  background-repeat:no-repeat;background-position:right 14px center}
.f-news-field select option{background:#1D1D23;color:#fff}
/* The former 1020px and 640px .f-news-grid overrides are gone: the grid is
   single-column at every width now, so restating it would be dead CSS that a
   later edit could mistake for a live breakpoint. */
@media(max-width:640px){.f-news-row .btn{width:100%;justify-content:center}}

/* Ask 19 (Rev 55): attention effect on Subscribe.
   A ring expands out of a pseudo-element twice per 8s cycle. It is NOT a blink:
   WCAG 2.3.1 forbids flashing more than three times a second, and 2.2.2 wants
   motion longer than five seconds to be stoppable. Two slow pulses per cycle
   clear both, the whole thing lives inside prefers-reduced-motion:no-preference
   so it does not exist for a visitor who asked for stillness, and it stops for
   good on hover, on focus and once the form has been submitted successfully.
   Only inset and opacity animate, so the button's own fill and label never
   move off their measured contrast values, and the ring is drawn outside the
   button box so it cannot cover the label.

   Rev 86: the ring used to animate `transform:scale(1.36)`, and that was the
   cause of a horizontal scrollbar on EVERY page at mobile widths. A scale is
   PROPORTIONAL to the box it is applied to, and line 765 above makes this
   button full-width below 640px, so the halo grew with the button. Measured
   halo, horizontal by vertical:

       viewport 1280   button 127px wide   halo 22.8 x 8.5    ring fits
       viewport  390   button 316px wide   halo 56.9 x 8.5    ring ends at 410
       viewport  520   button 446px wide   halo 80.3 x 8.5    ring ends at 563

   Two things are wrong with that. The overflow is the reported one: at 390 the
   ring reached 410 in a 390 viewport, the document scrolled sideways, and
   because Chrome's mobile emulation then widens the initial containing block,
   every position:fixed element with a `right` offset shifted right with it.
   That is why `.float-actions` measured its right edge at 392 instead of 376
   and looked like the culprit while actually being a victim; hiding it changed
   nothing (measured: still 406). The second problem is that a 56.9 x 8.5 halo
   is not a ring at all, it is an ellipse 5.4 times wider than it is tall.

   So the halo is now a constant 12px on all four sides, which fixes the shape
   as well as the overflow, and cannot grow with the button at any width. 12px
   clears the narrowest gutter with room to spare: at 320px the button spans
   37..287, so the ring spans 25..299 inside a 320px viewport.

   Why `inset` and not `outline-offset`, which would have been paint-only and
   cheaper: a rounded outline needs Safari 16.4+, and below that the ring would
   render as a rectangle around a pill. Keeping a real bordered box means
   `border-radius:inherit` is honoured everywhere. The layout cost is one
   out-of-flow, pointer-events:none pseudo-element whose containing block is a
   fixed-size button, so nothing else in the footer reflows, and the value only
   changes during the first 19% of the cycle - the 20%-100% keyframes are
   identical, so there is no per-frame work for the remaining 6.5s.

   Both candidates were measured before choosing, over a full 8s cycle at 360,
   390 and 520: scale reached 371 / 407 / 558, inset and outline-offset both
   held at exactly the viewport width. They were then re-sampled to confirm the
   ring still moves, because "no overflow" is also what a ring that quietly
   stopped animating would report. */
.f-news-btn{position:relative;isolation:isolate}
.f-news-btn::after{content:"";position:absolute;inset:0;border-radius:inherit;border:2px solid var(--brand-500);opacity:0;pointer-events:none;z-index:-1}
@media(prefers-reduced-motion:no-preference){
  .js .f-news-btn::after{animation:nlPulse 8s cubic-bezier(.22,.61,.36,1) infinite}
  .js .f-news-btn:hover::after,.js .f-news-btn:focus-visible::after,.js .f-news-btn.is-done::after{animation:none;opacity:0}
}
@keyframes nlPulse{
  0%{inset:0;opacity:.85}
  9%{inset:-12px;opacity:0}
  10%{inset:0;opacity:.85}
  19%{inset:-12px;opacity:0}
  20%,100%{inset:0;opacity:0}
}
/* Consent is an explicit unticked box (GDPR Art. 4(11) affirmative action).
   The 18px box plus 12px vertical padding on the label gives the control a
   >=24px activation area, satisfying WCAG 2.2 target size (2.5.8). */
.f-news-consent{display:flex;align-items:flex-start;gap:10px;margin-top:12px;padding:6px 0;font-size:13px;line-height:1.5;color:#C9C9D2;cursor:pointer}
.f-news-consent input{width:18px;height:18px;margin:1px 0 0;flex:0 0 auto;accent-color:var(--brand-500);cursor:pointer}
.f-news-consent a{color:#E4B9C0;text-decoration:underline}
.f-news-msg{font-size:13.5px;line-height:1.5;margin-bottom:10px;display:none}
.f-news-msg.ok,.f-news-msg.err{display:block}
.f-news-msg.ok{color:#8FE3C8}
.f-news-msg.err{color:#FFB3BE}
.site-footer .f-reach .footer-heading{margin-bottom:14px}
/* Ask 2 (Rev 67): the credit is the bottom line of the left-hand stack, under
   the AgileTech copyright, as the client asked.

   The stack is what makes the position hold. Rev 66 had the credit as a third
   flex child of .footer-bar with text-align:center, and space-between then
   parked it in the middle of the row - the centring the client objected to.
   Re-ordering the three siblings would not have fixed it, because space-between
   decides a middle item's position from the row's spare width, which changes
   with the viewport. Nesting the copyright and the credit inside one block moves
   that decision inside the group, so the credit is under the copyright at every
   width, with no media query and nothing to drift.

   align-items:flex-start on the bar is deliberate: the stack is now two lines
   tall and the socials one, and stretch would have left the social chips
   vertically centred against a taller neighbour rather than aligned to the top
   of the band. */
.footer-bar{align-items:flex-start}
.f-bar-left{display:flex;flex-direction:column;gap:5px;text-align:left}
/* No underline on "Horatos", at the client's request.

   That removes the link's only non-colour affordance in its resting state, so
   the underline comes BACK on hover and on keyboard focus. Without the focus
   half, a keyboard user tabbing the footer would have nothing but a colour shift
   to tell them where they are. text-underline-offset keeps the returning rule
   clear of the descenders in "Horatos" so it reads as an underline rather than a
   strikethrough through the tail of the s. */
.f-credit{text-align:left}
.f-credit a{color:#E4B9C0;text-decoration:none;text-underline-offset:3px}
.f-credit a:hover,.f-credit a:focus-visible{color:#fff;text-decoration:underline}

/* Ask 16 (Rev 48): Facebook and LinkedIn were two plain words in a text run.
   Now icon+label buttons, so they read as destinations worth clicking.
   Colours are explicit light values because .site-footer is a dark band:
   #FFFFFF label on the #303039 chip measures ~11.6:1, well clear of AA.
   On hover each takes its own platform colour, which is the strongest possible
   affordance that these are the real social profiles. Padding gives a 40px
   tall target, comfortably above the 24px WCAG 2.2 (2.5.8) minimum. */
.f-social{display:flex;gap:10px;flex-wrap:wrap}
.f-soc{display:inline-flex;align-items:center;gap:8px;padding:9px 15px;border-radius:99px;background:#303039;border:1px solid #45454F;color:#fff;font-size:13.5px;font-weight:650;line-height:1;transition:background var(--dur-2) var(--ease-out),border-color var(--dur-2) var(--ease-out),transform var(--dur-2) var(--ease-out)}
.f-soc .ico-sm{flex:none;width:16px;height:16px}
.f-soc:hover{transform:translateY(-1px);color:#fff}
.f-soc-fb:hover{background:#1877F2;border-color:#1877F2}
.f-soc-li:hover{background:#0A66C2;border-color:#0A66C2}
.f-soc:active{transform:translateY(0) scale(.97)}

/* Ask 13 (Rev 48): 24/7 reachability, sitting against the phone line it
   qualifies. Given the accent colour so it registers as a benefit rather than
   another address line. */
.f-avail{display:flex;align-items:center;gap:8px;font-size:14px;margin-bottom:10px;color:#fff}
.f-avail .ico-sm{color:#E4B9C0;flex:none}
.f-avail b{font-weight:800;letter-spacing:-.01em}

/* Reveal animation */
@media(prefers-reduced-motion:no-preference){
  /* Ask 9 (Rev 66): the reveal is a KEYFRAME animation, not a transition, and
     that choice is the whole fix. Recorded at length because the obvious
     implementation is the one that was here, and it was quietly breaking every
     hover on the site.

     THE ORIGINAL DEFECT. The reveal used to declare its entry state on
     `.js .reveal` and animate it with `transition` on the same selector. Both
     `transition` and `transform` are ordinary properties, so they went into the
     cascade and COMPETED with each element's own rules. `.js .reveal` scores
     (0,2,0); `.tile` scores (0,1,0). The reveal won. And `transition` is a
     SINGLE property whose value is the whole list, so winning did not merge
     lists, it REPLACED them: a revealed `.tile` computed
     `transition-property: opacity, transform` and nothing else, so its
     background, border-colour and box-shadow all changed INSTANTLY while the
     lift eased. A card that snaps colour and glides position in the same gesture
     is precisely the "cheap" tell this ask is about.

     WHY IT WAS INVISIBLE UNTIL NOW. Before this revision the elements declared
     `transition:.25s`, i.e. every animatable property. Losing the cascade
     therefore swapped one blanket for another and nothing looked wrong. Naming
     the properties is what exposed a bug that had been live since reveal was
     added.

     THE FIX I TRIED FIRST, AND WHY IT WAS NOT ENOUGH. Scoping the reveal's
     transition to `:not(:hover)` does hand the hovered state back: measured,
     hovering a `.tile` then computed `transform, box-shadow, border-color`, the
     element's own list. But a transition is resolved from the AFTER-change
     style, and on hover-OUT the after-change style is the REST style, which is
     still the reveal's list. Measured 60ms into the leave: `transform` easing,
     `border-color` and `box-shadow` already SNAPPED to rest. So the hover eased
     in and snapped back -- asymmetric, and arguably a worse tell than the
     original, because the eye reads the inconsistency.

     WHY A KEYFRAME FIXES IT PROPERLY. An animation carries its own timing; it
     needs no `transition` declaration and no resting `transform`, so the reveal
     contributes NEITHER property to the cascade and every element keeps its own
     transition list in BOTH directions. Only `opacity` is declared, and only to
     hold the pre-reveal state.

     WHY NO `forwards`. Animation fill would keep holding `transform` after the
     animation ends, and animations outrank normal declarations, so a held
     `transform:none` would beat `.tile:hover{transform:...}` and re-break ask 8
     exactly as the old `.js .reveal.in{transform:none}` did. With no fill the
     element falls back to its own cascade the moment the animation ends -- and
     its natural resting transform is already none, so the end state is
     identical without borrowing the property. Verified first that no element
     carrying `.reveal` declares its own `animation` or a base `transform`
     (checked against all 44 co-occurring classes), so there is nothing for the
     shorthand to clobber. */
  @keyframes revealIn{from{opacity:0;transform:translateY(18px)}to{opacity:1;transform:none}}
  /* Unrevealed: hidden, but NOT offset by a resting transform, for the reason
     above. Hovering a not-yet-revealed element must not reveal it, so this stays
     unscoped. */
  .js .reveal{opacity:0}
  .js .reveal.in{opacity:1;animation:revealIn var(--dur-3) var(--ease-out)}
}

/* Inline CTAs inside content */
.cta-inline{margin:22px auto 6px;display:flex;gap:12px;flex-wrap:wrap;justify-content:center;max-width:760px}

@media(max-width:1020px){.chipnav .container{justify-content:flex-start;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none}.chip{white-space:nowrap;flex:none}}
@media(max-width:900px){
  .hero h1{font-size:36px}.hero.hero-page h1{font-size:30px}
  .tiles,.tiles.cols-4,.tiles.cols-2,.cases,.stats,.cards-grid{grid-template-columns:1fr}
  .contact-grid{grid-template-columns:1fr}
  .footer-main{grid-template-columns:1fr 1fr}
  .center-head h2,.center-head .cta-heading{font-size:27px}.sec-head h2{font-size:24px}
  .cta-banner{padding:44px 28px}
  .band{padding:44px 22px}
  .sec-band{padding:44px 0}
  .checklist.cols-2{grid-template-columns:1fr}
  .trust-row{gap:14px}
}
@media(min-width:901px) and (max-width:1100px){.cards-grid{grid-template-columns:repeat(2,1fr)}}
@media(max-width:640px){.tiles.cols-4{grid-template-columns:1fr 1fr}}

/* ---------- Premium imagery pass (rev 3) ---------- */
/* Home split hero keeps stats full width below the grid */
.hero-home{padding-bottom:48px}
/* Rev 73 trial: this rule was `aspect-ratio:1024/560`, hard-coded to the exact
   pixel dimensions of the OLD hero photograph. Because `.hs-media img` also sets
   `object-fit:cover` (style.css:516), swapping in a 4:3 photograph did NOT change
   the box: the browser kept a 1.83:1 window and CENTRE-CROPPED the new picture,
   discharging 27% of its height off the top and bottom. Measured: box 485x265 at
   1440 against a 1.33 source. On this particular photograph that crop is the worst
   possible one, because the group of people occupies only the lower third of the
   frame and the upper half is sky and palm leaves, so the visible result was
   mostly sky with the bottom row of faces sliced off.
   Released to 4/3 so the trial shows the whole photograph and Brian judges the
   picture rather than an accidental crop of it. */
.hero-home .hs-media img{aspect-ratio:1024/560}
.hero-home .hero-split-grid{margin-bottom:40px}
/* Rev 25i: framed hero media. The photo is only 1024x560; instead of cropping
   or upscaling it, a soft brand-tinted frame panel extends the media column
   vertically so it balances the tall H1 without touching image quality. */
/* Ask 1 (Rev 57): this frame was a near-white slab (--surface-50 -> --brand-50),
   which was invisible on the old near-white hero and became a floating white
   box the moment the hero gained real colour. Measured against the deepened
   field it was 138/1000 lighter, so the eye read the FRAME rather than the
   photograph inside it.
   Replaced with a translucent white veil: it still separates the photo from the
   field, but it now takes the wash's own colour through it, so it belongs to
   the hero instead of sitting on top of it.

   SIZE (Rev 57, second pass, measured). `height:100%` plus `min-height:420px`
   plus the align-self:stretch below made this panel 481px tall around a 265px
   photograph: 55% fill, 216px of void, and an optical mat of 24px at the sides
   against 108px top and bottom. A picture frame with four different border
   widths is not a design, and it is the one thing a vision audit flagged here
   that the pixels confirmed rather than refuted.
   The panel now sizes to its picture: 485px wide at 1024x560 gives a 265px
   image, plus an even 24px mat, so the panel is ~313px and 85% filled with the
   same measurement on all four sides. Rev 25i introduced the stretch to
   "balance the tall H1", but the grid is already `align-items:center`, so the
   media column centres against the text instead of being padded out to match
   it. The source is never upscaled either way. */
.hero-home .hs-frame{display:flex;align-items:center;background:linear-gradient(160deg,rgba(255,255,255,.46),rgba(255,255,255,.20));border:1px solid rgba(255,255,255,.62);border-radius:var(--r-xl);padding:24px;backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px)}
.hero-home .hs-frame img{width:100%;height:auto;border-radius:var(--r-lg);box-shadow:0 18px 44px rgba(38,38,46,.18)}
/* Ask 21 (Rev 45): stretch ONLY when the column actually contains the frame that
   fills it. The four directory hubs (/services/ /industries/ /solutions/
   /technologies/) reuse class `hero-home` for its typography but render a bare
   <img>, with no .hs-frame. They therefore inherited align-self:stretch and grew
   to the height of the tall text column -- measured at 622.03px on /solutions/
   against an image of 401.58px -- while .hs-media::before draws its decorative
   outline on the CONTAINER, so the outline hung ~220px below the photo as an
   empty box. That is the misalignment the client reported. :has() scopes the
   stretch to the homepage's framed markup; the hubs now size to their image.

   Ask 21/1 (Rev 57): the stretch is now GONE from the homepage too, for exactly
   the reason Rev 45 removed it from the hubs. `.hs-media::before` draws its
   decorative outline on the CONTAINER, so a stretched container drags that
   outline past the bottom of the panel, and the panel itself was being padded
   from 313px to 481px to fill space the grid never needed filled. Rev 45 fixed
   the symptom on four pages and left the cause in place on the fifth. The grid
   is `align-items:center`, so both columns now centre against each other and
   the outline tracks the panel it belongs to. */
@media(max-width:900px){.hero-home .hs-frame{padding:16px}}
.hero-home .pill{margin-bottom:18px;display:inline-flex}
.hero-home .stats{margin-top:0}

/* SVG visualizations */
.viz-wrap{background:#fff;border:1px solid #F0DFE2;border-radius:var(--r-xl);padding:26px 20px;box-shadow:0 10px 30px rgba(38,38,46,.06)}
.viz{width:100%;height:auto;display:block}
.viz text,.viz tspan{font-family:'Plus Jakarta Sans','Inter',system-ui,sans-serif}
.viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:28px;align-items:stretch}
.viz-grid .viz-wrap{display:flex;align-items:center}
@media(max-width:980px){.viz-grid{grid-template-columns:1fr}}
/* Signal-flow animation for the engagement + reach diagrams (Rev 41, ask 7).

   Design rules this obeys:
   1. OFF BY DEFAULT. Every .viz-anim layer is hidden unless BOTH the wrap has
      .in (the existing reveal observer put it on screen) AND the visitor has not
      asked for reduced motion. So: no motion off-screen, none for no-JS, and the
      static diagram is exactly what shipped in Rev 40.
   2. ONE shared period (--viz-cycle) for every element, offset per element by
      --d. animation-delay only shifts the FIRST iteration, so elements sharing a
      period stay in phase forever; mixed durations would drift apart within a
      minute and the choreography would dissolve.
   3. Compositor-only properties (transform, opacity) plus stroke-dashoffset.
      Nothing here animates geometry or layout, so it cannot reflow the page -
      which matters because these two diagrams appear on ten pages. */
.viz-anim{visibility:hidden}
@media(prefers-reduced-motion:no-preference){
  .viz-wrap.in .viz-anim{visibility:visible}
  .viz-wrap.in .sig-dot,
  .viz-wrap.in .sig-ring,
  .viz-wrap.in .sig-arrive,
  .viz-wrap.in .sig-march,
  .viz-wrap.in .sig-trail,
  .viz-wrap.in .sig-node,
  .viz-wrap.in .sig-wire{
    animation-duration:var(--viz-cycle);
    animation-delay:calc(var(--d) * var(--viz-cycle));
    animation-iteration-count:infinite;
    animation-fill-mode:both;
  }
  /* A dot travelling a straight connector. Both endpoints arrive as CSS vars
     from the SVG, so the same keyframes serve every connector without any
     per-line rule. Unitless vars are multiplied by 1px to become lengths -
     inside an SVG those are user units, i.e. viewBox coordinates. */
  .viz-wrap.in .sig-dot{
    animation-name:sig-fly;
    animation-timing-function:cubic-bezier(.45,0,.55,1);
  }
  @keyframes sig-fly{
    0%{transform:translate(calc(var(--x1) * 1px),calc(var(--y1) * 1px));opacity:0}
    8%{opacity:1}
    30%{transform:translate(calc(var(--x2) * 1px),calc(var(--y2) * 1px));opacity:1}
    38%,100%{transform:translate(calc(var(--x2) * 1px),calc(var(--y2) * 1px));opacity:0}
  }
  /* An expanding ring: emitted at the source, or a landing acknowledgement.
     transform-box:fill-box + 50% origin makes the scale grow from the circle's
     own centre without hardcoding cx/cy per element. */
  .viz-wrap.in .sig-ring{
    transform-box:fill-box;transform-origin:50% 50%;
    animation-name:sig-ring;
    animation-timing-function:ease-out;
  }
  @keyframes sig-ring{
    0%{transform:scale(.82);opacity:0}
    6%{opacity:.85}
    26%{transform:scale(1.22);opacity:0}
    100%{transform:scale(1.22);opacity:0}
  }
  /* Outcome chip acknowledging its pulse. */
  .viz-wrap.in .sig-arrive{animation-name:sig-arrive;animation-timing-function:ease-out}
  @keyframes sig-arrive{
    0%{opacity:0}
    10%{opacity:.9}
    34%,100%{opacity:0}
  }
  /* Comet along a curve. pathLength="100" normalises every curve to 100 units,
     so this single 0 -> -100 sweep fits all six curves whatever their real arc
     length. Dash is 4 lit + 96 dark: exactly one visible segment at a time.
     The 4 was measured, not guessed - the curves are ~333 user units long and
     render at 0.7px per unit, so 4% is a ~9px streak that reads as a moving
     dot. The first attempt used 14, which drew a 33px arc: at that length the
     eye sees a line being drawn, not a signal travelling, which is the opposite
     of what the client asked for. */
  .viz-wrap.in .sig-wire{
    stroke-dasharray:4 96;
    animation-name:sig-wire;
    animation-timing-function:cubic-bezier(.4,0,.6,1);
  }
  @keyframes sig-wire{
    0%{stroke-dashoffset:0;opacity:0}
    6%{opacity:1}
    42%{stroke-dashoffset:-100;opacity:1}
    50%,100%{stroke-dashoffset:-100;opacity:0}
  }

  /* --- Rev 53 (ask 8): the six-step process relay ------------------------
     The client asked for "moving arrows", and the three rules above still
     hold: one shared period with per-element --d, nothing runs off-screen or
     under prefers-reduced-motion, and every animated property is either a
     transform, an opacity or a stroke-dashoffset. That last point is not
     decoration - this diagram is embedded on ELEVEN pages, so anything that
     touched geometry would have made a reflow the price of a page view.

     The percentages below are NOT free choices. The SVG builds its --d values
     from MARCH_STEP (0.145) and MARCH_FLIGHT (0.14), and a node's ping delay
     is derived as (arrow delay + MARCH_FLIGHT). So the 14% flight window in
     @keyframes sig-march MUST equal MARCH_FLIGHT, or every node would
     acknowledge an arrow that had not arrived yet. Change one, change both. */

  /* The arrow itself, travelling between two resolved translate() positions -
     the same mechanism as .sig-dot, so no per-gap rule is needed. It fades at
     both ends rather than appearing hard, because an arrow that pops into
     existence beside a node reads as a glitch, not as a handover. */
  .viz-wrap.in .sig-march{
    animation-name:sig-march;
    animation-timing-function:cubic-bezier(.42,0,.4,1);
  }
  @keyframes sig-march{
    0%{transform:translate(calc(var(--x1) * 1px),calc(var(--y1) * 1px));opacity:0}
    3%{opacity:1}
    11%{opacity:1}
    14%{transform:translate(calc(var(--x2) * 1px),calc(var(--y2) * 1px));opacity:0}
    100%{transform:translate(calc(var(--x2) * 1px),calc(var(--y2) * 1px));opacity:0}
  }
  /* The trail the arrow leaves along the spine. Drawn from the start of the
     gap and released a beat later, so the segment lights up behind the
     arrowhead and lets go - the dashed spine is never permanently redrawn.
     pathLength="100" on the element normalises every gap to 100 units, so this
     single 100 -> 0 sweep serves all five gaps whatever their real width. */
  .viz-wrap.in .sig-trail{
    stroke-dasharray:100;
    animation-name:sig-trail;
    animation-timing-function:cubic-bezier(.42,0,.4,1);
  }
  @keyframes sig-trail{
    0%{stroke-dashoffset:100;opacity:0}
    3%{opacity:.85}
    14%{stroke-dashoffset:0;opacity:.85}
    22%{stroke-dashoffset:0;opacity:0}
    100%{stroke-dashoffset:0;opacity:0}
  }
  /* A node acknowledging the arrow that just reached it. Scales from its own
     centre via fill-box, exactly as .sig-ring does, so cx/cy need not be
     repeated in CSS. It stays a STROKE and never becomes a fill: the node
     carries white 700-weight numerals on a crimson disc, and a crimson wash
     across it would collapse that text's contrast for a few frames - the same
     trap Rev 41 documented on the engagement chips. */
  .viz-wrap.in .sig-node{
    transform-box:fill-box;transform-origin:50% 50%;
    animation-name:sig-node;
    animation-timing-function:ease-out;
  }
  @keyframes sig-node{
    0%{transform:scale(.94);opacity:0}
    4%{opacity:.9}
    16%{transform:scale(1.16);opacity:0}
    100%{transform:scale(1.16);opacity:0}
  }
}
/* Period differs per diagram so the two side-by-side panels do not pulse in
   lockstep, which reads as a single flashing block rather than two systems. */
.viz-engage{--viz-cycle:5.2s}
.viz-reach{--viz-cycle:6.4s}
/* The relay needs a longer period than the engagement panel: it plays six
   events in sequence rather than three in parallel, and its own budget only
   spends 72% of the cycle (5 arrows at 0.145 apart, plus a 0.14 flight). At
   5.2s each handover would get ~0.75s, which is faster than the eye can follow
   across a 1000-unit span; 7.4s gives each arrow ~1.07s and leaves a ~2s rest
   beat before the loop repeats. That pause is deliberate - without it a
   delivery process reads as a conveyor belt that never finishes, which is the
   opposite of what the section is claiming. The value is also coprime-ish with
   5.2s and 6.4s so pages carrying more than one diagram never sync up. */
.viz-process{--viz-cycle:7.4s}

/* Stacked, full-width variant for visualizations that need more room to stay legible */
.viz-grid-lg{display:grid;grid-template-columns:1fr;gap:32px}
.viz-grid-lg .viz-wrap{padding:40px 36px}
@media(max-width:640px){.viz-grid-lg .viz-wrap{padding:20px 14px}}

/* Hub/detail split hero media: keep image modest on wide screens */
.hero-page.hero-split .hs-media img{aspect-ratio:4/3}

@media(max-width:900px){
  .hero-home .hero-split-grid{margin-bottom:28px}
  .viz-wrap{padding:16px 10px}
}
/* Mobile: wide process diagram scrolls instead of shrinking illegibly */
@media(max-width:720px){
  .viz-wrap:has(.viz-process){overflow-x:auto;-webkit-overflow-scrolling:touch}
  .viz-process{min-width:720px}
}

/* Corporate premium refinement */
:root{
  --brand-500:#D92F4A;
  --brand-700:#951B30;
  --brand-50:#FFF4F5;
  --brand-100:#F5D8DD;
  --ink-950:#17171D;
  --ink-900:#25252C;
  --ink-700:#44444E;
  --ink-500:#686873;
  --surface-50:#F6F6F8;
  /* Ask 27 (Rev 63): the ONE hairline. Every card, panel, chip and FAQ row in
     the site draws its 1px border from this token, and nothing hardcodes a
     near-white value any more.

     Why this mattered. Measuring the painted population (8 pages, 664 borders)
     found 28 sitting at a contrast ratio of 1.00-1.15 against the surface
     behind them, i.e. invisible. 26 of those were `.faq details`, which carried
     a hardcoded #EFEFF2. Walking every page in the sitemap put the real number
     far higher: 161 pages carry a FAQ, 459 rows, and 410 of them painted
     #EFEFF2 at 1.063 against the --surface-50 band they sit on. A card whose
     edge cannot be seen is not a card, it is a patch of slightly different
     white, and that is a large part of why the site read as soft rather than
     sharp.

     Why #E7E7EB and not something deeper. Measured in CIELAB against the two
     backdrops a card actually sits on (#FFFFFF fill, #F6F6F8 band):
       #F1F1F4  dL* 4.78 / 1.71   ratio 1.127 / 1.044
       #EFEFF2  dL* 5.48 / 2.41   ratio 1.148 / 1.063   <- what 410 rows had
       #E7E7EB  dL* 8.25 / 5.19   ratio 1.233 / 1.143   <- this token
       #DFDFE8  dL* 10.94 / 7.88  ratio 1.324 / 1.227
     1.233 is a hairline you can see at arm's length on a laptop panel without
     the border becoming a drawn outline; 1.148 is not. Deeper values were
     rejected for the general case because at 459 rows a visible frame starts
     to read as a form, not as a page.

     Not everything was folded in, on purpose:
       - the dark borders (#2E2E37, #303039, #383842, #45454F, #70707E) were
         measured on the dark surfaces they actually paint on, not against a
         guessed token, and came back at 1.328 or better, so they are already
         correct;
       - the rose borders (#F3E2E5, #F0DFE2) are brand-tinted, not neutral;
       - #DFDFE8 on the trust-badge wall is deliberate and is explained in the
         Ask 10 comment above `.badges>img,.badges>.badge-link`: that one wall
         needed to out-punch the band it sits on, so it takes the deeper value
         alone;
       - two `var(--line,#EFEFF2)` fallbacks stay as fallbacks.

     A page-scoped `#home-faq .faq details{border-color:var(--line)}` was
     removed at the same time. It was the trace of someone fixing the one
     instance in front of them; once the base rule uses the token it is a no-op
     that would make the next reader believe the base rule is still wrong.

     NOTE for anyone moving this: --line is declared ONLY here, and 20 of the
     rules that consume it sit ABOVE this block (L69-533). That resolves
     correctly because custom properties are inherited, not textually
     substituted, so declaration order does not matter. It is worth knowing
     because the failure mode is severe rather than subtle: an unresolved
     border-color falls back to currentColor, which would turn every hairline
     ink-coloured. Verified after the change: 1009 painted borders across 25
     pages all compute rgb(231,231,235), zero fell back. */
  --line:#E7E7EB;
  /* Ask 9 (Rev 66): SHADOW RAMP REBUILT IN TWO LAYERS.
     The client's brief was that the site reads like 1080p where competitors read
     like 4K, and explicitly NOT about image resolution -- "it's about the
     interface, the appearance you give to the website". So it was measured as
     craft variance rather than treated as taste. Two findings drove this token:

     1. All three shadows were SINGLE-LAYER. A real object casts two shadows: a
        tight, darker contact shadow where it meets the surface, and a wide, very
        soft ambient one. One layer has to be either tight or soft, so it reads
        as a flat drop shadow stuck behind a rectangle -- which is exactly the
        "sticker on a page" look that separates ordinary UI from premium UI.
     2. 28 distinct box-shadow values were in use, 13 of them one-offs.

     The layers are deliberately NOT the old value plus a second one: total
     darkness is held roughly constant so nothing on the site suddenly looks
     heavier. The contact layer is 1-2px at low alpha, the ambient layer keeps
     the previous blur and is slightly LIGHTENED to pay for the contact layer.
     Verified after the change by measuring painted luminance under cards rather
     than by eye (see tools/check_ui_craft.py).

     Blur radii stay on the old 1 : 3 : 7 progression. A ramp whose steps are not
     clearly separated is the other way shadows read as imprecise. */
  --shadow-sm:0 1px 2px rgba(23,23,29,.05),0 1px 1px rgba(23,23,29,.03);
  --shadow-md:0 1px 2px rgba(23,23,29,.05),0 10px 32px rgba(23,23,29,.06);
  --shadow-lg:0 2px 4px rgba(23,23,29,.05),0 24px 70px rgba(23,23,29,.09);
  /* Ask 9 (Rev 66): MOTION TOKENS. The site had none, and motion was measurably
     the least-crafted layer of the interface: 16 distinct duration+easing pairs,
     of which the two most common were `.2s ease` (29 uses) and a bare `.2s`
     (23 uses) that inherits the same default curve. `ease` is
     cubic-bezier(.25,.1,.25,1) -- it eases IN as well as out, so a hover starts
     slowly, which is what makes an interface feel soft and slightly late rather
     than crisp. Nothing feels expensive if it responds lazily to the cursor.

     --ease-out is a decisive decelerating curve: it leaves immediately at full
     speed and settles. That is the single highest-payoff change here, because it
     applies to every hover on the site.

     --ease-spring overshoots very slightly (the 1.14 control point) and is for
     elements that ENTER, not for hovers. Used sparingly on purpose: overshoot on
     a corporate site reads as playful the moment it is overused.

     Durations are a 3-step scale, not a continuum. The old set had .09s, .15s,
     .18s, .2s, .25s, .27s, .28s, .35s, .36s, .4s, .45s, .5s and .55s, which no
     one can hold in their head, so each new rule invented another value.
       --dur-1 120ms  micro: colour and icon swaps
       --dur-2 220ms  standard: hover lift, shadow, border
       --dur-3 420ms  deliberate: panels, reveals, anything that travels
     Kept in the 120-450ms band the brand guidelines call for, and hover stays
     fast: refinement here means precision, not more animation. */
  --ease-out:cubic-bezier(.22,.61,.36,1);
  --ease-spring:cubic-bezier(.34,1.14,.64,1);
  --dur-1:.12s;
  --dur-2:.22s;
  --dur-3:.42s;
}
body{color:var(--ink-900);line-height:1.65}
.container{max-width:1240px;padding-left:32px;padding-right:32px}
.skip-link{position:fixed;left:20px;top:-80px;z-index:1000;background:#fff;color:var(--ink-950);padding:12px 18px;border:2px solid var(--brand-500);border-radius:8px;font-weight:700;transition:top var(--dur-2) var(--ease-out)}
/* Ask 1 (Rev 66): :focus-visible, not :focus.
   :focus fires for pointer focus and for programmatic .focus() calls, which is
   why the back-to-top button used to slide this chip into view on every mouse
   click - a pop-up appearing top-left for a visitor who never touched Tab.
   :focus-visible fires only when the browser judges a focus ring warranted,
   i.e. keyboard navigation, so the chip is still the first thing a keyboard
   user reaches (the claim published in src/legal.tsx) and is now invisible to
   the mouse. Baseline in every current browser; older engines simply never
   reveal it, which is the same as the pre-JS state, not a regression. */
.skip-link:focus-visible{top:20px}
:focus-visible{outline:3px solid rgba(217,47,74,.28);outline-offset:3px}

/* Navigation becomes quieter and more authoritative */
.topbar{padding:7px 0;background:var(--ink-950);font-size:12px;letter-spacing:.01em}
.topbar .container{gap:20px;flex-wrap:nowrap}
.tb-iso{margin-left:auto}
/* Ask 27 (Rev 63): the divider was rgba(23,23,29,.07), which composites over
   the header's own 94% white to #EFEFEF and scores 1.152 against the white page
   behind it, below the 1.233 the --line token gives on the same surface. This is
   the same defect as the 410 FAQ rows, on the one element that appears on all
   173 pages, and it was found only because assertion A-abs in
   check_hairline_contrast.py compares each border against what the token would
   score on ITS OWN backdrop. No selector list contained this element, and the
   relative test could not see it: on a white page the fill step is exactly 1.000,
   so #EFEFEF cleared it by +0.152.
   The alpha form was also invisible to the "one source" assertion, which reads
   hex literals, so this is the case that proves both halves were needed. */
.site-header{top:0;margin:0;padding:0;background:rgba(255,255,255,.94);border-bottom:1px solid var(--line);backdrop-filter:blur(18px);-webkit-backdrop-filter:blur(18px)}
.navwrap{height:72px;max-width:1240px;padding:0 32px;background:transparent;border:0;border-radius:0;box-shadow:none}
.logo{height:40px;display:flex;align-items:center}
.logo img{height:40px;width:auto;object-fit:contain}
.mainnav{gap:5px}
.mainnav>li>a,.mainnav>li>button{padding:10px 12px;border-radius:8px;color:var(--ink-700);font-size:14px}
.mainnav>li>a:hover,.mainnav>li>button:hover,.mainnav>li:hover>button{color:var(--ink-950);background:var(--surface-50)}
.nav-cta{margin-left:12px}

/* ===== Ask 2 (Rev 66): make the header CTA "live" so it draws the eye =======
   The client asked for a live effect / live visualisation on "Start a
   conversation" so it pulls attention.

   There is already a house precedent for a live button: .f-news-btn (Rev 55,
   ask 19) pulses a ring out of a pseudo-element on an INFINITE loop. I did not
   copy it verbatim, for two measured reasons specific to THIS button:

   1. .f-news-btn lives in the footer, so a visitor sees it briefly at the end
      of a scroll. .nav-cta lives in `position:sticky` .site-header, so it is
      on screen for the ENTIRE visit. Perpetual movement in the corner of the
      eye for the whole session is the same class of defect the client just
      reported in ask 1 ("this is very annoying") -- an effect that keeps
      asserting itself after it has been seen. An attention effect has to spend
      attention once, not charge rent.
   2. The site publishes a WCAG 2.1 AA commitment on /accessibility/. SC 2.2.2
      requires auto-starting motion lasting MORE THAN five seconds to be
      pausable. The nlPulse comment reasons that "slow" clears it; slow is not
      the same as stoppable, so I did not extend that reasoning to a header
      element. The burst below runs 3 pulses in 3.9s and then stops for good,
      which is under the five-second threshold, so the success criterion is met
      outright rather than by argument.

   So: ONE bounded burst on load (3 pulses, then permanently still), plus a
   hover/focus sheen that answers the pointer. Attention on arrival; calm after.

   ANIMATION SAFETY
   - `animation-iteration-count:1` + `animation-fill-mode:forwards`, and the
     final keyframe is `opacity:0`, so the resting state is invisible. If the
     animation is never composited the ring simply never shows -- it cannot
     get stuck visible.
   - Only `transform` and `opacity` animate. Both are compositor properties, so
     this cannot reflow the sticky header (a width/inset animation on a
     space-between flex bar would shift the whole nav), and the button's own
     fill and label never move off their measured contrast values.
   - WCAG 2.3.1 (three flashes) is clear by a wide margin: 3 pulses over 3.9s.
   - The ring is drawn OUTSIDE the button box on z-index:-1 with
     pointer-events:none, so it can never cover the label or eat the click.
   - Ring scale is capped at 1.5 vertically but only 1.09 horizontally. Measured
     first: at the 1100px lower bound of the desktop nav the button ends just
     20px from the viewport edge, and <body> has no overflow-x:hidden, so a
     uniform 1.5x scale on a 181px-wide button would push ~45px past the edge
     and create a horizontal scrollbar. Scaling the two axes independently
     keeps the whole effect inside the viewport at every desktop width.
   - `.js` gated, so a no-JS visitor sees a normal, perfectly usable button.
   - The whole block sits inside prefers-reduced-motion:no-preference, which is
     what keeps the "Reduced motion" claim on /accessibility/ true. */
.nav-cta{position:relative;isolation:isolate}
.nav-cta::after{content:"";position:absolute;inset:0;border-radius:inherit;border:2px solid var(--brand-500);opacity:0;pointer-events:none;z-index:-1}
@media(prefers-reduced-motion:no-preference){
  .js .nav-cta::after{animation:navCtaPulse 3.9s cubic-bezier(.22,.61,.36,1) 1 forwards}
  /* Once the pointer arrives the ring has done its job: the visitor has already
     found the button. Cancelling on hover/focus also means the burst can never
     fight the :hover lift on .btn-primary. */
  .js .nav-cta:hover::after,.js .nav-cta:focus-visible::after{animation:none;opacity:0}
  /* A sheen that answers the pointer, so the button still feels "live" AFTER
     the burst has finished, but only while it is being looked at.

     The sheen travels by moving its BACKGROUND inside a box that never moves,
     rather than translating the box itself. .nav-cta cannot carry
     overflow:hidden -- that would clip the ::after ring, which has to expand
     OUTSIDE the button -- and clip-path would not help either, because clipping
     is applied before transform. So a moving box has nothing to contain it.

     Measured, tracking the pseudo-element's own matrix through the sweep on a
     181px-wide button:
       translateX version: box travels -103.6px -> +179.3px, i.e. for much of
                           the sweep its painted box lies almost entirely
                           outside the button.
       this version:       translateX stays 0.0 at every sample; only
                           background-position moves (69% -> -120%).

     Worth recording HOW this was established, because THREE of my readings here
     were instrument faults rather than page faults, and each one nearly caused a
     wrong edit:
       - A pixel probe on a 40px strip beside the button reported a 19-level
         channel difference and I first read that as the sheen leaking. A
         controlled A/B (sheen suppressed vs shipping) returned 19 vs 19, so the
         difference attributable to the sheen was ZERO: all 19 levels were the
         pre-existing .btn-primary:hover box-shadow (24px blur) and -1px lift.
       - Sampling FARTHER out (beyond the shadow's blur) returned 0 for the
         translateX version too, because by the time that box is far outside the
         button its opacity has decayed to 0.04 and the gradient's outer stop is
         transparent. The escape was real but nearly invisible, which is why a
         pixel probe was the wrong instrument and the transform matrix the right
         one.
       - A later probe reported the sheen painting 0.00% of the crop at EVERY
         sample, i.e. apparently not rendering at all. That was also the
         instrument: it called goto() between samples, so the page reloaded and
         hover was never actually established before the screenshot. Measured
         correctly, on one page with no reload, the sheen covers 21.97% of the
         crop at 40ms with a 141-level peak delta, decaying to 0 by 280ms.
     The lesson worth keeping: for an effect this small, always measure against a
     control that differs ONLY by the effect, and confirm the control is really
     in the state you think it is.

     Animating background-position keeps every painted pixel inside inset:0 by
     construction, so the escape question cannot arise again. */
  .js .nav-cta::before{content:"";position:absolute;inset:0;border-radius:inherit;pointer-events:none;opacity:0;
    background-image:linear-gradient(100deg,transparent 32%,rgba(255,255,255,.44) 50%,transparent 68%);
    background-repeat:no-repeat;background-size:260% 100%;background-position:120% 0}
  .js .nav-cta:hover::before,.js .nav-cta:focus-visible::before{animation:navCtaSheen .75s ease-out 1 forwards}
}
/* The ring is BORN slightly outside the button, not flush with it.
   First version started each pulse at scale(1,1) opacity .9, which measured as
   a near-invisible effect: at scale(1,1) the ring is exactly coincident with the
   button and it sits on z-index:-1, so its BRIGHTEST instant was completely
   hidden behind the opaque button fill, and by the time it had grown clear of
   the edge its opacity had already decayed. Measured area of changed pixels
   peaked at ~5% with the delta falling to 17 levels.
   Starting at 1.02/1.12 means the ring is already proud of the edge at full
   opacity, and holding opacity to 55% of the way through the growth keeps it
   solid while it travels. Ends at 0 so the resting state stays invisible. */
@keyframes navCtaPulse{
  0%{transform:scale(1.02,1.12);opacity:.95}
  6%{opacity:.95}
  10%{transform:scale(1.09,1.5);opacity:0}
  11%{transform:scale(1.02,1.12);opacity:.95}
  17%{opacity:.95}
  21%{transform:scale(1.09,1.5);opacity:0}
  22%{transform:scale(1.02,1.12);opacity:.95}
  28%{opacity:.95}
  32%{transform:scale(1.09,1.5);opacity:0}
  33%,100%{transform:scale(1,1);opacity:0}
}
@keyframes navCtaSheen{
  0%{background-position:120% 0;opacity:0}
  15%{opacity:1}
  100%{background-position:-120% 0;opacity:0}
}

/* Ask 20 (Rev 49): the nav now carries SIX items instead of five. Measured
   before adding it: at the 1021px lower bound of the desktop nav there was only
   60.6px of slack between the item list and the CTA, and a "Contact" item needs
   about 86px, so a sixth item would have collided. Rather than shrink type at
   all widths, the padding and gaps tighten only in the band where space is
   actually short, which leaves the roomy widths untouched. */
@media(max-width:1140px){
  .mainnav{gap:1px}
  .mainnav>li>a,.mainnav>li>button{padding:10px 9px;font-size:13.5px}
  .navwrap{padding:0 20px}
  .nav-cta{margin-left:6px}
}
.mega{top:calc(100% + 10px);border-radius:16px;border-color:var(--line);box-shadow:var(--shadow-lg)}
/* Rev 36: scoped to rail items only. A blanket `.mega a` rule here used to
   flatten the pane's pill-shaped child chips back to a 7px radius. */
.mega-railitem>a{border-radius:8px}

/* Buttons and links */
/* Ask 9 (Rev 50): every button reserves the SAME 1.5px border box, transparent by
   default. Base .btn sets border:none, so a filled .btn-primary had no border
   while every bordered secondary added its own on top of an auto height. Measured
   side by side in a hero: primary 52.75px, secondary 54.75px, and the pair sat
   1px out of vertical alignment. Reserving the box here means a variant can
   recolour the border without changing its own height, so the mismatch cannot
   come back the next time a bordered variant is added. */
.btn{border-radius:8px;padding:14px 24px;letter-spacing:-.01em;box-shadow:none;border:1.5px solid transparent}
.btn-primary{background:var(--brand-500);border-color:var(--brand-500)}
.btn-primary:hover{background:var(--brand-700);opacity:1;transform:translateY(-1px);box-shadow:0 10px 24px rgba(149,27,48,.2)}
.btn-primary:active{transform:translateY(0) scale(.98);box-shadow:none}
/* Ask 9 (Rev 50): the client asked for coloured CTA backgrounds, because white
   buttons "do not attract clicks". Measured first, and the audit split the 41
   secondary buttons into three genuinely different situations, so one blanket
   rule would have been wrong:

     - 19 sit ALONE on a dark ink-950 band (.consult-cta / .cta-banner). These
       are the real ask. Nothing competes with them, so they take the brand fill.
     - 20 sit on white NEXT TO an existing red .btn-primary. Filling these red
       too would put two identical primaries side by side and destroy the very
       hierarchy that makes the red one clickable, so they become a bordered
       brand-coloured secondary instead: clearly coloured, clearly subordinate.
     - 2 stand alone on a light band, so they get the full brand fill.

   The old .btn-soft border was #E7E7EB on white = 1.23:1, which FAILS WCAG
   1.4.11 (3:1 for the visual boundary of a control). So the white button was not
   only weak commercially, it was non-conformant. */

/* Standalone secondary on a light band: nothing competes, so take the fill. */
.btn-soft{background:var(--brand-500);color:#fff;border-color:var(--brand-500)}
.btn-soft:hover{background:var(--brand-700);border-color:var(--brand-700);color:#fff;transform:translateY(-1px);box-shadow:0 10px 24px rgba(149,27,48,.2)}
.btn-soft:active{transform:translateY(0) scale(.98);box-shadow:none}

/* Paired secondary: coloured, but deliberately NOT a second red fill.
   Border and label are brand-500/brand-700 (4.71:1 and 8.48:1 on white), so the
   control boundary now passes 1.4.11 with room to spare.

   Selector is deliberately CONTAINER-AGNOSTIC. Scoping it to `.ctas` looked
   correct, but `.cta-inline` is a second button group emitted by renderBlock for
   the 'cta' content type, which appears in 150 content JSON files. It happens to
   render on zero pages today, so a `.ctas`-only rule would have passed every
   test and then silently produced two competing red primaries the first time a
   cta block was enabled. `.btn-primary~.btn-soft` matches the RELATIONSHIP
   instead of the wrapper, so it holds in any container. (Every generator emits
   the primary first -- `i === 0 ? 'btn-primary' : 'btn-soft'` -- so the
   subsequent-sibling combinator is the right direction.) */
.btn-primary~.btn-soft{background:#fff;color:var(--brand-700);border-color:var(--brand-500);box-shadow:0 1px 2px rgba(23,23,29,.04)}
.btn-primary~.btn-soft:hover{background:var(--brand-500);color:#fff;border-color:var(--brand-500);transform:translateY(-1px);box-shadow:0 10px 24px rgba(149,27,48,.18)}
.btn-primary~.btn-soft:active{transform:translateY(0) scale(.98);box-shadow:none}

/* On the dark bands the hover fill is squeezed from BOTH sides: the white label
   needs 4.5:1 against the fill (so it cannot lighten) and the fill needs 3:1
   against the #17171D band (so it cannot darken). brand-700 is only 2.11:1
   against that band and FAILS, which is why this hover cannot reuse the
   btn-primary hover. #C52942 is brand-500 interpolated 30% toward brand-700, so
   it is the same red, and measures band 3.20:1 with a 5.57:1 white label. */
.btn-white{background:var(--brand-500);color:#fff;border-color:var(--brand-500)}
.btn-white:hover{background:#C52942;border-color:#C52942;color:#fff;transform:translateY(-1px);box-shadow:0 10px 26px rgba(0,0,0,.34)}
.btn-white:active{transform:translateY(0) scale(.98);box-shadow:none}
.btn-sm{padding:11px 18px;font-size:13.5px}
.text-link{display:inline-flex;align-items:center;gap:9px;font-family:'Plus Jakarta Sans',sans-serif;font-weight:700;font-size:14.5px;color:var(--ink-900);padding:10px 0;border-bottom:1px solid var(--ink-900)}
.text-link span{transition:transform var(--dur-2) var(--ease-out)}
.text-link:hover{color:var(--brand-700);border-color:var(--brand-700)}
.text-link:hover span{transform:translateX(4px)}

/* Editorial hero */
/* Ask 1 (Rev 46): this rule used to be a flat `background:#fff`, which silently
   cancelled the brand radial gradient declared at the top of this file (same
   specificity, later in source order, so it won). The hero therefore rendered
   pure white and the client read it as "too pale" against the branding.
   Restored as a brand wash, deepened deliberately: the active --brand-50 token
   is #FFF4F5, only 4 points off white, so reusing it would have been invisible.
   These stops are mixed from --brand-500 (#D92F4A) at low alpha instead, which
   ties the wash to the brand red itself and stays light enough to keep
   near-black body text at full AA contrast. */
/* Ask 1 (Rev 57): the client raised "hero too pale" a SECOND time, after Rev 46
   had already rebuilt this rule. Measured rather than re-argued: Rev 46's
   strongest painted stop was #F6EBED, 20/255 from white, decaying to pure
   #FFFFFF by 75% down the band. That is a whisper, and the client was right.

   Why the previous attempt could not have succeeded. The wash sits behind the
   hero sub paragraph, which was --ink-500 #686873. Compositing --brand-500
   over white and testing each step shows --ink-500 crosses below the WCAG
   1.4.3 floor of 4.5:1 at brand alpha 0.135. Rev 46 was already at ~0.10. The
   background had roughly three points of headroom left; there was no version
   of "deepen the tint" that could satisfy the ask, because the PALEST TEXT was
   the binding constraint, not the tint.

   So the fix moves the constraint. The hero sub alone (not the global token)
   drops to --ink-700 #44444E, which lifts the ceiling from alpha 0.135 to
   0.30. The wash is set at 0.22 at its deepest, well inside that, giving 46/255
   from white against the old 20, and the sub paragraph gets MORE readable in
   the bargain: 4.72:1 before, 6.90:1 now.

   The band also no longer decays to pure white before it ends; it holds a
   trace of brand to the boundary so the hero reads as a coloured field rather
   than as a gradient that gave up.

   GRADIENT DIRECTION (second pass, measured). The first version of this wash
   put its deepest stop at 0% and produced a 306/1000 single-row luminance
   cliff against the white header, measured by sampling every row across the
   boundary. A vision audit called it "a harsh, unnatural line" and the pixels
   agreed, so the ramp is inverted: the band now STARTS close to the header's
   white and deepens as it descends, which is also the physically sensible
   reading of a light source above the fold. The deepest stop moved to the
   lower half, where the ISO seals and the stat rail sit and where a coloured
   field does the most work.

   PIGMENT AND ALPHA (third pass, and this is the one that holds). Three things
   were wrong with the pass above, all found by measuring the rendered page.

   1. The ceiling was computed against the WRONG BACKGROUND. Each foreground was
      tested against the DEEPEST stop, but the sub paragraph renders at 46% down
      the band and the H1 at 24%: they never touch the deepest colour. Testing
      every element against a stop it never meets imposed a limit no pixel
      imposes. Re-derived by reading each text node's colour, size, weight and
      y-position off the live page and evaluating it against the colour the ramp
      actually produces at ITS row, plus a 15% deeper row for reflow headroom.
      That moves the legal ceiling from alpha 0.30 to 0.505.

   2. The alpha was then chosen by MARGIN, not by ceiling. Sitting just under a
      ceiling is Rev 53 rule B's coin flip and leaves nothing for the next
      revision. At 0.47 the stat numerals run +0.27 over their floor; at 0.32
      the tightest element in the whole hero clears by +1.01 and the field is
      still 3.3x darker than the version the client rejected. 0.32 it is.

   3. The PIGMENT was the actual cause of the "candy" reading, not the amount of
      it. A pale tint of the bright --brand-500 #D92F4A is bubblegum; the same
      colourfulness carried by the deep --brand-700 #951B30 is rose. Switching
      pigment drops measured chroma from 54 to 38 while luminance goes the other
      way, 405 -> 495 points below white. Deeper AND less sweet, which the
      earlier attempt treated as mutually exclusive.

   Measured clearances at the deepest stop #D9B3BA: H1 accent 4.01:1 (floor 3.0),
   H1 ink 12.91:1, sub 7.06:1 (floor 4.5), Why-AgileTech link 10.24:1, stat
   numeral 4.69:1 (floor 3.0), stat label 8.05:1.

   ONE GRADIENT, NOT TWO, and this was a real bug caught by the verifier rather
   than a tidy-up. The first application of the arithmetic above kept the
   inherited radial gradient AND baked the same pigment into the linear stops,
   so --brand-700 was composited TWICE. The field painted #CD98A1 at chroma 53
   and 619 luminance points below white, where the derivation said #D9B3BA at
   chroma 38 and 495 points: 25% darker than the accessibility sheet covered,
   and back above the chroma ceiling the whole tone argument rested on. Summing
   two curves also produced a local slope neither had alone, 56.6/1000 inside a
   20px window, which is a visible band. A screenshot read as "deeper, good"
   would have shipped both defects.

   Collapsed to a single vertical ramp, for a reason beyond the arithmetic: a
   radial makes the field vary HORIZONTALLY, so one sentence sits on several
   different colours across its own width, every contrast figure becomes
   x-dependent, and the left-gutter sampling that every measurement in this
   revision relies on stops being representative of the row. A vertical ramp is
   uniform across each row, so a reading taken in the gutter is true for the
   whole row.

   Stops every 8% (74px at 1440 wide) hold the worst local slope to 13.3/1000
   per 20px against the guard's 25 threshold, over a total rise of 495. */
.hero{background:linear-gradient(180deg,
  #FFFFFF 0%,
  #FEFDFD 8%,
  #FCF9F9 16%,
  #F9F3F4 24%,
  #F7EEF0 32%,
  #F4E9EB 40%,
  #F0E2E5 48%,
  #EDDCDF 56%,
  #EAD5D9 64%,
  #E6CED2 72%,
  #E3C6CB 80%,
  #DFBFC4 88%,
  #DBB7BD 96%,
  #D9B3BA 100%)}
.hero::before{background-image:linear-gradient(rgba(23,23,29,.025) 1px,transparent 1px),linear-gradient(90deg,rgba(23,23,29,.025) 1px,transparent 1px);background-size:64px 64px;mask-image:linear-gradient(to bottom,#000,transparent 88%);-webkit-mask-image:linear-gradient(to bottom,#000,transparent 88%)}
.hero-split{padding:76px 0 56px}
.hero-split-grid{grid-template-columns:minmax(0,1.03fr) minmax(420px,.97fr);gap:72px}
.hero h1{font-size:clamp(44px,5vw,68px);line-height:1.05;letter-spacing:-.04em;font-weight:800;max-width:13ch}
.hero h1 em{background:none;color:var(--brand-500)}
/* --ink-700, not --ink-500: see the wash note above. This is what buys the
   background its depth, and it reads better on the deeper field than the
   lighter grey did on the paler one. */
.hero .sub{font-size:17px;line-height:1.7;max-width:54ch;color:var(--ink-700);margin:24px 0 30px}
.hero .ctas{gap:26px;align-items:center}
/* Ask 1 fallout (Rev 57), and this was NOT in the brief or in any audit.
   Deepening the hero to satisfy the client silently weakened the one control
   the whole page exists to get clicked. Measured: #D92F4A reads 4.71:1 on
   white, 3.48:1 where the button actually renders on the new wash, and 2.90:1
   against the band's deepest stop, which is BELOW the WCAG 1.4.11 floor of
   3:1 for a control boundary. Nobody asked about it and it outranks every
   aesthetic note in this revision, because a CTA that dissolves into its
   background is a commercial defect as well as an accessibility one.

   The fill is not touched: Rev 50 made #D92F4A the sitewide CTA colour for
   ask 9, and re-tinting it only here would fracture that answer. 1.4.11 is
   satisfied by the boundary OR the fill, and Rev 50 already reserved a 1.5px
   border box on every .btn precisely so a variant could recolour its edge
   without changing its own height. So the edge does the work: --brand-700
   measures 4.48:1 against the deepest stop and 8.19:1 at the top, holding
   comfortably across the entire band.

   Scoped to .hero deliberately. The hero is where the field was measured; the
   same ring applied sitewide would restyle 41 buttons that Rev 50 tuned
   individually against their own backgrounds, which is a change no measurement
   here supports. */
.hero .btn-primary{border-color:var(--brand-700)}
/* Ask 2 (Rev 46): the ISO line was 13px --ink-500 body text, the lightest
   treatment on the page, so the site's two hardest-won credentials read as a
   footnote. These are real audited certifications, so they now get badge
   treatment: a white chip with a brand hairline, the shield in brand red, and
   the standard numbers set in a tabular-figure weight so "9001:2015" and
   "27001:2013" scan as identifiers rather than prose. Kept as ONE row of two
   chips rather than enlarged text, so it gains weight without competing with
   the H1 above it. */
/* Ask 2 (Rev 46): was a single 13px --ink-500 run of text, the lightest thing in
   the hero. Now two self-contained credential chips. Weights raised after a
   vision audit judged the first pass "entirely like a small, low-contrast
   footnote ... extremely thin borders and weak shadows, making them feel flat":
   border went brand-100 -> brand-300-equivalent, the shadow doubled, and the
   numerals moved up to 14.5px so the standard reads before the label does.
   A second audit then called the red-tinted shadow "a heavy, pinkish-red glowing
   halo ... amateurish", so weight now comes from a neutral ink shadow plus a
   solid (not translucent) border, which reads as a crisp edge rather than glow. */
.hero-trustline{display:flex;align-items:center;justify-content:center;flex-wrap:wrap;gap:10px;margin-bottom:26px;color:var(--ink-700);font-size:13px;font-weight:600}
.hero-trustline .ico-sm{color:var(--brand-500)}
.hero-split .hero-trustline{justify-content:flex-start}
/* Ask 2 (Rev 57): raised a THIRD time, so this is a change of kind rather than
   another change of degree. Rev 46 and its follow-up both kept the same idea (a
   white pill) and adjusted border thickness, shadow weight and numeral size;
   the client rejected both, and a third nudge of the same idea would have been
   the definition of not listening.

   A white pill was always going to lose here. It is the same value as the page
   it sits on, so it can only be found by its 1px edge, and on the deeper Rev 57
   wash a white fill reads as a hole rather than as an object.

   Inverted to a solid ink seal. Measured on the deepest wash stop #F7D2D8:
   the seal is 12.89:1 against the field where the brand-red primary CTA is
   3.40:1, so the credential is now the highest-contrast object in the hero
   while remaining a different HUE from the CTA. Red keeps meaning "do this",
   dark means "this is verified", and the two no longer compete for the same
   job. Label white is 17.85:1 inside the seal and the shield stays --brand-500
   at 3.79:1, clear of the 3:1 non-text floor. */
.iso-chip{display:inline-flex;align-items:center;gap:10px;padding:10px 18px;background:var(--ink-950);border:1px solid var(--ink-950);border-radius:99px;box-shadow:0 1px 2px rgba(23,23,29,.10),0 6px 18px rgba(23,23,29,.16);color:#fff;font-size:13px;font-weight:600;letter-spacing:-.005em;line-height:1.2;white-space:nowrap}
.iso-chip .ico-sm{color:var(--brand-500);flex:none}
.iso-chip b{font-weight:800;font-size:14.5px;font-variant-numeric:tabular-nums;letter-spacing:-.015em;color:#fff}
/* "Certified" is the qualifier, not the credential, so it steps back one level
   while still clearing 4.5:1 (#C9C9D2 on #17171D is 10.85:1). */
.iso-chip i{font-style:normal;color:#C9C9D2;font-weight:600}
/* Rev 65, ask 5: `.iso-lead` and `.iso-suffix` are RETIRED. IsoTrustline no
   longer accepts a lead or a suffix, so no element can carry these classes.
   The rules are deleted rather than left in place, because a dead rule is how a
   retired pattern gets reintroduced: the next person adds the span, sees it
   already styled, and concludes it is supported. Do not re-add them. */
@media(max-width:520px){.iso-chip{padding:8px 13px;gap:7px}.iso-chip b{font-size:13.5px}}
.hs-media{position:relative}
.hs-media::before{content:"";position:absolute;inset:-14px 18px 18px -14px;border:1px solid var(--brand-100);border-radius:20px;z-index:-1}
/* Rev 75: the `.hs-media::after` dot-grid ornament is DELETED, with its two
   `display:none` overrides (.hero-services here and .hero-directory below),
   which this deletion makes dead.

   Removed rather than merely hidden on the homepage because a census of one
   route per template shape found it rendering on the HOMEPAGE ONLY: 8 templates
   already suppressed it through those two overrides and 8 more have no
   `.hs-media` at all. So the rule had exactly one live consumer, and keeping a
   shared rule plus two suppressions to serve one page is worse than not having
   it. Brian asked for it gone after Rev 74 measured it crossing the hero
   photograph's right edge by 39px at 1600/1440/1280/1024 alike, where it landed
   on people rather than on the blank office wall it was designed against.

   `.hs-media::before` (the offset outline) is deliberately KEPT: it sits behind
   the frame at z-index -1, does not cross the picture, and was not in the ask.

   Nothing depends on the removal: the wash and purity tools reference
   `.hs-media::after` only in `display:none !important` blocks used to clear
   decoration before sampling background colour, so their selectors now simply
   match nothing. Verified by re-running check_hero_wash. */
.hs-media img{border-radius:16px;box-shadow:var(--shadow-lg);aspect-ratio:4/3}
.hero-home .hero-split-grid{margin-bottom:54px}
.hero-home .stats{border-top:1px solid var(--line);border-bottom:1px solid var(--line)}
.stats{gap:0;margin-top:0}
.stat{position:relative;text-align:left;border:0;border-right:1px solid var(--line);border-radius:0;padding:30px 34px;background:transparent;box-shadow:none}
.stat:last-child{border-right:0}
.stat b{font-size:35px;color:var(--ink-950);line-height:1}
.stat span:last-child{font-size:13px;color:var(--ink-500);margin-top:8px}

/* Section hierarchy */
.section{padding:104px 0}
.section.tight{padding:72px 0}
.section-head{margin-bottom:46px}
.split-head{display:grid;grid-template-columns:1fr 1fr;gap:60px;align-items:end}
.split-head>div{max-width:600px}
.split-head>p{max-width:480px;justify-self:end;color:var(--ink-500);font-size:16px;margin-bottom:4px}
.stack-head{max-width:640px}
.stack-head p{color:var(--ink-500);font-size:16px;margin-top:12px}
.section-head h2,.center-head h2,.center-head .cta-heading{font-size:clamp(30px,3.2vw,44px);font-weight:800;letter-spacing:-.03em}
.center-head{max-width:720px;margin-bottom:48px}
.center-head p{font-size:16px}
/* Balance centred standfirsts instead of letting them wrap ragged.
   Measured on the legal pages, the "Still have a question?" paragraph broke
   75ch / 83ch / 15ch: an 83-character line well past comfortable measure,
   followed by a 15-character orphan ("have you guess.") on its own third line.
   Under centred text a short last line is especially conspicuous, because it
   sits isolated in the middle of the column.
   `text-wrap:balance` asks the browser to even out the lines rather than fill
   each one greedily. It degrades silently to normal wrapping where unsupported,
   so nothing depends on it, and the max-width below is what guarantees a sane
   measure either way. */
.center-head p{max-width:62ch;margin-left:auto;margin-right:auto}

/* =====================================================================
   Asks 4 and 5 (Rev 66): THE WIDOW RULE. One declaration, site-wide.

   The client reported two: the "Our Services" intro ending on a line holding
   only "way." and the "Our Solutions" intro ending on only "rebuild." He called
   the first a very serious design mistake and asked, on the second, that the
   rule be written down rather than the sentence patched. This is that rule; its
   prose half is AGENTS.md rule 16.

   WHY NOT EDIT THE TWO SENTENCES. A widow is not a property of the copy, it is a
   property of the RENDERED line boxes, so it moves with the viewport. Measured
   on the homepage with tools/measure_widows.py, which reads real line boxes via
   per-word Range rects rather than counting characters:

       1440px  5 widows / 34 prose blocks
       1024px  8 widows / 34
        390px  7 widows / 34

   and the MEMBERS differ per width - "way." is a widow at 1440 and 1024 but not
   at 390, and 390 has entirely different ones. Rewriting two sentences would
   have fixed one viewport and left the other two, while looking finished.

   WHY `pretty` AND NOT `balance`. Both were measured, same instrument, A/B on
   the same build:

       pretty   1440: 5->0    1024: 8->1    390: 7->1   total line count UNCHANGED
       balance  fails on "rebuild.", and worse, IS ITSELF A CAUSE: at 390px the
                `balance` already on .center-head p was producing the widows
                "standards." and "project." Chromium stops balancing past a
                handful of lines, so on a long paragraph the declaration goes
                quiet and the greedy last line comes back.

   `pretty` is the right tool because it only adjusts the last few lines to avoid
   a short one, and the measurement confirms the thing that matters commercially:
   the total line count is identical at all three widths, so nothing below moves
   and no card grows. `balance` is retained on HEADINGS (h1-h4, line 42), which
   are short enough for it to work on and are a different population.

   One survivor at 1024 and 390 ("releases.", a 9-character word on a 4-line
   card body). `pretty` is a hint, not a guarantee, and the honest claim is
   "removes 5 of 5, 7 of 8 and 6 of 7" rather than "eliminates widows".
   check_widows.py holds the site to the measured figure so it cannot drift back.

   Degrades silently to normal wrapping where unsupported. The max-width above
   is what guarantees a sane measure either way, so nothing depends on this. */
.section-head>p,
.center-head>p,
.hero .sub,
.sec-body>p,
.mc-body>p,
.prose-block>p,
.cs-copy>p{text-wrap:pretty}
/* Ask 6 (Rev 47): was `text-align:right`, which the client called out directly
   ("right-aligned looks very ugly"). Centred with flexbox rather than
   text-align, because the child is now a pill button and flex centring also
   survives the mobile override below (text-align:left used to undo it there).
   The second half of the ask was that these must STAND OUT: they were bare
   underlined text links, visually the weakest element in each section. Promoted
   to an outlined brand pill -- outlined rather than solid red so it stays a
   secondary action and does not compete with the primary "Start a conversation"
   CTA, while still reading unmistakably as a button. */
.section-action{display:flex;justify-content:center;margin-top:34px}
.btn-outline{display:inline-flex;align-items:center;gap:10px;background:#fff;color:var(--brand-700);border-color:var(--brand-500);box-shadow:0 1px 2px rgba(23,23,29,.04)}
.btn-outline span{transition:transform var(--dur-2) var(--ease-out)}
.btn-outline:hover{background:var(--brand-500);color:#fff;border-color:var(--brand-500);transform:translateY(-1px);box-shadow:0 10px 24px rgba(149,27,48,.18)}
.btn-outline:hover span{transform:translateX(4px)}
.btn-outline:active{transform:translateY(0) scale(.98);box-shadow:none}

/* Premium service and industry cards */
.tiles{gap:16px}
.services-grid{grid-template-columns:repeat(3,1fr)}
/* Ask 9 (Rev 66): `background-color` belongs in this list even though
   `.tile:hover` two lines below sets `background:#fff` on an already-white tile
   and changes nothing. It is the DARK variants that need it: `.band .tile:hover`
   and `.industry-full .tile:hover` take the fill from #202027 to #27272F. Those
   rules are more specific, so they win the background, but `transition` is
   declared once HERE and a hover rule does not add to it -- so an omission here
   makes every dark tile snap its fill while its lift eases. Caught by measuring
   the computed background 60ms into a hover, not by reading this rule, which on
   its own looks complete. */
.tile{position:relative;background:#fff;border:1px solid var(--line);border-radius:12px;padding:32px;min-height:240px;overflow:hidden;transition:transform var(--dur-2) var(--ease-out),box-shadow var(--dur-2) var(--ease-out),border-color var(--dur-2) var(--ease-out),background-color var(--dur-2) var(--ease-out)}
.tile::after{content:"";position:absolute;left:0;bottom:0;width:0;height:3px;background:var(--brand-500);transition:width var(--dur-2) var(--ease-out)}
.tile:hover{background:#fff;border-color:#D5D5DB;box-shadow:var(--shadow-md);transform:translateY(-4px)}
.tile:hover::after{width:100%}
.tile:active{transform:translateY(-1px) scale(.99)}
/* Ask 7 (Rev 52): the icon chip grows 44 -> 58px and the glyph 22 -> 28px.
   The size was set by MEASURING the system, not by taste. The chip sat in a
   381x223px card whose content column is 317px wide (padding 32px a side), so
   at 44px it filled 13.9% of that column while the SAME design system gives a
   `.card .ic` 46px inside a 193px column -- 23.8%. The industry family had
   simply drifted small relative to every other icon on the site, which is what
   "icons too small" was reporting. 58px restores it to 18.3% and 1.74x the
   area. It is deliberately NOT bigger than that: the client asked for balance,
   not bulk ("can bang design nhung van sang trong"), and 64px would have put
   the chip at 20.2%, wider than the 17px heading is tall by more than 3x and
   heavy enough to compete with the card's own title.
   The glyph goes to 28px so the chip keeps a 15px optical inset instead of
   growing into an empty plate. stroke-width stays at the shared 2 (a viewBox-24
   glyph painted at 28px renders a 2.33px stroke, up from 1.83px), which is what
   actually reads as "sharper" rather than merely larger -- ask 27's concern.
   Radius tracks the box (9 -> 13px) so the corner curvature stays proportional;
   holding 9px on a 58px box would have made the chip read as a hard square. */
.tile .ic{width:58px;height:58px;border-radius:14px;background:var(--brand-50);color:var(--brand-700);box-shadow:none;transition:background var(--dur-2) var(--ease-out),color var(--dur-2) var(--ease-out)}
.tile .ic svg{width:28px;height:28px}
.tile:hover .ic{background:var(--brand-500);color:#fff}
/* Rev 84: `.tile h2` rides alongside `.tile h3` because the /legal/ hub promoted
   its six tile titles from h3 to h2 to close the site's only heading-level skip.
   A bare <h2> would otherwise take the UA default 1.5em and its own top margin,
   so both the size and the margin are restated here, not just the size. */
.tile h3,.tile h2{font-size:17px;margin:0 0 10px}
.tile p{font-size:13.5px;line-height:1.6}
.tile .more{position:absolute;bottom:28px;left:32px;color:var(--ink-900)}
.band{background:var(--ink-950);color:#fff;border-radius:16px;padding:70px 56px}
.band .split-head>p,.band .tile p{color:#AFAFBA}
.band .tile{background:#202027;border-color:#303039;min-height:190px}
.band .tile:hover{background:#27272F;border-color:#454550;box-shadow:none}
/* Ask 7 (Rev 52), unreported defect found while measuring: the dark tile chip
   NEVER responded to hover. `.band .tile .ic` is specificity (0,3,0) and beats
   `.tile:hover .ic` at (0,2,1), so the hover rule lost even though it comes
   later in source order -- the same specificity-over-source-order trap that bit
   the badge fix in Rev 51. Measured, not inferred: on the dark tile the chip
   reported bg rgb(45,45,53) / colour rgb(242,165,176) BOTH at rest and while
   hovered, while a light `.card .ic` on the same site correctly swapped to the
   brand gradient with a white glyph. Half the site's tiles had a dead
   interaction state and nothing flagged it: no console error, no build warning.
   The hover pair below is written at the same (0,3,0) weight so it can win, and
   it uses --brand-500 rather than the light theme's gradient: measured against
   the hovered tile #27272F, brand-500 gives 3.14:1 (clears the 3:1 non-text
   floor of WCAG 1.4.11) with a 4.71:1 white glyph on top, whereas brand-700
   would have given only 1.75:1 against the tile and effectively vanished. */
.band .tile .ic{background:#2D2D35;color:#F2A5B0}
.band .tile:hover .ic{background:var(--brand-500);color:#fff}
.band .tile h3{color:#fff}

/* Content pages */
.hero.hero-page{padding:68px 0 58px;background:linear-gradient(180deg,var(--surface-50),#fff)}
.hero.hero-page h1{font-size:clamp(38px,4vw,56px)}
/* Centre the standfirst under a centred H1 on CENTRED page heroes.
   `.hero .sub` earlier in this file sets `margin:24px 0`, overriding the
   `margin:22px auto` from the base rule, so the paragraph kept text-align:center
   INSIDE a 594px block that was itself pinned to the left edge. Measured on the
   legal pages: 32px of space to its left and 614px to its right. The words
   looked centred, the block did not, which is what read as unbalanced.
   Scoped to `.hero-page:not(.hero-split)` so the deliberately left-aligned
   split heroes (homepage and the hub pages with a photo beside the copy) are
   untouched, and asymmetric by intent stays asymmetric. */
.hero.hero-page:not(.hero-split) .sub{margin-left:auto;margin-right:auto}

/* Legal hero: centre the whole stack, not just the words inside each block.
   `.hero-legal` was already on the markup of both legal templates but had no
   rule anywhere in this file, so it was a dead class. It is the correct hook,
   because these pages want a genuinely symmetrical hero: breadcrumb, H1,
   standfirst and the "Last updated" line all sharing one centre line.
   The H1's `max-width:13ch` from the base .hero rule has to be released too,
   otherwise a centred title wraps after two or three words. */
.hero-legal{text-align:center}
.hero-legal h1{max-width:22ch;margin-left:auto;margin-right:auto}
.hero-legal .crumbs{justify-content:center}
.hero-legal .sub{margin-left:auto;margin-right:auto}
.crumbs{justify-content:flex-start;margin-bottom:24px}
.chipnav{position:sticky;top:72px;z-index:30;border-color:var(--line);box-shadow:0 8px 20px rgba(23,23,29,.04)}
.chip{border-radius:8px;background:#fff}
.sec-band{padding:84px 0}
.sec-band.alt{background:var(--surface-50)}
.sec-head{text-align:left;margin:0 0 40px;max-width:820px}
.sec-head h2{font-size:clamp(28px,3vw,40px);letter-spacing:-.03em}
.cards-grid{gap:16px}
.card,.step{border-radius:12px;box-shadow:none;border-color:var(--line)}
.card:hover,.step:hover{box-shadow:var(--shadow-md)}
.faq details{border-radius:10px}

/* Proof and conversion */
.cta-banner{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:52px;text-align:left;border-radius:16px;padding:64px;background:var(--ink-950)}
.cta-banner .cta-heading{font-size:clamp(30px,3vw,42px)}
.cta-banner p{margin:14px 0 0;max-width:60ch}

/* Forms and footer */
.form-card{border-radius:12px;box-shadow:none;padding:40px}
.field input,.field textarea,.field select{border-radius:8px;padding:13px 14px}
.info-card{border-radius:10px;border:1px solid var(--line)}
.site-footer{border-radius:0;margin-top:104px;background:var(--ink-950)}
.footer-main{padding:72px 0 52px}
.f-brand .logo{background:#fff;border-radius:8px;padding:9px 14px;width:max-content;height:auto}
.f-brand .logo img{height:34px;width:auto}

@media(max-width:1020px){
  .navwrap{padding:0 24px}
  .topbar .container{justify-content:space-between}
  .tb-iso{margin-left:0}
  .hero-split-grid{grid-template-columns:1fr;gap:44px}
  .hero-split{text-align:left}
  .hero-split .ctas,.hero-split .crumbs{justify-content:flex-start}
  .hs-media{max-width:760px}
  .services-grid{grid-template-columns:repeat(2,1fr)}
  .chipnav{top:72px}
}
@media(max-width:760px){
  .container{padding-left:20px;padding-right:20px}
  .topbar .container{justify-content:flex-end}
  .topbar .tb-item:first-child,.topbar .tb-iso{display:none}
  .navwrap{height:64px;padding:0 20px}
  .logo,.logo img{height:34px}
  .mobile-nav{left:12px;right:12px;border-radius:12px}
  .hero-split{padding:54px 0 38px}
  .hero h1{font-size:42px;max-width:14ch}
  .hero .sub{font-size:16px}
  .hero .ctas{align-items:flex-start;gap:14px;flex-direction:column}
  .hero-trustline{align-items:flex-start}
  .hero-home .stats{grid-template-columns:1fr}
  .stat{border-right:0;border-bottom:1px solid var(--line);padding:22px 4px}
  .stat:last-child{border-bottom:0}
  .section{padding:72px 0}
  .section.tight{padding:54px 0}
  .split-head{grid-template-columns:1fr;gap:16px}
  .split-head>p{justify-self:start}
  .services-grid,.tiles.cols-4{grid-template-columns:1fr}
  .tile{min-height:220px}
  .band{padding:48px 22px}
  .cta-banner{grid-template-columns:1fr;padding:42px 26px;gap:28px;align-items:start}
  .cta-banner .btn{justify-self:start}
  /* Ask 6 (Rev 47): this used to be `text-align:left`, which cancelled the
     centring on mobile. Centring is now correct at every width. */
  .section-action .btn-outline{width:100%;justify-content:center}
  .sec-band{padding:60px 0}
  .footer-main{grid-template-columns:1fr}
}
@media(prefers-reduced-motion:reduce){
  .btn,.tile,.tile::after,.text-link span,.hs-media img{transition:none!important}
}

/* Homepage technologies chip cloud */
.tech-links{display:flex;flex-wrap:wrap;gap:10px}
.tech-links .chip{padding:9px 17px;font-size:13.5px}

/* Homepage technologies logo badges */
.tech-badges{display:grid;grid-template-columns:repeat(6,1fr);gap:14px;margin-top:8px}
.tech-badge{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:20px 12px;background:#fff;border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow-sm);transition:transform var(--dur-2) var(--ease-out),box-shadow var(--dur-2) var(--ease-out),border-color var(--dur-2) var(--ease-out);text-align:center}
.tech-badge:hover{transform:translateY(-4px);box-shadow:var(--shadow-md);border-color:#D5D5DB}
.tech-badge img{width:48px;height:48px;object-fit:contain}
/* `letter-spacing:normal` is load-bearing, not tidying. Promoting this label
   from <span> to <h3> for ask 9 also opted it in to the -.015em tracking on the
   h1..h4 rule, and at 13px that is -0.195px, which crushed the inter-word space
   from 3.00px to 1.80px. Measured, not guessed: "C Development", "React Native"
   and "AR Development" started reading as one word. Same failure mode as the
   footer band earlier this revision, and NARROWER than the 1.83px there that
   Brian could see unaided. The tag changed; the paint must not. */
.tech-badge span,.tech-badge h3{margin:0;letter-spacing:normal;font-size:13px;font-weight:600;color:var(--ink-900);font-family:'Plus Jakarta Sans',sans-serif;line-height:1.25}
.tech-badge--text{min-height:96px}
@media(max-width:1020px){.tech-badges{grid-template-columns:repeat(4,minmax(0,1fr))}}
/* Rev 86 (Brian: "you are cutting some of the technologies" on mobile).
   All 27 badges were always in the markup - nothing was hidden and nothing was
   sliced, at any width. They were being pushed OFF-SCREEN instead.

   `1fr` is shorthand for `minmax(auto,1fr)`, and that `auto` floor is the
   track's min-content width, so a track is free to grow past its share to fit
   the longest label it holds. Measured at 320px: the three tracks resolved to
   131px, 87px and 117px, which with two 14px gaps is 363px of grid inside a
   280px container. The document scrolled to 383px and the whole third column
   sat outside the viewport - Angular, PHP, .NET, C Development, React Native,
   Solidity, AR Development, WordPress and PrestaShop. Nine of twenty-seven,
   invisible unless you knew to scroll sideways. Same at 360px. At 390px the
   overflow existed too but stayed inside the viewport, which is why it read as
   a phone-size-specific fault rather than a layout one.

   `minmax(0,1fr)` removes the min-content floor so the tracks divide the
   container instead of escaping it. That alone stops the clipping, but three
   columns below 430px leaves roughly 73px of content per badge, and the widest
   single word here ("Development") does not fit that, so the label would clip
   instead of the column. Two columns below 430px gives about 109px of content
   at 320px, which does fit. Both conditions are asserted by
   check_mobile_overflow.py, at 320, 360 and 390.

   The last-child span keeps the final row complete on the two-column layout,
   because 27 is odd. It is the same rule .tech-badges-svc already uses at line
   2639, not a new idea. */
@media(max-width:640px){.tech-badges{grid-template-columns:repeat(3,minmax(0,1fr))}}
@media(max-width:430px){
  .tech-badges{grid-template-columns:repeat(2,minmax(0,1fr))}
  .tech-badges .tech-badge:last-child:nth-child(odd){grid-column:span 2}
}

/* Homepage industry expertise - full-width dark section */
.industry-full{background:var(--ink-950);color:#fff;padding:88px 0}
.industry-full .section-head h2{color:#fff}
.industry-full .section-head p{color:#AFAFBA}
/* Rev 36: 9 industries render as a true 3x3 grid. Was cols-4, which left a
   lone orphan tile on the second row once the ninth industry was added. */
.industry-full .tiles.cols-3{grid-template-columns:repeat(3,1fr)}
.industry-full .tile{background:#202027;border:1px solid #303039;min-height:190px;border-radius:12px}
.industry-full .tile:hover{background:#27272F;border-color:#454550;box-shadow:none}
/* Ask 7 (Rev 52): same dead-hover defect as `.band .tile .ic` above, and this
   is the one the client actually sees -- it is the homepage "Our Industry
   Expertise" grid, the only place on all 172 pages that renders `.tile .ic`
   (measured by crawling the sitemap: 9 instances, all here). The chip is also
   lifted off the tile a little: at #2D2D35 it sat only dL* 6.24 from the
   #202027 tile, so at 44px it read as a faint smudge; #33333C gives dL* 9.05
   while still keeping the pink glyph at 6.42:1 on top, comfortably past the
   4.5:1 text floor. Together with the 58px box this is what makes the icon
   register as an object rather than a tint. */
.industry-full .tile .ic{background:#33333C;color:#F2A5B0}
.industry-full .tile:hover .ic{background:var(--brand-500);color:#fff}
.industry-full .tile h3{color:#fff}
.industry-full .tile p{color:#AFAFBA}
.industry-full .tile::after{background:var(--brand-500)}
@media(max-width:1020px){.industry-full .tiles.cols-3{grid-template-columns:repeat(2,1fr)}}
@media(max-width:760px){.industry-full .tiles.cols-3{grid-template-columns:1fr}}

/* Recognition badge links */
.badge-link{display:inline-flex;border-radius:14px}
.badge-link:focus-visible{outline:2px solid var(--brand-500);outline-offset:3px}

/* Footer: contact row + 5-column link grid */
/* Rev 42: was 2fr 1.2fr (710/426 at 1440px). The right column now carries the
   newsletter AND the contact block, so it needs comparable width to the brand
   column rather than roughly half of it. 1.35fr/1fr gives 645/478 with the
   48px gap, which reads as two weighted halves instead of one wide column
   beside a narrow rail. */
.footer-top{display:grid;grid-template-columns:1.35fr 1fr;gap:48px;padding:64px 0 8px;align-items:start}
.f-contact p{font-size:14px;margin-bottom:6px}
.f-contact a{color:#E4B9C0}
/* Rev 67: the Contact heading is now a link (it was the footer's one unlinked
   page after the exhaustive index was removed). It must keep reading as a
   HEADING, not as another pink contact detail, so it takes the same white bold
   treatment as the four column headings. The override is explicit because
   `.f-contact a` above would otherwise colour it #E4B9C0 and `.site-footer a`
   would drop its weight to normal. */
.site-footer .f-contact .footer-heading a{color:#fff;font-weight:700}
.site-footer .f-contact .footer-heading a:hover{color:#E4B9C0}
.footer-links{grid-template-columns:1.1fr .8fr 1.1fr 1.4fr .8fr;padding:40px 0 44px}
.footer-links .footer-heading a{color:#fff;font-size:14px;font-weight:700}
.footer-links .footer-heading a:hover{color:#E4B9C0}
.footer-links li{margin-bottom:8px}
.footer-links a{font-size:13.5px}
.f-2col{columns:2;column-gap:22px}
.f-2col li{break-inside:avoid}

/* ===== Task 7 ask 1 (Rev 67): nested footer rows =====================
   Replaces the .f-sitemap exhaustive index. Rev 56's index and the curated
   columns duplicated 97 of 270 footer anchors between them; the sub-pages now
   live under their parent instead, so each page has exactly one anchor.

   LAYOUT: the parent link and the arrow share the first line, and the sub-list
   flows below. The summary is taken OUT OF FLOW and pinned to the top right of
   the row, which is what makes this hold together with no wrapper element:

     - a closed <details> has zero height, because its only in-flow child when
       closed is the summary, and the summary is absolute. So a collapsed row is
       exactly as tall as a plain row and the column does not gain 15 slivers of
       empty space.
     - the sub-list, when open, is a normal block inside the <details> and gets
       the full column width without any grid or flex span to maintain.

   This is NOT the first thing I tried. The obvious technique is
   `display:contents` on the <details> so the link, arrow and list become
   siblings in one grid, and I measured that it does preserve the closed state
   (checkVisibility, closed=false / open=true) - but it puts the sub-list inside
   the ::details-content box rather than in the row's own grid, so the full-width
   span has to be declared on a UA pseudo-element whose display type is not
   stable across engines. Pinning the summary needs none of that and leaves the
   disclosure mechanism completely stock.

   Note that the ARROW is the toggle and the NAME still navigates, which is the
   client's stated model. The summary carries an aria-label naming the parent and
   the count, because its visible content is a glyph; see the accessibility-tree
   comparison recorded in components.tsx for why the link is a sibling of the
   summary rather than a child of it.

   COLOUR is measured, not chosen by eye. The painted footer band is #17171D (two
   .site-footer rules set different backgrounds and the later --ink-950 one wins;
   measured at 1440, 900 and 390). On that band #9A9AA6 is 6.41:1 and #C9C9D2 is
   10.85:1, both clear of the 4.5:1 WCAG 1.4.3 floor. */
.f-nest{position:relative}
/* The right padding is the arrow's lane. Without it a long parent label runs
   under the glyph, which reads as a typo rather than as a control. */
.footer-links .f-nest>a{padding-right:30px;display:inline-block}
.f-drop{margin:0}
/* 26x26 clears the 24x24 WCAG 2.5.8 minimum target size. A caret is a 9px
   glyph, so without an explicitly sized summary the hit area would be the glyph
   and this row would be the smallest target on the page.
   top is -3px rather than 0 so the 26px square is optically centred on the
   ~20px first line of the label instead of hanging below it. */
.f-drop>summary{position:absolute;top:-3px;right:0;width:26px;height:26px;
  display:flex;align-items:center;justify-content:center;
  cursor:pointer;list-style:none;border-radius:6px;
  transition:background var(--dur-2) var(--ease-out)}
.f-drop>summary::-webkit-details-marker{display:none}
.f-drop>summary:hover{background:#2A2A33}
.f-drop>summary:focus-visible{outline:2px solid var(--brand-500);outline-offset:2px}
/* The caret is drawn from two borders rather than set as a text glyph, so it
   cannot be affected by a font swap and needs no icon payload. */
.f-caret{width:7px;height:7px;border-right:2px solid #9A9AA6;border-bottom:2px solid #9A9AA6;
  transform:rotate(45deg) translate(-1px,-1px);transition:transform var(--dur-2) var(--ease-out)}
.f-drop>summary:hover .f-caret{border-color:#fff}
.f-drop[open]>summary .f-caret{transform:rotate(225deg) translate(0,0)}
/* The hairline is the nesting cue. An indent alone is ambiguous in a column of
   short links, where a wrapped parent label already looks indented. */
.f-drop[open] .f-subs{margin:7px 0 10px;padding-left:11px;border-left:1px solid #33333D}
.footer-links .f-subs li{margin-bottom:4px}
.footer-links .f-subs li:last-child{margin-bottom:0}
.site-footer .f-subs a{font-size:12.5px;line-height:1.45;color:#9A9AA6;display:inline-block}
.site-footer .f-subs a:hover{color:#fff}

/* Ask 3 (Rev 67): the sitemap.xml row, last in the Company column. Set apart by
   a hairline above it and a quieter colour, because it is the one row in the
   footer that does not lead to a page a person reads. */
.f-xml{margin-top:12px;padding-top:10px;border-top:1px solid #2E2E37}
.site-footer .f-xml a{color:#9A9AA6}
.site-footer .f-xml a:hover{color:#fff}

@media(prefers-reduced-motion:reduce){
  .f-caret,.f-drop>summary{transition:none!important}
}
@media(max-width:1020px){.footer-links{grid-template-columns:repeat(3,1fr)}.footer-top{grid-template-columns:1fr}}
@media(max-width:760px){.footer-links{grid-template-columns:1fr 1fr}.f-2col{columns:1}.footer-top{padding-top:56px}}

/* ===== Services landing page (SEO and AI search optimized) ===== */

/* Hero variant for the services landing page */
.hero-services{padding-bottom:48px}
.hero-services .hero-split-grid{margin-bottom:32px}
/* Rev 24 (Brian): H1 size is identical on home and every hub page; the old 44px services-hub override is retired. Detail pages keep .hero.hero-page h1. */
.hero-services h1{line-height:1.08;letter-spacing:-.02em}
.hero-services .sub{font-size:18px;max-width:540px}
.hero-services .hs-media img{aspect-ratio:4/3;border-radius:var(--r-xl);box-shadow:var(--shadow-lg)}
.hero-services .pill{margin-bottom:18px;display:inline-flex}

/* Featured services: alternating image and text rows */
.feature-rows{display:flex;flex-direction:column;gap:72px}
.feature-row{display:grid;grid-template-columns:1fr 1fr;gap:56px;align-items:center}
.feature-row .fr-media{order:1}
.feature-row .fr-copy{order:2}
.feature-row .fr-media.fr-flip{order:2}
.feature-row .fr-media.fr-flip + .fr-copy{order:1}
/* Ask 22 (Rev 44): the CONTAINER must carry the same radius as the image it
   holds, not just the image. .hub-feature-row .fr-media paints a grey
   (--surface-50) panel that the logo variant needs, but the panel had
   border-radius:0 while the img inside was rounded to --r-xl, so a grey wedge
   showed in each of the four corners -- visible because the artwork's own edges
   are warm cream against a cool grey. Rounding the box and clipping to it fixes
   every corner at once and cannot regress when a new fr-media variant is added. */
.fr-media{border-radius:var(--r-xl);overflow:hidden}
.fr-media img{width:100%;aspect-ratio:4/3;object-fit:cover;border-radius:var(--r-xl);box-shadow:var(--shadow-md);display:block}
.fr-title{display:flex;align-items:center;gap:16px;margin-bottom:18px}
.fr-title .fr-ic{flex:none;margin:0}
.fr-copy h3{font-size:26px;font-weight:800;margin:0;letter-spacing:-.01em}
.fr-copy h3 a{color:var(--ink-950);text-decoration:none}
.fr-copy h3 a:hover{color:var(--brand-700)}
.fr-copy p{color:var(--ink-700);font-size:16px;line-height:1.65;margin-bottom:22px}
.fr-ic{width:54px;height:54px;border-radius:14px;background:var(--brand-50);display:flex;align-items:center;justify-content:center;margin-bottom:18px}
.fr-ic svg{width:26px;height:26px;color:var(--brand-500)}

/* Card that is also a link (for more services and industries) */
.card-link{display:block;text-decoration:none;color:inherit;transition:transform var(--dur-2) var(--ease-out),box-shadow var(--dur-2) var(--ease-out),border-color var(--dur-2) var(--ease-out)}
.card-link:hover{transform:translateY(-4px);box-shadow:var(--shadow-lg);border-color:var(--brand-100)}
.card-link .more{color:var(--brand-700);font-size:13.5px;font-weight:600;display:inline-block;margin-top:12px}
.card-link:hover .more{color:var(--brand-500)}

/* Full-width banner figure for the process section */
.fig-banner{margin:0 0 40px;border-radius:var(--r-xl);overflow:hidden;box-shadow:var(--shadow-md)}
.fig-banner img{width:100%;aspect-ratio:16/9;object-fit:cover;display:block}

/* Benefit split: image on one side, cards grid on the other */
.benefit-split{display:grid;grid-template-columns:1fr 1.1fr;gap:48px;align-items:start}
.benefit-media{margin:0;border-radius:var(--r-xl);overflow:hidden;box-shadow:var(--shadow-md);position:sticky;top:96px}
.benefit-media img{width:100%;aspect-ratio:4/3;object-fit:cover;display:block}
/* Rev 59: the integrations panel on industry sub-pages holds an inline authored SVG
   instead of repeating the page hero photograph. `.benefit-media img` cannot style it,
   so the same 4:3 box is restated for the svg, plus a hairline and a light mat so a
   line drawing does not float on the page the way a bled photograph does. */
.benefit-media-art{box-shadow:var(--shadow-sm);border:1px solid var(--line);background:var(--surface-50)}
.benefit-media-art .card-art{width:100%;aspect-ratio:4/3;display:block}
.benefit-cards{display:grid;grid-template-columns:1fr 1fr;gap:20px}

/* CTA split: copy and image side by side */
.cta-split{display:grid;grid-template-columns:1.1fr 1fr;gap:56px;align-items:center}
.cta-split .cta-copy .cta-heading{font-size:32px;font-weight:800;letter-spacing:-.02em;line-height:1.2;margin-bottom:16px;color:var(--ink-950)}
.cta-split .cta-copy p{color:var(--ink-700);font-size:17px;line-height:1.6;margin-bottom:28px}
.cta-split .cta-copy .ctas{display:flex;gap:14px;flex-wrap:wrap;align-items:center}
.cta-media{margin:0;border-radius:var(--r-xl);overflow:hidden;box-shadow:var(--shadow-lg)}
.cta-media img{width:100%;aspect-ratio:4/3;object-fit:cover;display:block}

/* Services landing responsive */
@media(max-width:1020px){
  .feature-rows{gap:56px}
  .feature-row{gap:40px}
  .benefit-split{grid-template-columns:1fr;gap:32px}
  .benefit-media{position:static}
  .benefit-cards{grid-template-columns:1fr 1fr}
  .cta-split{gap:40px}
}
@media(max-width:900px){
  .hero-services .sub{font-size:16px}
  .feature-row{grid-template-columns:1fr;gap:28px}
  .feature-row .fr-media,.feature-row .fr-media.fr-flip,.feature-row .fr-copy,.feature-row .fr-media.fr-flip + .fr-copy{order:0}
  .fr-media img{aspect-ratio:16/9}
  .cta-split{grid-template-columns:1fr;gap:28px}
}
@media(max-width:720px){
  .feature-rows{gap:44px}
  .benefit-cards{grid-template-columns:1fr}
  .fr-copy h3{font-size:22px}
  .cta-split .cta-copy .cta-heading{font-size:26px}
}

/* Image cards: More Services grid with photo tops */
.cards-grid-img{display:grid;grid-template-columns:repeat(3,1fr);gap:24px}
/* Ask 22 (Rev 44): .img-card carries ONLY the class `img-card`, never `card`, so
   it never picked up .card's border-radius -- it was a genuinely SQUARE bordered
   box wrapping a grey media strip. Measured, not assumed: computed radius was
   0px on the card while the strip inside was 20px, which is why grey showed at
   the top corners. The radius belongs on the card (its border draws the visible
   edge); overflow:hidden then clips the strip and image to that curve. */
.img-card{display:flex;flex-direction:column;overflow:hidden;padding:0;border:1px solid var(--line);border-radius:var(--r-lg)}
.img-card-media{aspect-ratio:4/3;overflow:hidden;background:var(--surface-50)}
.img-card-media img{display:block}
.img-card-media img{width:100%;height:100%;object-fit:cover;transition:transform var(--dur-3) var(--ease-out)}
.img-card:hover .img-card-media img{transform:scale(1.05)}
/* Rev 58 (ask 4): art-backed variant of the media strip. The three offshore benefit
   cards previously used at-sv02-*.webp, which are the same AI-generated illustration
   family as the homepage photographs, so they carry authored SVG now. The hairline
   separates the art from the copy the way .sol-media does; without it the art's own
   white ground merges into the body and the card loses its top block. */
.img-card-media.img-card-art{border-bottom:1px solid #F3E2E5}
.img-card:hover .img-card-media .card-art{transform:scale(1.05)}
.img-card-body{padding:22px 22px 26px;flex:1;display:flex;flex-direction:column}
.img-card-body h3{font-size:19px;font-weight:800;margin-bottom:10px;letter-spacing:-.01em}
.img-card-body p{color:var(--ink-700);font-size:14.5px;line-height:1.6;margin-bottom:14px;flex:1}
.img-card-body .more{color:var(--brand-700);font-size:13.5px;font-weight:600;margin-top:auto}
.img-card:hover .img-card-body .more{color:var(--brand-500)}
.cards-grid-img.cols-2{grid-template-columns:repeat(2,1fr)}
@media(max-width:900px){.cards-grid-img,.cards-grid-img.cols-2{grid-template-columns:repeat(2,1fr);gap:20px}}
@media(max-width:620px){.cards-grid-img,.cards-grid-img.cols-2{grid-template-columns:1fr}}

/* Rev 17: industry who-benefits audience tiles (H3 title + context line each, rule 12e) */
.audience-tiles{display:grid;grid-template-columns:1fr 1fr;gap:18px}
.audience-tiles .card{padding:22px 22px}
.audience-tiles .card .ic{margin-bottom:12px}
.audience-tiles .card h3{font-size:17px;font-weight:750;margin:0 0 8px;letter-spacing:-.01em}
.audience-tiles .card p{color:var(--ink-700);font-size:14px;line-height:1.6;margin:0}
@media(max-width:620px){.audience-tiles{grid-template-columns:1fr}}

/* Rev 17b (Brian): industry sections need breathing room, never cramped */
#ind-solutions .cards-grid-img{gap:32px}
#ind-solutions .cards-grid-img.cols-2{max-width:1080px;margin:0 auto}
#ind-solutions .img-card-body{padding:26px 26px 30px}
#ind-why .benefit-split{gap:44px}
#ind-why .benefit-cards{gap:26px}
#ind-why .benefit-cards .card{padding:30px 28px}
#ind-who .benefit-split{gap:44px}
@media(max-width:900px){#ind-solutions .cards-grid-img{gap:22px}#ind-why .benefit-cards{gap:18px}}

/* Rev 18 (Brian): solution pages. 8 items ALWAYS render 4+4 (even-layout rule),
   degrading 2 cols then 1 col. Used for the industries-served tile band. */
.tiles-grid-4{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:18px}
.tiles-grid-4 .card{padding:24px 22px}
.tiles-grid-4 .card .ic{margin-bottom:12px}
.tiles-grid-4 .card h3{font-size:16.5px;font-weight:750;margin:0 0 8px;letter-spacing:-.01em}
.tiles-grid-4 .card p{color:var(--ink-700);font-size:13.5px;line-height:1.6;margin:0}
@media(max-width:1020px){.tiles-grid-4{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:620px){.tiles-grid-4{grid-template-columns:1fr}}
/* Solution sections mirror the industry breathing-room treatment */
#sol-modules .cards-grid-img{gap:32px}
#sol-modules .cards-grid-img.cols-2{max-width:1080px;margin:0 auto}
#sol-modules .img-card-body{padding:26px 26px 30px}
#sol-benefits .cards-grid-img{gap:32px}
#sol-why .benefit-split{gap:44px}
#sol-why .benefit-cards{gap:26px}
#sol-why .benefit-cards .card{padding:30px 28px}
#sol-why .svc-creds{margin-top:44px;max-width:920px;margin-left:auto;margin-right:auto}
#sol-why .benefit-media img{aspect-ratio:4/3;object-fit:cover}
#sol-benefits .benefit-split{gap:44px}
@media(max-width:900px){#sol-modules .cards-grid-img,#sol-benefits .cards-grid-img{gap:22px}#sol-why .benefit-cards{gap:18px}}
/* Rev 20 technology pages: same why-split and creds treatment as solutions;
   .grid-stack separates the icon-tile row that follows a photo-card row in
   the mixed expertise grids (3 imaged cards + 3 icon tiles). */
#tech-why .benefit-split{gap:44px}
#tech-why .benefit-cards{gap:26px}
#tech-why .benefit-cards .card{padding:30px 28px}
#tech-why .svc-creds,#tech-standout .svc-creds,#tech-expertise .svc-creds{margin-top:44px;max-width:920px;margin-left:auto;margin-right:auto}
#tech-why .benefit-media img{aspect-ratio:4/3;object-fit:cover}
#tech-standout .cards-grid-img,#tech-expertise .cards-grid-img{gap:32px}
.audience-tiles.grid-stack{margin-top:24px}
@media(max-width:900px){#tech-standout .cards-grid-img,#tech-expertise .cards-grid-img{gap:22px}#tech-why .benefit-cards{gap:18px}.audience-tiles.grid-stack{margin-top:20px}}
/* 3-column audience tiles for 6 or 9 item grids (3+3 / 3+3+3, even-layout rule) */
.audience-tiles.cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}
@media(max-width:1020px){.audience-tiles.cols-3{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:620px){.audience-tiles.cols-3{grid-template-columns:1fr}}
/* Rev 19 (Brian): section head splits beside his SO-xx illustration.
   Modules: copy LEFT, image RIGHT. Industries (.media-left): image LEFT, copy RIGHT. */
.sol-split-head{display:grid;grid-template-columns:1.12fr 1fr;gap:52px;align-items:center;margin-bottom:52px}
.sol-split-head .sol-split-copy h2{font-size:clamp(30px,3.2vw,44px);font-weight:800;letter-spacing:-.03em}
.sol-split-head .sol-split-copy p{color:var(--ink-500);margin-top:14px;line-height:1.75;max-width:56ch}
.sol-split-head.media-left{grid-template-columns:1fr 1.12fr}
.sol-split-media{margin:0;border:1px solid var(--line);border-radius:14px;overflow:hidden;background:#fff}
.sol-split-media img{display:block;width:100%;height:auto}
@media(max-width:900px){
  .sol-split-head,.sol-split-head.media-left{grid-template-columns:1fr;gap:28px;margin-bottom:36px}
  .sol-split-head.media-left .sol-split-media{order:2}
}

/* Services landing audit fixes: readability, responsive density, and brand consistency */
.hero-services .trust-row{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:30px}
.hero-services .trust{min-width:0;align-items:flex-start;gap:8px;padding:12px 10px;border:1px solid var(--line);border-radius:10px;background:#fff;color:var(--ink-700);font-size:12px;line-height:1.35}
.hero-services .trust>span{min-width:0;display:block}
.hero-services .trust b{display:block;color:var(--ink-950);font-size:18px;line-height:1.1}
.hero-services .trust small{display:block;margin-top:4px;color:var(--ink-500);font-size:11px;line-height:1.3}
/* Rev 65, ask 5: was `border-top:1px solid var(--line);padding-top:18px;
   margin-bottom:0`, which is what made this block 58px tall against the
   homepage's 39px and fenced the credential off below a rule. Brian asked for
   the homepage treatment exactly, so the override is gone and the base
   `.hero-trustline` (26px bottom margin, no divider) now applies here too.
   `.hero-directory` carried an identical override and was removed with it. */
#services-directory,#development-process,#why-vietnam,#engagement-models,#faq{scroll-margin-top:104px}
#services-directory{padding-top:84px;padding-bottom:96px}
#development-process,#why-vietnam,#engagement-models{padding-top:84px;padding-bottom:84px}
#services-directory .center-head{margin-bottom:52px}
.services-intro{max-width:900px}
/* Ask 4 (Rev 66): was `text-wrap:balance`. Replaced by `pretty` for the reason
   measured at the widow rule above - on a paragraph this long Chromium stops
   balancing, so the declaration was doing nothing here while reading as though
   the wrapping had been considered. */
.services-intro p{max-width:820px;margin-left:auto;margin-right:auto;text-wrap:pretty}
.service-catalog-list{gap:64px}
.process-overview{position:relative}
.services-process-steps{grid-template-columns:repeat(3,minmax(0,1fr))}
#why-vietnam .center-head{max-width:900px}
#why-vietnam .center-head p{max-width:860px;margin-left:auto;margin-right:auto;white-space:nowrap}

@media(max-width:1020px){
  .chipnav{position:sticky}
  .chipnav::after{content:"";position:absolute;top:0;right:0;bottom:0;width:38px;background:linear-gradient(90deg,rgba(255,255,255,0),#fff 78%);pointer-events:none}
  .chipnav .container{padding-right:54px;scrollbar-width:thin;scrollbar-color:var(--brand-100) transparent}
  .chipnav .container::-webkit-scrollbar{height:4px}
  .chipnav .container::-webkit-scrollbar-thumb{background:var(--brand-100);border-radius:99px}
  .services-process-steps{grid-template-columns:repeat(2,minmax(0,1fr))}
}
@media(max-width:760px){
  .topbar{font-size:13px;padding:8px 0}
  .topbar .container{justify-content:center;gap:14px;white-space:nowrap}
  .hero-services .hero-split-grid{gap:30px}
  .hero-services .ctas{width:100%}
  .hero-services .ctas .btn{width:100%;text-align:center}
  .hero-services .trust-row{grid-template-columns:1fr;gap:8px;width:100%}
  .hero-services .trust{align-items:center;padding:10px 12px}
  .hero-services .trust>span{display:grid;grid-template-columns:auto 1fr;gap:0 8px;align-items:baseline}
  .hero-services .trust b{font-size:17px}
  .hero-services .trust small{font-size:12px;margin:0}
  /* Rev 65, ask 5: the mobile `.hero-services .hero-trustline{font-size:12.5px}`
     rule was deleted as dead. `.hero-trustline`'s own font-size only ever
     painted the retired `.iso-suffix` / `.iso-lead` spans, because `.iso-chip`
     and `.iso-chip b` both set their own size. With the suffix gone the rule
     styled nothing. Verified by measuring the computed chip size, not assumed. */
  #services-directory,#development-process,#why-vietnam,#engagement-models{padding-top:64px;padding-bottom:64px}
  #services-directory .tiles{grid-template-columns:1fr;gap:12px}
  #services-directory .tile{min-height:0;padding:20px 18px 58px}
  #services-directory .tile .ic{width:40px;height:40px;margin-bottom:13px}
  #services-directory .tile h3{font-size:16px;line-height:1.3}
  #services-directory .tile p{font-size:13.5px;line-height:1.55}
  #services-directory .tile .more{left:18px;bottom:17px;font-size:12.5px}
  .feature-rows{gap:40px}
  .feature-row{gap:22px}
  .fr-copy p{font-size:15px;line-height:1.6}
  .process-overview{scrollbar-width:thin;scrollbar-color:var(--brand-100) transparent}
  .service-catalog-list{gap:46px}
  .services-process-steps{grid-template-columns:1fr}
  .services-process-steps .step .no{display:flex;margin-left:auto;margin-right:auto}
  #why-vietnam .center-head p{white-space:normal}
}

/* Persistent left-side services page table of contents */
/* Rev 78: `max-height` added when the case studies started using this rail. The
   services pages all carry 5 or 6 entries, but a case study's rail is one entry per
   authored section and measured up to 11 (/portfolio/online-liquor-store-app/), so a
   centred fixed panel could run past the top and bottom of a short viewport. The cap
   is expressed against the viewport with room for the 72px sticky header, and the
   overflow scrolls rather than clipping. Below 1321px the rail is display:none, so
   this only ever applies where the rail is actually on screen. */
.page-rail{position:fixed;left:16px;top:50%;z-index:35;display:flex;width:148px;max-height:calc(100vh - 140px);overflow-y:auto;overscroll-behavior:contain;flex-direction:column;gap:4px;transform:translateY(-50%);padding:14px;background:rgba(255,255,255,.96);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow-md);backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px)}
.page-rail-title{margin:0 0 8px;color:var(--ink-500);font-family:'Plus Jakarta Sans',sans-serif;font-size:10px;font-weight:800;letter-spacing:.1em;text-transform:uppercase}
.page-rail a{position:relative;display:grid;grid-template-columns:13px 1fr;align-items:center;gap:10px;min-height:36px;padding:4px 7px 4px 2px;border-radius:8px;color:var(--ink-700);font-size:12.5px;font-weight:650;line-height:1.25;transition:background var(--dur-2) var(--ease-out),color var(--dur-2) var(--ease-out)}
.page-rail a:not(:last-child)::after{content:"";position:absolute;left:7px;top:27px;width:1px;height:19px;background:var(--line)}
.page-rail a>span:last-child{display:block;overflow:visible;white-space:normal;opacity:1}
.page-rail-node{position:relative;z-index:1;width:10px;height:10px;border:2px solid #B8B8C0;border-radius:50%;background:#fff;transition:background var(--dur-2) var(--ease-out),border-color var(--dur-2) var(--ease-out),transform var(--dur-2) var(--ease-out)}
.page-rail a:hover,.page-rail a[aria-current="location"]{background:var(--brand-50);color:var(--brand-700)}
.page-rail a:hover .page-rail-node,.page-rail a[aria-current="location"] .page-rail-node{border-color:var(--brand-500);background:var(--brand-500);transform:scale(1.1)}
@media(min-width:1321px){
  #main>.page-rail~section>.container{width:calc(100% - 230px);max-width:1070px;margin-left:max(196px,calc((100% - 1070px)/2));margin-right:auto}
  #main>.page-rail~.sec-band>.container{width:calc(100% - 230px);max-width:860px;margin-left:max(196px,calc((100% - 860px)/2));margin-right:auto}
  /* Rev 60 (Brian): the rail is position:fixed at left:16px and occupies x=16..164,
     but every compensation rule above uses `~`, a GENERAL SIBLING combinator, so it
     can only reach sections emitted AFTER the rail in the DOM. The hero and the
     testimonials block are emitted BEFORE it, so they were never compensated and
     kept a plain centred 1240px container starting at x=100 on a 1440px viewport.
     Two defects came out of that one cause, measured on all 147 rail pages:
       1) The rail SAT ON TOP of the hero (z-index 35), covering the left 64px of
          the H1, the body copy, the primary CTA, the first stat and the ISO badge.
          Confirmed by live getBoundingClientRect plus elementFromPoint returning
          the rail's own <a>, and the band bisected to exactly 1321px..1567px,
          which contains 1366 and 1440, the two most common laptop widths.
       2) Above 1568px there is no overlap, but the hero still started 16px to the
          left of every section below it, so the content column visibly jogged
          sideways on scroll. That misalignment existed at EVERY width >=1321px.
     `:has()` scopes this to the rail pages only, so the 26 pages with no rail keep
     their centred hero. The formula is copied verbatim from the rule above rather
     than re-derived, so the hero lands on the same 196px edge as the body content
     instead of a third value of its own.
     The selector is `:has(~ .page-rail)`, which reads "a section that has the rail
     as a LATER sibling", i.e. a real previous-sibling selector. That matters for
     two reasons. It matches ONLY the pre-rail blocks, so it cannot touch the
     post-rail sections the rules above already govern, and it keeps specificity at
     (1,2,2), identical to those rules, so there is no override either way. An
     earlier draft used `#main:has(>.page-rail)>section:not(.page-rail)`, which
     scored (1,3,2) and would have silently widened every post-rail section from
     1070px to 1240px across all 147 pages. */
  #main>section:has(~.page-rail)>.container{width:calc(100% - 230px);max-width:1240px;margin-left:max(196px,calc((100% - 1240px)/2));margin-right:auto}
  /* Mobile app development page: widen Capabilities, Process, and Why Choose to match the home page's container width */
  /* Rev 17b (Brian): industry sections were stuck at the 1070px rail cap and felt
     cramped; solutions/who/why/approach/band now match the 1240px wide treatment. */
  #main>.page-rail~#svc-capabilities>.container,
  #main>.page-rail~#svc-process>.container,
  #main>.page-rail~#svc-why>.container,
  #main>.page-rail~#ind-solutions>.container,
  #main>.page-rail~#ind-who>.container,
  #main>.page-rail~#ind-why>.container,
  #main>.page-rail~#ind-approach>.container,
  #main>.page-rail~#sol-benefits>.container,
  #main>.page-rail~#sol-modules>.container,
  #main>.page-rail~#sol-extra>.container,
  #main>.page-rail~#sol-extra2>.container,
  #main>.page-rail~#sol-why>.container,
  #main>.page-rail~#sol-industries>.container,
  #main>.page-rail~#sol-approach>.container,
  #main>.page-rail~#sol-practices>.container,
  #main>.page-rail~#tech-standout>.container,
  #main>.page-rail~#tech-expertise>.container,
  #main>.page-rail~#tech-extra>.container,
  #main>.page-rail~#tech-why>.container,
  #main>.page-rail~#tech-industries>.container,
  #main>.page-rail~#tech-approach>.container,
  #main>.page-rail~#about-journey>.container,
  #main>.page-rail~#about-mission>.container,
  #main>.page-rail~#about-story>.container,
  #main>.page-rail~#about-process>.container,
  #main>.page-rail~#about-pricing>.container,
  #main>.page-rail~#about-industries>.container,
  #main>.page-rail~#about-numbers>.container,
  #main>.page-rail~#about-stack>.container,
  #main>.page-rail~#about-people>.container{width:calc(100% - 230px);max-width:1240px;margin-left:max(196px,calc((100% - 1240px)/2));margin-right:auto}
}
@media(max-width:1320px){.page-rail{display:none}}
@media(max-width:420px){
  .topbar .tb-item:nth-last-child(2){display:none}
  .topbar .container{justify-content:center}
}

/* Services-pattern directory hubs: industries, solutions, and technologies */
/* Rev 75: `.hero-directory .hs-media::after{display:none}` removed here; the
   ornament it suppressed no longer exists (see the note at .hs-media::before). */
.hero-directory .trust-row{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:30px}
.hero-directory .trust{min-width:0;align-items:flex-start;gap:8px;padding:12px 10px;border:1px solid var(--line);border-radius:10px;background:#fff;color:var(--ink-700);font-size:12px;line-height:1.35}
.hero-directory .trust>span{min-width:0;display:block}
.hero-directory .trust b{display:block;color:var(--ink-950);font-size:18px;line-height:1.1}
.hero-directory .trust small{display:block;margin-top:4px;color:var(--ink-500);font-size:11px;line-height:1.3}
/* Rev 65, ask 5: the `.hero-directory` divider override was removed here too,
   for the reason recorded at the `.hero-services` rule above. */
.hero-directory .hs-media img{aspect-ratio:4/3}
.hub-directory,.hub-process,.hub-benefits,.hub-engagement,.hub-faq{scroll-margin-top:104px}

/* Bespoke service detail page scroll anchors (mobile app prototype) */
#svc-what-is,#svc-capabilities,#svc-process,#svc-why,#svc-stack,#svc-who,#svc-choosing,#svc-engagement,#svc-start,#faq,#ind-solutions,#ind-who,#ind-why,#ind-approach,#sol-benefits,#sol-modules,#sol-extra,#sol-extra2,#sol-why,#sol-industries,#sol-approach,#sol-practices,#tech-standout,#tech-expertise,#tech-extra,#tech-why,#tech-industries,#tech-approach,#about-journey,#about-mission,#about-story,#about-process,#about-pricing,#about-industries,#about-numbers,#about-stack,#about-people,#cases-industries,#cases-featured,#team-story,#team-culture,#industry-events,#careers-life,#careers-pictures,#company-creds{scroll-margin-top:104px}
#svc-what-is .center-head,#svc-capabilities .center-head,#svc-process .center-head,#svc-why .center-head,#svc-choosing .center-head,#svc-engagement .center-head{max-width:900px}
#svc-what-is .center-head p,#svc-capabilities .center-head p,#svc-process .center-head p,#svc-why .center-head p,#svc-choosing .center-head p,#svc-engagement .center-head p{max-width:860px;margin-left:auto;margin-right:auto}
/* Rev 17c (Brian): 6 steps ALWAYS render 3+3, never 4+2 (the auto-fit default
   fits 4 columns at the wide 1240px container). #ind-approach pinned to 3 cols. */
#svc-process .steps,#svc-choosing .steps,#ind-approach .steps,#sol-approach .steps,#tech-approach .steps,#about-process .steps,.home-process-steps{grid-template-columns:repeat(3,minmax(0,1fr));margin-top:34px}
#svc-why .svc-creds{margin-top:44px;max-width:920px;margin-left:auto;margin-right:auto}
#svc-why .benefit-media img{aspect-ratio:4/3;object-fit:cover}
#svc-what-is .benefit-media img{aspect-ratio:4/3;object-fit:cover}
.what-is-copy .lead{font-size:18px;line-height:1.65;color:var(--ink-700);margin-bottom:28px}
.what-is-copy .checklist{margin-top:0}
/* Rev 16 fix: who-benefits H2 must match every other section H2 */
.what-is-copy h2{font-size:clamp(30px,3.2vw,44px);font-weight:800;letter-spacing:-.03em;margin-bottom:24px}
@media(max-width:640px){.what-is-copy h2{font-size:27px}}
@media(max-width:1020px){
  #svc-process .steps,#svc-choosing .steps,#ind-approach .steps,#sol-approach .steps,#tech-approach .steps,#about-process .steps,.home-process-steps{grid-template-columns:repeat(2,minmax(0,1fr))}
}
@media(max-width:760px){
  #svc-process .steps,#svc-choosing .steps,#ind-approach .steps,#sol-approach .steps,#tech-approach .steps,#about-process .steps,.home-process-steps{grid-template-columns:1fr}
  #svc-process .steps .step .no,#svc-choosing .steps .step .no,#ind-approach .steps .step .no,#sol-approach .steps .step .no,#tech-approach .steps .step .no,#about-process .steps .step .no,.home-process-steps .step .no{display:flex;margin-left:auto;margin-right:auto}
  #svc-why .svc-creds{margin-top:32px}
}
.hub-directory{padding-top:84px;padding-bottom:96px}
.hub-directory .center-head{margin-bottom:52px}
.hub-feature-list{gap:64px}
.hub-feature-row .fr-media{background:var(--surface-50)}
.hub-feature-logo{display:grid;place-items:center;padding:72px;background:linear-gradient(145deg,#fff,var(--surface-50))!important}
.hub-feature-logo img{width:180px;height:180px;object-fit:contain}
.hub-process,.hub-benefits,.hub-engagement{padding-top:84px;padding-bottom:84px}
.hub-benefits .benefit-media img{aspect-ratio:4/5;object-fit:cover}
.hub-ready .cta-media img{aspect-ratio:4/3;object-fit:cover}

@media(max-width:760px){
  .hero-directory .hero-split-grid{gap:30px}
  .hero-directory .ctas{width:100%}
  .hero-directory .ctas .btn{width:100%;text-align:center}
  .hero-directory .trust-row{grid-template-columns:1fr;gap:8px;width:100%}
  .hero-directory .trust{align-items:center;padding:10px 12px}
  .hero-directory .trust>span{display:grid;grid-template-columns:auto 1fr;gap:0 8px;align-items:baseline}
  .hero-directory .trust b{font-size:17px}
  .hero-directory .trust small{margin:0;font-size:12px}
  .hub-directory,.hub-process,.hub-benefits,.hub-engagement{padding-top:64px;padding-bottom:64px}
  .hub-feature-list{gap:46px}
  .hub-feature-logo{padding:48px}
  .hub-feature-logo img{width:130px;height:130px}
}


/* Rev 16: "Technologies We Work With" logo grid on service pages.
   Reuses the homepage .tech-badge card but auto-fits the row count so
   4-7 stacks always fill a single balanced row on desktop. */
.tech-badges-svc{display:grid;gap:16px;margin:0 auto}
.tech-badges-svc.cols-2{grid-template-columns:repeat(2,1fr);max-width:520px}
.tech-badges-svc.cols-3{grid-template-columns:repeat(3,1fr);max-width:760px}
.tech-badges-svc.cols-4{grid-template-columns:repeat(4,1fr);max-width:960px}
.tech-badges-svc.cols-5{grid-template-columns:repeat(5,1fr);max-width:1060px}
.tech-badges-svc .tech-badge{padding:26px 14px 22px;gap:12px}
.tech-badges-svc .tech-badge img{width:52px;height:52px;object-fit:contain}
.tech-badges-svc .tech-badge h3{font-size:15px;font-weight:700;color:var(--ink-900);font-family:'Plus Jakarta Sans',sans-serif;line-height:1.25;margin:0}
.tech-badges-svc .tech-badge:hover{transform:translateY(-3px);box-shadow:var(--shadow-md);border-color:var(--brand-100)}
@media(max-width:860px){.tech-badges-svc.cols-5{gap:12px}.tech-badges-svc.cols-5 .tech-badge{padding:20px 10px 16px}}
@media(max-width:640px){
  .tech-badges-svc,.tech-badges-svc.cols-3,.tech-badges-svc.cols-4,.tech-badges-svc.cols-5{grid-template-columns:repeat(2,1fr)}
  /* keep the final row filled when the count is odd on the 2-col mobile grid */
  .tech-badges-svc .tech-badge:last-child:nth-child(odd){grid-column:span 2}
}

/* Rev 25k: grouped tech stacks (AI / DevOps / QA services). Category cards
   with named chips; chips link when a dedicated technology page exists. */
.tech-groups{display:grid;gap:18px}
.tech-groups.cols-2{grid-template-columns:repeat(2,1fr);max-width:960px;margin:0 auto}
.tech-groups.cols-3{grid-template-columns:repeat(3,1fr)}
.tech-group{background:#fff;border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow-sm);padding:24px;transition:transform var(--dur-2) var(--ease-out),box-shadow var(--dur-2) var(--ease-out),border-color var(--dur-2) var(--ease-out)}
.tech-group:hover{transform:translateY(-3px);box-shadow:var(--shadow-md);border-color:var(--brand-100)}
.tg-head{display:flex;align-items:center;gap:12px;margin:0 0 16px}
.tg-head .ic{display:grid;place-items:center;width:40px;height:40px;border-radius:10px;background:var(--brand-50);color:var(--brand-700);flex:none}
.tg-head .ic .ico{width:20px;height:20px}
.tg-head h3{font-size:16.5px;font-weight:700;color:var(--ink-900);font-family:'Plus Jakarta Sans',sans-serif;line-height:1.3;margin:0}
.tg-chips{display:flex;flex-wrap:wrap;gap:8px;margin:0;padding:0;list-style:none}
.tg-chips li{display:contents}
.tg-chip{display:inline-flex;align-items:center;gap:7px;padding:6px 13px 6px 9px;border-radius:99px;background:var(--surface-50);border:1px solid var(--line);font-size:13.5px;font-weight:600;color:var(--ink-700);line-height:1.3;text-decoration:none}
.tg-chip img{width:20px;height:20px;flex:none;display:block}
a.tg-chip{color:var(--brand-700);border-color:var(--brand-100);background:var(--brand-50)}
a.tg-chip:hover{background:var(--brand-100)}
@media(max-width:980px){.tech-groups.cols-3{grid-template-columns:repeat(2,1fr)}}
@media(max-width:640px){.tech-groups.cols-2,.tech-groups.cols-3{grid-template-columns:1fr}}

/* Rev 16: designed consultation CTA (photo + copy card), replaces the old
   naked "Receive free consultation" button at the end of body sections */
.consult-cta{margin-top:44px;display:grid;grid-template-columns:1.15fr 1fr;align-items:stretch;border-radius:var(--r-xl);overflow:hidden;background:var(--ink-950,#1A1A21);color:#fff;box-shadow:var(--shadow-md)}
.consult-cta-copy{padding:44px 48px;display:flex;flex-direction:column;justify-content:center;align-items:flex-start;gap:14px}
.consult-cta-copy .cta-heading{font-size:24px;font-weight:800;letter-spacing:-.02em;line-height:1.25;color:#fff;margin:0}
.consult-cta-copy p{font-size:15px;line-height:1.65;color:#C9C9D2;margin:0;max-width:46ch}
.consult-cta-copy .btn{margin-top:8px}
.consult-cta-media{margin:0;min-height:240px}
.consult-cta-media img{width:100%;height:100%;object-fit:cover;display:block}
@media(max-width:860px){
  .consult-cta{grid-template-columns:1fr}
  .consult-cta-copy{padding:32px 26px;order:2}
  .consult-cta-media{min-height:200px;order:1}
}

/* Rev 21: company hub bespoke layouts */
/* 4-stat hero trust row (case studies): 2x2 so labels never crush */
.hero-services .trust-row.tr-4{grid-template-columns:repeat(2,minmax(0,1fr))}

/* 10 industry tiles on case studies: always even rows, 5+5 wide / 2-col mid */
.audience-tiles.cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}
.audience-tiles.cols-5 .card{padding:20px 16px;text-align:center}
.audience-tiles.cols-5 .card .ic{margin-left:auto;margin-right:auto}
.audience-tiles.cols-5 .card h3{font-size:15px}
.audience-tiles.cols-5 .card p{font-size:12.5px;color:var(--ink-500)}
@media(max-width:1100px){.audience-tiles.cols-5{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:620px){.audience-tiles.cols-5{grid-template-columns:1fr}}

/* About: CEO story quote */
.about-quote-grid{display:grid;grid-template-columns:minmax(0,.72fr) minmax(0,1.28fr);gap:56px;align-items:center;max-width:1080px;margin:0 auto}
.about-quote-photo{margin:0;border-radius:var(--r-lg);overflow:hidden;box-shadow:var(--shadow-md)}
.about-quote-photo img{display:block;width:100%;height:100%;object-fit:cover;aspect-ratio:4/5}
.about-quote{margin:0;position:relative;padding-left:28px;border-left:3px solid var(--brand-500)}
.about-quote h2{font-size:clamp(28px,3vw,40px);font-weight:800;letter-spacing:-.03em;margin-bottom:20px}
.about-quote p{color:var(--ink-700);font-size:16.5px;line-height:1.7;margin-bottom:16px}
.about-quote cite{display:block;margin-top:22px;font-style:normal;font-weight:750;font-size:15px}
.about-quote cite a{color:var(--brand-700)}
.about-quote cite a:hover{color:var(--brand-500)}
@media(max-width:900px){
  .about-quote-grid{grid-template-columns:1fr;gap:32px}
  .about-quote-photo{max-width:380px;margin:0 auto}
}

/* About: pricing model comparison, three designed columns */
.pricing-compare{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px;margin:0 auto 44px}
.pricing-model .pricing-link{display:inline-flex;align-items:center;gap:6px;margin-top:16px;font-size:13.5px;font-weight:700;color:var(--brand-500)}
.pricing-model .pricing-link:hover{color:var(--brand-700)}
@media(max-width:1080px){.pricing-compare{grid-template-columns:repeat(2,minmax(0,1fr))}}
.pricing-model{background:#fff;border:1px solid var(--line);border-radius:var(--r-lg);padding:34px 32px;box-shadow:var(--shadow-sm)}
.pricing-model>h3{font-size:22px;font-weight:800;letter-spacing:-.01em;margin-bottom:20px;padding-bottom:16px;border-bottom:1px solid var(--line)}
.pricing-model .checklist{margin:0;max-width:none}
.pricing-model .checklist li{font-size:14.5px;margin-bottom:13px}
@media(max-width:860px){.pricing-compare{grid-template-columns:1fr;gap:20px}}

/* About: numbers row (3 stats, card treatment scoped so the homepage .stat
   overrides at the top of the file do not flatten these) */
.about-numbers-row{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:22px;max-width:920px;margin:0 auto 42px}
.about-numbers-row .stat{position:static;text-align:center;background:#fff;border:1px solid var(--line);border-right:1px solid var(--line);border-radius:var(--r-md);padding:30px 24px;box-shadow:var(--shadow-sm)}
.about-numbers-row .stat b{display:block;font-family:'Plus Jakarta Sans',sans-serif;font-size:40px;font-weight:800;color:var(--brand-700);line-height:1.05}
.about-numbers-row .stat span{display:block;margin-top:8px;color:var(--ink-500);font-size:13.5px}
@media(max-width:760px){.about-numbers-row{grid-template-columns:1fr;gap:14px}}

/* About: grouped tech stack with the source's real logos */
.stack-groups{display:grid;gap:22px;max-width:1080px;margin:0 auto}
.stack-group{background:#fff;border:1px solid var(--line);border-radius:var(--r-lg);padding:26px 28px;box-shadow:var(--shadow-sm)}
.stack-group>h3{font-size:17px;font-weight:800;letter-spacing:-.01em;margin-bottom:16px}
.stack-badges{display:flex;flex-wrap:wrap;gap:10px}
.stack-badge{display:inline-flex;align-items:center;gap:9px;padding:8px 14px 8px 9px;background:var(--surface-50);border:1px solid var(--line);border-radius:999px;font-size:13.5px;font-weight:650;color:var(--ink-700)}
.stack-badge img{width:24px;height:24px;object-fit:contain;display:block}
@media(max-width:620px){.stack-group{padding:20px 18px}}

/* Rev 22 (Brian): storytelling treatments for the 4 company pages.
   Same brand tokens everywhere: brand red accents, Jakarta display type,
   the soft-tinted surfaces, and one consistent photo frame. */

/* Framed real photo: white mat + brand-offset backplate so every story image
   reads as deliberate ("you can use a frame for them") */
.story-frame{margin:0;position:relative;z-index:0;background:#fff;border:1px solid var(--line);border-radius:var(--r-md);padding:12px;box-shadow:var(--shadow-md)}
.story-frame::before{content:"";position:absolute;top:14px;left:14px;right:-12px;bottom:-12px;border:2px solid var(--brand-100);border-radius:var(--r-md);z-index:-1}
.story-frame img{display:block;width:100%;height:100%;object-fit:cover;border-radius:calc(var(--r-md) - 6px)}

/* Story split: narrative copy beside a framed photo */
.story-split{display:grid;grid-template-columns:minmax(0,1.16fr) minmax(0,1fr);gap:56px;align-items:center;max-width:1080px;margin:0 auto}
.story-copy h2{font-size:clamp(28px,3.2vw,42px);font-weight:800;letter-spacing:-.03em;margin-bottom:18px}
.story-copy .story-lede{color:var(--ink-700);font-size:17px;line-height:1.75;margin-bottom:16px}
.story-marks{display:flex;gap:34px;flex-wrap:wrap;margin-top:26px;padding-top:22px;border-top:1px solid var(--line)}
.story-mark b{display:block;font-family:'Plus Jakarta Sans',sans-serif;font-size:30px;font-weight:800;color:var(--brand-700);line-height:1.05}
.story-mark small{display:block;margin-top:5px;color:var(--ink-500);font-size:12.5px;max-width:16ch;line-height:1.4}
@media(max-width:900px){
  .story-split{grid-template-columns:1fr;gap:34px}
  .story-frame{max-width:520px;margin:0 auto;width:100%}
}

/* Brand pull-quote: the statement lines that carry each page's voice */
.pull-quote{position:relative;max-width:880px;margin:52px auto 0;padding:6px 0 0 30px;border-left:3px solid var(--brand-500)}
.pull-quote p{font-family:'Plus Jakarta Sans',sans-serif;font-size:clamp(20px,2.4vw,27px);font-weight:750;letter-spacing:-.02em;line-height:1.45;color:var(--ink-900)}
.pull-quote p+p{margin-top:12px}
.pull-quote cite{display:block;margin-top:16px;font-style:normal;font-weight:650;font-size:14px;color:var(--ink-500)}
.pull-quote cite a{color:var(--brand-700)}
.pull-quote cite a:hover{color:var(--brand-500)}

/* Rev 22: linked expertise cards and tech-stack badges (Brian: link each
   industry and each tech to its landing page) */
a.stack-badge{transition:border-color var(--dur-2) var(--ease-out),color var(--dur-2) var(--ease-out),box-shadow var(--dur-2) var(--ease-out),transform var(--dur-2) var(--ease-out)}
a.stack-badge:hover{border-color:var(--brand-500);color:var(--brand-700);box-shadow:var(--shadow-sm);transform:translateY(-1px)}
.audience-tiles.cols-5 .card-link .more,.tiles-grid-4 .card-link .more{font-size:12.5px}
.audience-tiles.cols-5 .card-link .more{display:block;margin-top:8px}

/* Rev 25g: conversion-first contact hero (form above the fold) */
.hero-contact .hero-split-grid{grid-template-columns:minmax(0,1fr) minmax(420px,460px);gap:56px;align-items:start}
.hero-contact .hs-form .form-card{padding:28px 26px;border:1px solid var(--line);box-shadow:var(--shadow-lg)}
.form-card-title{font-family:'Plus Jakarta Sans',sans-serif;font-size:20px;font-weight:800;letter-spacing:-.015em;margin-bottom:18px}
.hero-contact .field{margin-bottom:14px}
.hero-contact .contact-promises{margin-top:26px}
.hero-contact .contact-channels{margin-top:18px;max-width:440px}
.hero-contact .hero-trustline{margin-top:22px;margin-bottom:0}
.btn-block{width:100%;justify-content:center;display:flex}
@media(max-width:900px){.hero-contact .hero-split-grid{grid-template-columns:1fr;gap:36px}.hero-contact .hs-form{text-align:left}.hero-contact .contact-channels{margin-left:auto;margin-right:auto}}

/* Rev 23: contact page (friendly, professional, clean) */
.contact-promises{display:flex;gap:14px;flex-wrap:wrap;margin-top:30px}
.promise{display:inline-flex;align-items:center;gap:10px;padding:12px 18px 12px 14px;background:#fff;border:1px solid var(--line);border-radius:12px;color:var(--brand-500)}
.promise b{display:block;font-family:'Plus Jakarta Sans',sans-serif;font-size:17px;font-weight:800;color:var(--ink-950);line-height:1.1}
.promise small{display:block;color:var(--ink-500);font-size:12px;margin-top:2px}
.field-row{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.field-row .field{min-width:0}
.form-note{display:flex;align-items:center;gap:8px;margin-top:16px;font-size:13px;color:var(--ink-500)}
.form-note svg{color:var(--brand-500);flex:none}
.contact-side{display:flex;flex-direction:column;gap:18px}
.contact-side-photo{margin-bottom:6px}
.contact-side-photo img{aspect-ratio:16/10}
.contact-side-copy h3{font-size:18px;font-weight:800;letter-spacing:-.01em;margin-bottom:8px}
.contact-side-copy p{font-size:14.5px;color:var(--ink-500);line-height:1.7}
.contact-channels{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.channel{display:flex;align-items:center;gap:12px;padding:16px;background:#fff;border:1px solid var(--line);border-radius:12px;color:var(--brand-500);transition:border-color var(--dur-2) var(--ease-out),box-shadow var(--dur-2) var(--ease-out),transform var(--dur-2) var(--ease-out)}
.channel:hover{border-color:var(--brand-500);box-shadow:var(--shadow-sm);transform:translateY(-1px)}
.channel b{display:block;font-size:13.5px;font-weight:700;color:var(--ink-950)}
.channel small{display:block;font-size:12.5px;color:var(--ink-500);margin-top:2px;word-break:break-word}
.channel>span{min-width:0}
#contact-form-section .contact-grid{margin-top:8px}
#contact-form-section .info-card{background:#fff;margin-bottom:0}
.office-cards{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:24px;max-width:920px;margin:0 auto}
.office-card{background:var(--surface-50);border:1px solid var(--line);border-radius:var(--r-lg);padding:32px 30px}
.office-card .ic{width:48px;height:48px;border-radius:14px;background:#fff;color:var(--brand-500);display:flex;align-items:center;justify-content:center;margin-bottom:16px;box-shadow:var(--shadow-sm)}
.office-card h3{font-size:19px;font-weight:800;letter-spacing:-.01em;margin-bottom:8px}
.office-card p{font-size:14.5px;color:var(--ink-500);line-height:1.65}
.office-card .office-note{margin-top:8px;color:var(--ink-700)}
.office-card .office-line{margin-top:10px;padding-top:10px;border-top:1px solid var(--line);font-weight:700;color:var(--ink-900)}
.office-card .office-line a{color:inherit;text-decoration:none}
.office-card .office-line a:hover{color:var(--brand-700);text-decoration:underline;text-underline-offset:2px}
@media(max-width:760px){
  .field-row{grid-template-columns:1fr}
  .contact-channels{grid-template-columns:1fr}
  .office-cards{grid-template-columns:1fr}
  .contact-promises{gap:10px}
}
#contact-form-section,#contact-offices,#contact-people,#blog-topics{scroll-margin-top:104px}

/* ---- Rev 83 ask 6: the human-presence band on /contact/ -------------------
   Two columns of DIFFERENT kinds, so they are sized by content rather than
   split 50/50: the photograph carries the room, the signed note carries the
   words, and the note needs the narrower measure of the two to stay readable.
   1.15fr/1fr lands the note's paragraphs near 60 characters at 1440. */
.people-split{display:grid;grid-template-columns:1.15fr 1fr;gap:40px;align-items:center;max-width:1060px;margin:0 auto}
.people-photo{margin:0}
/* Rev 84. Was `aspect-ratio:4/3`, inherited from when this slot held a 1024x768
   photo that happened to be exactly 4:3, so the crop was a no-op and nobody
   noticed it was there. The replacement picture is 1024x642 (1.59), and its
   subjects stand in a line that runs edge to edge, so a centred 4:3 crop cut a
   person off each side. Letting the intrinsic ratio drive is the fix, and it is
   also the durable one: the next photo swap cannot silently crop either.
   `.story-frame img` sets height:100% and object-fit:cover, so both have to be
   unset here or the ratio never gets a chance to apply. No layout shift: the
   <img> carries width/height attributes, so the box is reserved before load. */
.people-photo img{aspect-ratio:auto;height:auto;object-fit:contain}
.people-photo figcaption{display:flex;align-items:center;gap:8px;margin-top:12px;font-size:13.5px;color:var(--ink-500);line-height:1.5}
.people-photo figcaption svg{flex:none;color:var(--brand-500)}
.people-note{background:#fff;border:1px solid var(--line);border-radius:var(--r-lg);padding:32px 30px;box-shadow:var(--shadow-md)}
.people-note>p{font-size:15px;color:var(--ink-700);line-height:1.7}
.people-sig{display:flex;align-items:center;gap:14px}
/* The source is 1024x1024, so cover crops nothing horizontally and the only
   free variable is where the face sits vertically -- tuned by measurement, not
   guessed: 50% 30% is where the eyeline lands in the 64px circle. */
.people-face{width:64px;height:64px;flex:none;border-radius:50%;object-fit:cover;object-position:50% 30%;border:1px solid var(--line)}
.people-name{font-family:'Plus Jakarta Sans',sans-serif;font-size:17px;font-weight:800;letter-spacing:-.015em;line-height:1.25;color:var(--ink-900)}
.people-role{font-size:13.5px;color:var(--ink-500);line-height:1.5;margin-top:2px}
.people-quote{margin:20px 0 0;padding:0 0 0 16px;border-left:3px solid var(--brand-100)}
.people-quote p{font-size:16.5px;font-weight:600;line-height:1.55;color:var(--ink-900);letter-spacing:-.01em}
.people-note>p{margin-top:18px}
.people-links{margin-top:18px;font-size:14px;display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.people-links a{color:var(--brand-700);font-weight:700;text-decoration:none}
.people-links a:hover{text-decoration:underline;text-underline-offset:2px}
.people-links span{color:var(--ink-500)}
@media(max-width:900px){
  .people-split{grid-template-columns:1fr;gap:28px;max-width:560px}
}

/* ---- Rev 83 ask 3: the offices band + Hanoi map --------------------------
   The two address cards stack in the left column so the map can hold a column
   of its own. .office-cards is a 2-up grid everywhere else it is used, so the
   single-column override is scoped to this wrapper rather than changing it. */
/* align-items:stretch (the grid default, stated because it is load-bearing) is
   what makes the map exactly as tall as the two address cards beside it. Left
   to `start` the map sized itself from its own content and measured 677 against
   the cards' 522, which read as a mistake rather than a design. */
.office-split{display:grid;grid-template-columns:1fr 1.1fr;gap:32px;align-items:stretch;max-width:1060px;margin:0 auto}
/* align-content:start is the Rev 79 rule applied to a column: the cards keep
   their natural height instead of being stretched to fill the row the map sets.
   Stretched, the Hanoi card painted 130px of empty space below its phone number
   INSIDE a bordered box, which is the exact defect the case-study guard bans.
   A short column beside a tall one is fine; a hole inside a card is not. */
.office-split .office-cards{grid-template-columns:1fr;gap:20px;max-width:none;margin:0;align-content:start}
.office-split .office-card{padding:26px 26px}
.office-split .office-card .ic{width:42px;height:42px;border-radius:12px;margin-bottom:12px}
@media(max-width:900px){
  .office-split{grid-template-columns:1fr;max-width:560px}
}

/* The map. Both states occupy EXACTLY the same box, so pressing "Load map" does
   not move the page under the finger that pressed it. That is not decoration:
   the first build let the iframe size itself and it came out 268x724 against
   the panel's 536x675 -- half the width, 49px taller. Two causes, both fixed
   here. The frame was a flex item with `width:100%` but no `flex`, so it shrank
   to the iframe's 300px intrinsic default; and neither child was told to fill
   the container, so each state measured its own content.
   Now .at-map is a column flexbox of full height, and panel and frame are
   interchangeable `flex:1` children of it. */
.at-map{position:relative;border:1px solid var(--line);border-radius:var(--r-lg);overflow:hidden;background:var(--surface-50);min-height:420px;height:100%;display:flex;flex-direction:column}
.at-map-panel{flex:1;min-height:0;display:flex;flex-direction:column}
/* A fixed band, not a flexible one. Given the leftover height it grew to 400px
   of pale grid with a dot in it, which read as an image that had failed to load
   rather than as a placeholder for a map -- the opposite of the confidence this
   panel exists to carry. At 190px it is clearly a header treatment. `slice`
   crops it like a photograph so the pin stays centred at any width. */
.at-map-plate{flex:none;height:190px;line-height:0;border-bottom:1px solid var(--line)}
.at-map-plate svg{display:block;width:100%;height:100%}
.at-map-body{flex:1;display:flex;flex-direction:column;padding:22px 24px 24px;background:#fff}
.at-map-name{display:flex;align-items:center;gap:8px;font-family:'Plus Jakarta Sans',sans-serif;font-size:16px;font-weight:800;letter-spacing:-.015em;color:var(--ink-900)}
.at-map-name svg{flex:none;color:var(--brand-500)}
.at-map-addr{margin-top:6px;font-size:14.5px;color:var(--ink-700);line-height:1.6}
.at-map-note{margin-top:12px;font-size:13px;color:var(--ink-500);line-height:1.6}
.at-map-actions{margin-top:auto;padding-top:18px;display:flex;flex-wrap:wrap;gap:10px}
.at-map-actions .btn{min-height:44px}
.at-map-load{display:inline-flex;align-items:center;gap:8px}
.at-map-fine{margin-top:12px;font-size:12.5px;color:var(--ink-500);line-height:1.6}
/* The inline "Cookie settings" trigger. A button, because it opens a dialog and
   does not navigate, styled as a link because that is what it reads as in a
   sentence. Same 24px minimum target as the footer's copy of this control. */
.linkish{font:inherit;font-size:inherit;color:var(--brand-700);background:none;border:0;padding:0;min-height:24px;cursor:pointer;font-weight:700;text-decoration:underline;text-underline-offset:2px}
.linkish:hover{color:var(--brand-500)}
.linkish:focus-visible{outline:2px solid var(--brand-500);outline-offset:3px;border-radius:4px}
/* Absolutely positioned over the box rather than laid out inside it. The frame
   then inherits the locked height exactly, with no flex-basis or percentage
   chain in between -- both of which resolved wrongly when the panel left flow. */
.at-map-frame{position:absolute;inset:0;line-height:0}
.at-map-frame iframe{display:block;width:100%;height:100%;border:0}
@media(max-width:900px){
  /* Stacked, so the map no longer borrows the cards' height and needs a real
     one of its own. 420 is the same floor the desktop box carries. */
  .at-map{height:auto;min-height:420px}
}

/* ================= Rev 24 (Brian): homepage redesign ================= */
/* Photo media cards: home services (12) and benefits (6). Real AgileTech photos on top, copy below. */
.media-cards{display:grid;gap:24px}
.services-grid.media-cards{grid-template-columns:repeat(3,minmax(0,1fr))}
.benefits-grid{grid-template-columns:repeat(3,minmax(0,1fr))}
.media-card{display:flex;flex-direction:column;background:#fff;border:1px solid var(--line);border-radius:14px;overflow:hidden;transition:transform var(--dur-2) var(--ease-out),box-shadow var(--dur-2) var(--ease-out),border-color var(--dur-2) var(--ease-out)}
/* Ask 8 (Rev 66): the benefits cards get the services cards' hover.

   Both grids are `.media-card`, but the services cards are <a> and the benefits
   cards are <div class="media-card-static">, and every hover rule here was
   scoped `a.media-card:hover`. So the shared class made the two grids look
   identical at rest and behave differently the moment a cursor arrived, which
   is what the client noticed. The lift, the shadow, the border and the art
   scale are all now written for both.

   The benefits cards STAY divs. There is no per-benefit page to link to, and
   AGENTS.md rule 2 forbids inventing one; wrapping them in an <a href="#"> or
   pointing all six at /about-us/ would buy the hover with a dishonest link and
   six identical destinations. `.media-card-static` was already in the markup as
   a marker with no rule attached, so it is the hook.

   This is a pointer-only affordance by design: a hover that lifts a card the
   keyboard cannot reach is correct here precisely BECAUSE the card is not
   interactive. It signals "this panel is a unit" rather than "this panel is a
   link", so no focus style is added and no ARIA role is claimed. */
a.media-card:hover,
.media-card-static:hover{border-color:#D5D5DB;box-shadow:var(--shadow-lg);transform:translateY(-4px)}
/* Rev 25: framed-photo treatment (white mat + offset brand backplate on a tinted pad)
   so the service/benefit photos read as mounted real team photos */
.mc-media{padding:18px 24px 12px 16px;background:linear-gradient(160deg,var(--brand-50),#FFF 78%)}
.mc-frame{position:relative;display:block;z-index:0;background:#fff;border:1px solid var(--line);border-radius:10px;padding:8px;box-shadow:var(--shadow-sm)}
.mc-frame::before{content:"";position:absolute;top:10px;left:10px;right:-8px;bottom:-8px;border:2px solid var(--brand-100);border-radius:10px;z-index:-1}
.mc-crop{display:block;aspect-ratio:4/3;overflow:hidden;border-radius:8px;background:var(--surface-50)}
.mc-crop img{width:100%;height:100%;object-fit:cover;display:block;transition:transform var(--dur-3) var(--ease-out)}
a.media-card:hover .mc-crop img,
.media-card-static:hover .mc-crop img{transform:scale(1.045)}
/* Rev 58 (ask 4): the service/benefit cards now carry authored SVG art instead of
   AI-generated photographs. An <svg> is not a replaced element the way <img> is, so
   it needs width/height stated explicitly or it collapses to its intrinsic size and
   leaves the .mc-crop pad showing. Mirrors .sol-art, and repeats the same hover
   scale the removed <img> provided so the interaction is unchanged. */
.card-art{width:100%;height:100%;display:block;transition:transform var(--dur-3) var(--ease-out)}
a.media-card:hover .card-art,
.media-card-static:hover .card-art{transform:scale(1.045)}

/* ==========================================================================
   Asks 6, 7 and 8 (Rev 68): bring the card art alive
   ==========================================================================
   The client asked for the Our Services icons (naming offshore development and
   AI/data services), the Benefits of Choosing AgileTech icons (naming expertise
   and cost-effective solutions) and the homepage Our Solutions icons to move,
   and pointed at ONE reference: "the effect that you made for the Flexible Ways
   to Work Together section". So this is not a free hand. That section is the
   .viz-anim / sig-* system at L917-1070, and the brief is to speak the same
   visual language on a different surface.

   WHAT IS BORROWED FROM sig-*, UNCHANGED
   --------------------------------------
   Three properties are what make the reference read as calm rather than busy,
   so they are kept exactly:
     - a signal TRAVELS a path that already exists in the static drawing; the
       art is never re-arranged, only traced;
     - one shared period per diagram so elements stay in phase forever, offset
       per element by --d (animation-delay only shifts the FIRST iteration, so
       mixed durations would drift apart within a minute);
     - compositor-only properties (transform, opacity) plus stroke-dashoffset.
       Nothing animates geometry, so nothing can reflow.

   WHAT IS DELIBERATELY DIFFERENT, AND WHY
   ---------------------------------------
   The reference runs `animation-iteration-count:infinite` and that is right for
   it: TWO diagrams, each ~600px wide, one per screenful, and a visitor looks at
   them deliberately. The surfaces in these three asks are not that. The homepage
   alone carries 15 service cards + 6 benefit cards + 17 solution cards = 38
   animated figures, in grids of three, mostly several to a screenful at once.

   Copying `infinite` onto 38 small tiles would produce a page that never stops
   twitching in the reader's peripheral vision while they try to read the copy
   beside it - which is the "very annoying" class of defect the client has
   already objected to once (the earlier perpetual-motion complaint that made
   .nav-cta a bounded burst in Rev 66). An "alive" tile and a restless page are
   different things, and 38 of them is the difference.

   So the vocabulary below is BOUNDED: `--ca-runs` cycles on arrival, then still.
   Each figure animates only while it is actually on screen (.ca-live, set by an
   IntersectionObserver in app.js), so a visitor sees each tile come alive as
   they reach it, exactly once, and reads the text next to a settled picture.
   Hover replays it, which is where a deliberate look deserves a response.

   This also disposes of WCAG 2.2.2 without an argument: that criterion wants a
   pause control for auto-starting motion running longer than five seconds, and
   at 4.6s x 2 runs nothing here does. WCAG 2.3.1 (three flashes per second) is
   clear by a wide margin: the fastest element pulses about once per 2.3s.

   THE GATE, restated because it is the whole safety story:
     .ca-anim is visibility:hidden by default -> no-JS visitors and anyone with
     prefers-reduced-motion see precisely the static Rev 58 art, and the
     "Reduced motion" claim on /accessibility/ stays true.  */
.ca-anim{visibility:hidden}
@media(prefers-reduced-motion:no-preference){
  /* --ca-cycle is the shared period; --ca-runs the bounded repeat count. The
     three surfaces get slightly different periods (see the tokens below) so a
     grid of neighbouring cards never locks into one synchronised heartbeat,
     which is what would turn 17 calm tiles into one flashing block. */
  .ca-live .ca-anim{visibility:visible}
  .ca-live .ca-dot,
  .ca-live .ca-ring,
  .ca-live .ca-wire,
  .ca-live .ca-lift,
  .ca-live .ca-glow{
    animation-duration:var(--ca-cycle,4.6s);
    animation-delay:calc(var(--d,0) * var(--ca-cycle,4.6s));
    animation-iteration-count:var(--ca-runs,2);
    animation-fill-mode:both;
  }
  /* A signal travelling a straight run, endpoints supplied per element from the
     SVG as plain viewBox numbers. Unitless vars are multiplied by 1px to become
     lengths; inside an SVG those are user units. One keyframe set serves every
     connector on all 40 motifs, so no motif needs a rule of its own. */
  .ca-live .ca-dot{animation-name:ca-fly;animation-timing-function:cubic-bezier(.45,0,.55,1)}
  @keyframes ca-fly{
    0%{transform:translate(calc(var(--x1) * 1px),calc(var(--y1) * 1px));opacity:0}
    9%{opacity:1}
    34%{transform:translate(calc(var(--x2) * 1px),calc(var(--y2) * 1px));opacity:1}
    43%,100%{transform:translate(calc(var(--x2) * 1px),calc(var(--y2) * 1px));opacity:0}
  }
  /* An expanding acknowledgement at a node. transform-box:fill-box with a 50%
     origin scales from the shape's own centre, so cx/cy never appear in CSS. */
  .ca-live .ca-ring{
    transform-box:fill-box;transform-origin:50% 50%;
    animation-name:ca-ring;animation-timing-function:ease-out;
  }
  @keyframes ca-ring{
    0%{transform:scale(.7);opacity:0}
    8%{opacity:.75}
    30%{transform:scale(1.5);opacity:0}
    100%{transform:scale(1.5);opacity:0}
  }
  /* A comet drawn along a curve. pathLength="100" on the element normalises
     every path to 100 units, so this single 0 -> -100 sweep fits any arc length
     without per-path arithmetic. Dash is 8 lit + 92 dark: exactly one visible
     segment at a time. 8 not 4 (the reference's value) because these motifs are
     painted into a ~290px-wide card from a 400-unit viewBox, i.e. ~0.72px per
     unit, so 4 units would render a ~3px speck that reads as dirt rather than
     as a signal. Measured on the rendered card, not assumed from the reference. */
  .ca-live .ca-wire{
    stroke-dasharray:8 92;
    animation-name:ca-wire;animation-timing-function:cubic-bezier(.4,0,.6,1);
  }
  @keyframes ca-wire{
    0%{stroke-dashoffset:0;opacity:0}
    7%{opacity:1}
    46%{stroke-dashoffset:-100;opacity:1}
    54%,100%{stroke-dashoffset:-100;opacity:0}
  }
  /* A element rising a little and settling: growth, arrival, a plate seating
     into place. Translation only, so it cannot disturb a neighbour. */
  .ca-live .ca-lift{
    transform-box:fill-box;transform-origin:50% 100%;
    animation-name:ca-lift;animation-timing-function:cubic-bezier(.34,1.4,.64,1);
  }
  @keyframes ca-lift{
    0%{transform:translateY(6px);opacity:.35}
    18%,74%{transform:translateY(0);opacity:1}
    100%{transform:translateY(0);opacity:1}
  }
  /* A held element brightening once, for the "this is the outcome" shape that
     every motif ends on. Opacity only: the brand fill underneath is unchanged,
     so no measured contrast value moves. */
  .ca-live .ca-glow{animation-name:ca-glow;animation-timing-function:ease-in-out}
  @keyframes ca-glow{
    0%,22%{opacity:0}
    38%{opacity:.9}
    62%{opacity:0}
    100%{opacity:0}
  }
  /* A deliberate look deserves a response: hover/focus replays the sequence.
     `alternate` is not used - the run must always read forwards. */
  .media-card:hover .ca-anim,.media-card:focus-visible .ca-anim,
  .sol-card:hover .ca-anim,.sol-card:focus-visible .ca-anim,
  .img-card:hover .ca-anim{animation-iteration-count:var(--ca-runs,2)}
}
/* Stated explicitly as well as being scoped out above. A held transform on a
   stuck pseudo-animation is exactly what a later edit reintroduces by accident,
   and this art sits on 173 pages. */
@media(prefers-reduced-motion:reduce){
  .ca-anim{visibility:hidden!important;animation:none!important}
}
/* Per-surface periods, coprime-ish so three neighbouring cards never sync into
   one block-wide pulse. Service cards are the densest grid, so they get the
   longest breath. */
.ca-svc{--ca-cycle:5.1s}
.ca-ben{--ca-cycle:4.3s}
.ca-sol{--ca-cycle:4.7s}
.mc-body{padding:16px 22px 22px;display:flex;flex-direction:column;flex:1}
.mc-body h3{font-size:17px;font-weight:750;letter-spacing:-.01em;margin-bottom:6px;color:var(--ink-900)}
.mc-body p{font-size:14px;line-height:1.62;color:var(--ink-500);margin:0}
.mc-body .more{color:var(--brand-700);font-size:13px;font-weight:650;margin-top:auto;padding-top:12px}
.benefits-grid .mc-body{padding:18px 24px 26px}
.benefits-grid .mc-body h3{font-size:17.5px}
.benefits-grid .mc-body p{font-size:14px;line-height:1.65}
/* Solution cards: curated SVG art backdrop per card, brand palette only */
/* Rev 39: flex, not grid. The homepage now lists all 17 solutions (was 12), and 17
   divides evenly into neither 3 nor 2 columns, so a grid always left a ragged
   trailing row hanging off to the left (3,3,3,3,3,2 at desktop; a lone card at
   tablet). Flex with justify-content:center centres ONLY the partial last row -
   full rows fill the line exactly and stay flush - and it does so for any count,
   so adding an 18th solution needs no CSS change. Widths use 33.333/50 rather
   than 100/3 so sub-pixel rounding can never push a row to wrap. */
.sol-cards{display:flex;flex-wrap:wrap;justify-content:center;gap:24px}
.sol-cards>.sol-card{flex:0 1 calc(33.333% - 16px)}
.sol-card{display:flex;flex-direction:column;background:#fff;border:1px solid var(--line);border-radius:14px;overflow:hidden;transition:transform var(--dur-2) var(--ease-out),box-shadow var(--dur-2) var(--ease-out),border-color var(--dur-2) var(--ease-out)}
.sol-card:hover{border-color:var(--brand-100);box-shadow:var(--shadow-lg);transform:translateY(-4px)}
.sol-media{height:158px;overflow:hidden;border-bottom:1px solid #F3E2E5}
.sol-art{width:100%;height:100%;display:block;transition:transform var(--dur-3) var(--ease-out)}
.sol-card:hover .sol-art{transform:scale(1.05)}
.sol-body{padding:20px 22px 22px}
.sol-body h3{font-size:16.5px;font-weight:750;letter-spacing:-.01em;margin-bottom:6px;color:var(--ink-900)}
.sol-body p{font-size:13.5px;line-height:1.6;color:var(--ink-500);margin:0}
@media(max-width:1020px){
  .services-grid.media-cards{grid-template-columns:repeat(2,minmax(0,1fr))}
  .sol-cards>.sol-card{flex-basis:calc(50% - 12px)}
  .benefits-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
}
@media(max-width:640px){
  .services-grid.media-cards,.benefits-grid{grid-template-columns:1fr}
  .sol-cards>.sol-card{flex-basis:100%}
  .mc-crop{aspect-ratio:16/9}
}

/* ================= Rev 25 (Brian): homepage upgrades ================= */
/* CTA banner with a real team photo (trust) */
.cta-banner-photo{grid-template-columns:minmax(0,1.1fr) minmax(0,.9fr);align-items:stretch;gap:0;padding:0;overflow:hidden}
.cta-banner-photo .cta-copy{padding:64px 48px 64px 64px;display:flex;flex-direction:column;align-items:flex-start;justify-content:center;position:relative;z-index:1}
.cta-banner-photo .cta-copy .btn{margin-top:30px}
.cta-banner-media{margin:0;min-height:300px;position:relative}
.cta-banner-media::before{content:"";position:absolute;inset:0;background:linear-gradient(90deg,var(--ink-950) 0,transparent 30%);z-index:1;pointer-events:none}
.cta-banner-media img{width:100%;height:100%;object-fit:cover;display:block}
@media(max-width:760px){
  .cta-banner-photo{padding:0}
  .cta-banner-photo .cta-copy{padding:38px 26px}
  .cta-banner-media{min-height:210px}
  .cta-banner-media::before{background:linear-gradient(180deg,transparent 60%,var(--ink-950))}
}
/* Hero stat band: five verified proof points with count-up numerals.
   Rev 25b (Brian): numerals much larger for first-sight impact, crimson
   brand color, staggered rise-in per stat, count-up pop. */
.stats-5{grid-template-columns:repeat(5,1fr)}
.stats-5 .stat{padding:38px 30px 36px}
.stats-5 .stat b{display:flex;align-items:baseline;gap:3px;font-size:clamp(40px,3.6vw,58px);font-weight:800;letter-spacing:-.03em;color:var(--brand-700);line-height:1}
/* .stat span (global) sets 14px; the numeral spans inside <b> must inherit the big size */
.stats-5 .stat b span{font-size:inherit;color:inherit;margin:0;display:inline}
.stats-5 .stat b .count{font-variant-numeric:tabular-nums}
.stats-5 .stat b .suf{color:var(--brand-500);font-size:.62em;font-weight:800}
.stats-5 .stat>span:last-child{font-size:14px;font-weight:600;color:var(--ink-900);margin-top:12px;letter-spacing:.01em;max-width:18ch;display:block}
/* Rev 46: was `left:34px`, inherited from the base .stat padding of 34px -- but
   .stats-5 .stat overrides padding to 30px, so every underline sat 4px right of
   the numeral above it. Measured (numeral left 162px vs rule left 166px), not
   guessed. Uses the padding value itself so the two cannot drift again. */
.stats-5 .stat::after{content:"";position:absolute;left:30px;bottom:24px;width:26px;height:3px;border-radius:2px;background:var(--brand-100);transition:width .5s ease .3s,background .5s ease .3s}
.stats-5 .stat{padding-bottom:44px}
.js .stats-5 .stat{opacity:0;transform:translateY(22px);transition:opacity var(--dur-3) var(--ease-out),transform var(--dur-3) var(--ease-out)}
.js .stats-5.in .stat{opacity:1;transform:none}
.stats-5.in .stat::after{width:52px;background:var(--brand-500)}
.stats-5 .stat:nth-child(2){transition-delay:.09s}
.stats-5 .stat:nth-child(3){transition-delay:.18s}
.stats-5 .stat:nth-child(4){transition-delay:.27s}
.stats-5 .stat:nth-child(5){transition-delay:.36s}
@media(prefers-reduced-motion:reduce){.js .stats-5 .stat{opacity:1;transform:none;transition:none}}
@media(max-width:1180px){.stats-5 .stat{padding:30px 22px 38px}.stats-5 .stat b{font-size:clamp(34px,3.4vw,44px)}}
@media(max-width:980px){
  .stats-5{grid-template-columns:repeat(5,1fr)}
  .stats-5 .stat{padding:24px 14px 34px}
  .stats-5 .stat b{font-size:28px}
  .stats-5 .stat>span:last-child{font-size:12px}
  .stats-5 .stat::after{left:14px;bottom:16px}
}
@media(max-width:760px){
  .hero-home .stats-5,.stats-5{grid-template-columns:repeat(2,1fr)}
  .stats-5 .stat{border-bottom:1px solid var(--line);border-right:1px solid var(--line);padding:24px 18px 34px}
  .stats-5 .stat:nth-child(2n){border-right:0}
  .stats-5 .stat:nth-child(5){border-bottom:0;grid-column:1/-1;border-right:0}
  .stats-5 .stat:nth-child(4){border-bottom:0}
  .stats-5 .stat b{font-size:34px}
  .stats-5 .stat::after{left:18px}
}
/* Homepage FAQ, and (Rev 80) the featured case-study band.
   Neither id is linked from anywhere on this site, so this is defensive rather than
   navigational: both are public fragment targets that a newsletter, a chat answer or a
   search result can jump straight to, and the header is sticky, so without a
   scroll-margin the band arrives with its heading underneath it. 104px is the existing
   value and it clears the header at both widths measured today (73px at 1440, 65px at
   390). check_home_order.py asserts the margin still clears the painted header, so
   growing the header cannot silently start burying the heading. */
#home-faq,#case-studies{scroll-margin-top:104px}
/* Rev 25 T2.4: company news */
/* Company News hub (Rev 25i): editorial hero + year timeline */
.news-hero{display:grid;grid-template-columns:.9fr 1.1fr;gap:44px;align-items:center;text-decoration:none;color:inherit}
.news-hero .nh-body{display:flex;flex-direction:column;gap:14px}
.news-hero .nh-body time{font-size:13px;font-weight:600;color:var(--brand-700);letter-spacing:.02em}
.news-hero .nh-body h2{font-size:32px;line-height:1.2;margin:0;transition:color var(--dur-2) var(--ease-out)}
.news-hero:hover .nh-body h2{color:var(--brand-700)}
.news-hero .nh-body p{color:var(--ink-500);margin:0;font-size:16.5px}
.news-hero .nh-media{margin:0;border-radius:var(--r-xl);overflow:hidden;box-shadow:var(--shadow-lg);border:1px solid var(--line)}
.news-hero .nh-media img{width:100%;height:auto;display:block;transition:transform var(--dur-3) var(--ease-out)}
.news-hero:hover .nh-media img{transform:scale(1.025)}
/* Rev 25j: centered hero for the news hub (Brian: description must not sit left with an empty right side) */
.hero-centered{text-align:center}
.hero-centered .crumbs{justify-content:center}
.hero-centered h1{margin-left:auto;margin-right:auto;max-width:20ch}
.hero-centered .sub{margin-left:auto;margin-right:auto;max-width:62ch}
.nc-body time{font-size:13px;font-weight:600;color:var(--brand-700);letter-spacing:.02em}
.news-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:28px}
.news-card{display:flex;flex-direction:column;border:1px solid var(--line);border-radius:16px;overflow:hidden;background:#FFF;text-decoration:none;color:inherit;transition:box-shadow var(--dur-2) var(--ease-out),transform var(--dur-2) var(--ease-out)}
.news-card:hover{box-shadow:0 8px 26px rgba(38,38,46,.10);transform:translateY(-2px)}
.nc-media{margin:0;aspect-ratio:1200/628;overflow:hidden}
.nc-media img{width:100%;height:100%;object-fit:cover;display:block;transition:transform var(--dur-3) var(--ease-out)}
.nc-media.nc-contain{background:var(--surface-50);border-bottom:1px solid var(--line)}
.nc-media.nc-contain img{object-fit:contain}
.news-grid-hub{grid-template-columns:repeat(3,minmax(0,1fr))}
.news-grid-hub .nc-body h3{font-size:17.5px}
.news-card:hover .nc-media img{transform:scale(1.04)}
.nc-body{padding:22px 24px 26px;display:flex;flex-direction:column;gap:10px;flex:1}
.nc-body h3{font-size:17px;line-height:1.35;margin:0}
.nc-body p{color:var(--ink-500);font-size:14px;margin:0;flex:1}
.news-featured .more,.news-card .more{color:var(--brand-500);font-weight:600;font-size:14px}
/* Rev 25j: article template polish (Brian: individual news pages need a cleaner design) */
.hero-article h1{max-width:26ch;font-size:clamp(32px,3.4vw,46px);line-height:1.15}
/* Rev 86 (Phase E): the font-size above has never once applied. `.hero-article h1`
   is (0,1,1) and `.hero.hero-page h1` at line ~1985 is (0,2,1), so the generic
   page-hero size wins no matter that this rule is written 1300 lines later.
   Proof rather than inference: on the TechBehemoths article the computed size
   measured 38px (the page-hero clamp floor, not this rule's 32px) while the line
   box measured 43.7px, which is 38 x 1.15, so `line-height` from THIS rule was
   live at the same moment its `font-size` was being discarded. Only the
   contested property lost.
   The visible cost was at 320px: 38px type in a 280px column needs 283px for the
   single word "TechBehemoths", so it overflowed its own box and the hero's
   `overflow-x:hidden` shaved the final letter. At the intended 32px that word
   measures 238px and fits with 42px to spare.
   The fix is the same scoping the house already applies to the two other heroes
   that hit this exact collision, `.hero.hero-page.hero-ceo h1` (line ~615) and
   `.hero.hero-page.cs-hero h1` (line ~4163). `.hero-article` was simply missed.
   Note this restores Rev 25j's intended article typography at EVERY width, not
   just mobile, so desktop article headings step from 56px to 46px. That is the
   design that was authored and asked for; it is called out in the Rev 86 report
   so it can be reverted deliberately rather than discovered. */
.hero.hero-page.hero-article h1{font-size:clamp(32px,3.4vw,46px)}
.news-meta{display:flex;align-items:center;justify-content:center;gap:10px;font-size:13.5px;font-weight:600;color:var(--brand-700);letter-spacing:.02em;margin:0 0 16px}
.news-meta .dotsep{width:4px;height:4px;border-radius:99px;background:var(--brand-100)}
.news-cover{margin:0 0 40px;border-radius:var(--r-xl);overflow:hidden;border:1px solid var(--line);box-shadow:var(--shadow-lg)}
.news-cover img{width:100%;height:auto;display:block;border-radius:0}
.news-article h2{font-size:25px;letter-spacing:-.02em;margin:44px 0 14px;padding-top:26px;border-top:1px solid var(--line)}
.news-article h2:first-of-type{border-top:0;padding-top:0}
.news-article p{margin:0 0 16px;color:var(--ink-700);line-height:1.8;font-size:16px}
.news-article p.lead{font-size:18.5px;line-height:1.75;color:var(--ink-900);font-weight:500}
.news-article figure{margin:30px 0}
.news-article img{width:100%;height:auto;border-radius:14px;display:block}
.news-article .fig img{border:1px solid var(--line)}
.news-article ul{margin:0 0 18px;padding-left:0}
@media(max-width:980px){.news-grid,.news-grid-hub{grid-template-columns:repeat(2,minmax(0,1fr))}.news-hero{grid-template-columns:1fr;gap:24px}.news-hero .nh-body h2{font-size:26px}}
@media(max-width:640px){.news-grid,.news-grid-hub{grid-template-columns:1fr}}
/* Rev 25 T2.5: CEO profile external links */
.profile-links{display:flex;flex-wrap:wrap;gap:12px;margin-top:22px}
.profile-links a{display:inline-flex;align-items:center;gap:8px;padding:9px 16px;border:1px solid var(--line);border-radius:999px;font-size:14px;font-weight:600;color:var(--ink-900);text-decoration:none;background:#FFF;transition:border-color var(--dur-2) var(--ease-out),color var(--dur-2) var(--ease-out)}
.profile-links a:hover{border-color:var(--brand-500);color:var(--brand-500)}
.profile-links .ico-sm{width:16px;height:16px}

/* Rev 25 T2.6: portfolio case-study detail pages */
.cs-meta{display:grid;grid-template-columns:repeat(6,1fr);gap:14px;margin:30px 0 8px}
.cs-meta .cm{background:#fff;border:1px solid var(--line);border-radius:var(--r-md);padding:16px 18px;text-align:center}
.cs-meta .cm b{display:block;font-family:'Plus Jakarta Sans',sans-serif;font-size:17px;font-weight:800;color:var(--brand-700)}
.cs-meta .cm small{display:block;margin-top:3px;font-size:12px;color:var(--ink-500);text-transform:uppercase;letter-spacing:.04em}
.cs-impact{display:grid;grid-template-columns:repeat(3,1fr);gap:20px;margin-top:34px}
.cs-impact.g4{grid-template-columns:repeat(4,1fr)}
.cs-impact .ci{background:#fff;border:1px solid var(--line);border-left:4px solid var(--brand-500);border-radius:var(--r-md);padding:26px 28px;box-shadow:var(--shadow-md)}
.cs-impact .ci b{display:block;font-family:'Plus Jakarta Sans',sans-serif;font-size:40px;font-weight:800;color:var(--brand-700);line-height:1}
.cs-impact .ci span{display:block;margin-top:10px;font-size:14px;color:var(--ink-500)}
.cs-gallery{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}
.cs-gallery figure{margin:0;border:1px solid var(--line);border-radius:var(--r-md);overflow:hidden;background:#fff;box-shadow:var(--shadow-md)}
.cs-gallery img{width:100%;aspect-ratio:10/7;object-fit:cover;display:block;transition:transform var(--dur-3) var(--ease-out)}
.cs-gallery figure:hover img{transform:scale(1.03)}
.cs-shots{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin:28px 0}
.cs-shots figure{margin:0;border:1px solid var(--line);border-radius:var(--r-md);overflow:hidden;box-shadow:var(--shadow-md)}
.cs-shots img{width:100%;display:block}
.cs-banner{margin:34px 0 0;border-radius:var(--r-lg);overflow:hidden;border:1px solid var(--line);box-shadow:var(--shadow-md)}
.cs-banner img{width:100%;display:block}
.case .body .cs-tags{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}
.case .body .cs-tags span{font-size:12px;font-weight:600;color:var(--ink-500);background:var(--surface-50);border:1px solid var(--line);border-radius:99px;padding:4px 11px}
.case .body .more{display:inline-block;margin-top:12px;font-size:13.5px;font-weight:700;color:var(--brand-500)}
@media(max-width:980px){.cs-meta{grid-template-columns:repeat(3,1fr)}.cs-impact,.cs-impact.g4{grid-template-columns:1fr 1fr}.cs-gallery{grid-template-columns:1fr 1fr}}
@media(max-width:640px){.cs-meta{grid-template-columns:1fr 1fr}.cs-impact,.cs-impact.g4{grid-template-columns:1fr}.cs-gallery{grid-template-columns:1fr}.cs-shots{grid-template-columns:1fr}}
.cases.cases-deep{grid-template-columns:1fr 1fr}
.cases-deep .case p{margin-top:8px;font-size:14px;color:var(--ink-500)}
@media(max-width:760px){.cases.cases-deep{grid-template-columns:1fr}}

/* Verified reviews responsive (Rev 25f) */
@media(max-width:980px){.rev-slide{grid-template-columns:repeat(2,1fr)}}
@media(max-width:640px){.rev-track{grid-auto-columns:88%;gap:14px}.rev-slide{display:contents}.rev-card{scroll-snap-align:start}.rev-agg{gap:12px}.rev-agg-card{padding:16px 20px;min-width:0;flex:1}.rev-agg-score{font-size:24px}.rev-agg-logo{height:24px}}

/* Rev 27: industry sub-practice (spoke) pages. Hub-and-spoke depth pages under
   an industry hub, e.g. /industries/hospitality-software-development/*. */
/* Module/capability grid: 6 -> 3x2, 4 -> 2x2 via .cols-2 */
.module-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px}
.module-grid.cols-2{grid-template-columns:repeat(2,minmax(0,1fr));max-width:1080px;margin:0 auto}
.module-grid .card{padding:26px 24px}
.module-grid .card .ic{margin-bottom:14px}
.module-grid .card h3{font-size:17.5px;font-weight:750;margin:0 0 10px;letter-spacing:-.01em}
.module-grid .card p{color:var(--ink-700);font-size:14.5px;line-height:1.65;margin:0}
@media(max-width:980px){.module-grid,.module-grid.cols-2{grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}}
@media(max-width:620px){.module-grid,.module-grid.cols-2{grid-template-columns:1fr}}

/* Technical considerations: icon beside copy, two columns of longer-form points */
.consider-grid{display:grid;grid-template-columns:1fr 1fr;gap:24px}
.consider-card{display:flex;gap:18px;background:#fff;border:1px solid var(--line);border-radius:var(--r-lg);padding:26px 24px;box-shadow:var(--shadow-sm);transition:border-color var(--dur-2) var(--ease-out),box-shadow var(--dur-2) var(--ease-out),transform var(--dur-2) var(--ease-out)}
.consider-card:hover{box-shadow:var(--shadow-lg);transform:translateY(-3px);border-color:var(--brand-100)}
.consider-card .ic{flex:0 0 auto}
.consider-card h3{font-size:17px;font-weight:750;margin:0 0 8px;letter-spacing:-.01em}
.consider-card p{color:var(--ink-700);font-size:14.5px;line-height:1.65;margin:0}
.section:not(.alt) .consider-card{background:var(--surface-50);border-color:transparent;box-shadow:none}
@media(max-width:900px){.consider-grid{grid-template-columns:1fr}}

/* Evidence band: the one place a spoke cites a REAL delivered project */
.evidence-band{background:var(--surface-50);border:1px solid var(--line);border-left:4px solid var(--brand-500);border-radius:var(--r-lg);padding:34px 38px;max-width:900px;margin:0 auto}
.section.alt .evidence-band{background:#fff}
.ev-tag{display:inline-flex;align-items:center;gap:7px;color:var(--brand-700);font-size:12.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px}
.ev-copy h2{font-size:26px;font-weight:800;margin:0 0 14px;letter-spacing:-.015em}
.ev-copy p{color:var(--ink-700);font-size:15.5px;line-height:1.75;margin:0 0 14px}
.ev-copy .ctas{margin-top:20px}
@media(max-width:620px){.evidence-band{padding:26px 22px}.ev-copy h2{font-size:22px}}

/* Trailing link under a section grid */
.section-note{text-align:center;margin-top:28px}

/* ---------------------------------------------------------------------------
   Legal pages (Rev 36)
   The six policy pages plus /legal/ ship their own markup classes. Without
   these rules they rendered as raw browser defaults: the sub-nav collapsed
   into one run-on string of link text ("Privacy PolicyCookie PolicyTerms of
   Use..."), the table of contents rendered as a bare numbered list, and the
   data-inventory tables had no borders at all. The pages returned 200 and the
   build passed throughout, because unstyled markup is still valid markup.

   Type scale is deliberately a little larger and looser than the marketing
   pages. These are documents that must survive being read end to end, and
   they are the pages a procurement or DPO reader will actually study.
   --------------------------------------------------------------------------- */

/* Review date, under the hero subtitle */
.legal-updated{margin-top:18px;font-size:13.5px;font-weight:600;color:var(--ink-500);letter-spacing:.01em}

/* Sub-nav across the six policies. Was a run-on string of bare anchors. */
.legal-nav{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;padding:26px 0 4px;border-bottom:1px solid var(--line)}
.legal-nav-link{display:inline-block;padding:8px 16px;border-radius:99px;border:1px solid var(--line);background:#fff;font-size:13.5px;font-weight:600;color:var(--ink-700);line-height:1.5;transition:background-color var(--dur-1) var(--ease-out),border-color var(--dur-1) var(--ease-out),color var(--dur-1) var(--ease-out)}
.legal-nav-link:hover{border-color:var(--brand-500);color:var(--brand-700);background:var(--brand-50)}
.legal-nav-link.is-current{background:var(--ink-950);border-color:var(--ink-950);color:#fff}

/* On-this-page contents */
.legal-toc{background:var(--surface-50);border:1px solid var(--line);border-radius:var(--r-lg);padding:24px 28px;margin:0 0 44px}
.legal-toc-title{margin:0 0 14px;font-family:'Plus Jakarta Sans',sans-serif;font-size:12.5px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--ink-500)}
.legal-toc ol{margin:0;padding:0;list-style:none;counter-reset:legaltoc;columns:2;column-gap:34px}
.legal-toc li{counter-increment:legaltoc;break-inside:avoid;margin-bottom:9px}
.legal-toc li a{position:relative;padding-left:26px;font-size:14.5px;color:var(--ink-700);line-height:1.55;display:inline-block}
.legal-toc li a::before{content:counter(legaltoc);position:absolute;left:0;top:1px;width:19px;text-align:right;font-size:12.5px;font-weight:700;color:var(--brand-500);font-variant-numeric:tabular-nums}
.legal-toc li a:hover{color:var(--brand-700);text-decoration:underline}

/* Body sections */
.legal-body .container{padding-top:8px}
.legal-sec{scroll-margin-top:100px;margin-bottom:46px}
.legal-sec:last-child{margin-bottom:0}
.legal-sec>h2{font-size:25px;font-weight:800;letter-spacing:-.015em;margin:0 0 16px;padding-top:14px;border-top:1px solid var(--line);color:var(--ink-950)}
.legal-sec:first-of-type>h2{border-top:none;padding-top:0}
.legal-sec h3{font-size:17px;font-weight:750;letter-spacing:-.01em;margin:26px 0 10px;color:var(--ink-950)}
.legal-sec p{color:var(--ink-700);font-size:15.5px;line-height:1.78;margin:0 0 15px}
.legal-sec p:last-child{margin-bottom:0}
.legal-sec strong{color:var(--ink-950);font-weight:700}
.legal-sec a{color:var(--brand-700);font-weight:600;text-decoration:underline;text-underline-offset:2px;text-decoration-thickness:1px}
.legal-sec a:hover{text-decoration-thickness:2px}
.legal-sec ul,.legal-sec ol{margin:0 0 16px;padding-left:22px;color:var(--ink-700);font-size:15.5px;line-height:1.78}
.legal-sec li{margin-bottom:9px}
.legal-sec li::marker{color:var(--brand-500)}

/* Contact blocks at the foot of a policy */
.legal-contact{list-style:none;padding:22px 26px;margin:0 0 16px;background:var(--surface-50);border:1px solid var(--line);border-left:3px solid var(--brand-500);border-radius:var(--r-md)}
.legal-contact li{margin-bottom:8px;font-size:15px;color:var(--ink-700)}
.legal-contact li:last-child{margin-bottom:0}

/* Data inventory and rights matrix tables */
.legal-tablewrap{overflow-x:auto;margin:0 0 20px;border:1px solid var(--line);border-radius:var(--r-md);-webkit-overflow-scrolling:touch}
.legal-table{width:100%;border-collapse:collapse;font-size:14.5px;min-width:520px}
.legal-table caption{caption-side:top;text-align:left;padding:14px 18px;font-size:12.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--ink-500);background:var(--surface-50);border-bottom:1px solid var(--line)}
.legal-table th,.legal-table td{padding:13px 18px;text-align:left;vertical-align:top;line-height:1.65;border-bottom:1px solid var(--line)}
.legal-table thead th{font-size:12.5px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--ink-500);background:var(--surface-50);white-space:nowrap}
.legal-table tbody th{font-weight:700;color:var(--ink-950);width:34%}
.legal-table tbody td{color:var(--ink-700)}
.legal-table tbody tr:last-child th,.legal-table tbody tr:last-child td{border-bottom:none}
.legal-table tbody tr:hover th,.legal-table tbody tr:hover td{background:var(--brand-50)}

@media(max-width:760px){
  .legal-toc ol{columns:1}
  .legal-toc{padding:20px 20px;margin-bottom:34px}
  .legal-sec>h2{font-size:22px}
  .legal-sec p,.legal-sec ul,.legal-sec ol{font-size:15px}
  .legal-nav{gap:6px;padding-top:20px}
  .legal-nav-link{padding:7px 13px;font-size:12.5px}
}

/* Footer legal strip (Rev 36). Sits above the copyright bar, on the DARK
   footer, so colours are the footer's own light-on-dark set rather than the
   --ink-* tokens, which would be near-invisible here. */
.footer-legal{display:flex;flex-wrap:wrap;gap:2px 22px;padding:18px 0 0;border-top:1px solid #383842;margin-top:12px}
/* padding-block keeps the tap target >= 24px tall (WCAG 2.2 AA 2.5.8 Target
   Size Minimum). Measured 20.8px on a 390px viewport before this was added;
   we publish an accessibility statement, so the floor has to actually hold. */
.footer-legal a{display:inline-block;font-size:13.5px;color:#A9A9B4;line-height:1.6;padding:3px 0}
.footer-legal a:hover{color:#fff;text-decoration:underline;text-underline-offset:2px}
.footer-legal+.footer-bar{border-top:none;padding-top:16px}
@media(max-width:620px){.footer-legal{gap:2px 16px}.footer-legal a{font-size:13px;padding:4px 0}}


/* ============================================================
   Asks 17 and 18 (Rev 48): sitewide floating actions.

   Ask 17: the client noted there was NO way back up, and that scrolling up
   manually from the footer "is very bad". Ask 18: a WhatsApp chat button on
   every page.

   Stacked in one fixed column so the two controls can never overlap each other
   or the footer, and so they share one safe-area inset on notched phones.

   z-index 900 sits below the mobile nav overlay (1000) deliberately: an open
   menu must not have buttons floating over it.

   The scroll button is hidden until app.js adds .on, so a no-JS visitor is
   never shown a control that cannot do anything. WhatsApp is a plain link and
   therefore always works, JS or not.

   Both are 48px, above the 24px WCAG 2.2 (2.5.8) target minimum and within
   reach of a thumb. The label appears on hover on pointer devices only; the
   accessible name lives in aria-label, so touch users lose nothing. */
.float-actions{position:fixed;right:20px;bottom:calc(20px + env(safe-area-inset-bottom,0px));z-index:900;display:flex;flex-direction:column;gap:12px;align-items:flex-end}
.float-btn{position:relative;display:flex;align-items:center;justify-content:center;width:48px;height:48px;border-radius:50%;border:none;cursor:pointer;color:#fff;box-shadow:0 6px 20px rgba(23,23,29,.22),0 1px 3px rgba(23,23,29,.12);transition:transform var(--dur-2) var(--ease-out),box-shadow var(--dur-2) var(--ease-out),background var(--dur-2) var(--ease-out)}
.float-btn:hover{transform:translateY(-2px);box-shadow:0 10px 28px rgba(23,23,29,.28)}
.float-btn:active{transform:translateY(0) scale(.95)}
.float-btn:focus-visible{outline:3px solid var(--brand-500);outline-offset:3px}
.float-wa{background:#25D366}
.float-wa:hover{background:#1EBE5A}
/* ---- Ask 4 (Rev 67): a light attention effect on the WhatsApp button -------
   Three ring pulses on arrival, then permanently still.

   WHY BOUNDED AND NOT A LOOP. The house already has two precedents and they
   disagree, so the choice is deliberate: .f-news-btn (Rev 55) loops forever,
   .nav-cta (Rev 66) bursts once. This button follows .nav-cta, because it is
   position:fixed and therefore on screen for the WHOLE visit - the same
   situation as the sticky header CTA, and the opposite of a footer button seen
   briefly at the end of a scroll. Perpetual movement in the corner of the eye
   for an entire session is the "very annoying" class of defect the client has
   already reported once. An attention effect should spend attention once.

   It also makes WCAG 2.2.2 unambiguous rather than argued: that criterion only
   asks for a pause control when auto-starting motion runs longer than five
   seconds, and this runs 3.6s. WCAG 2.3.1 (three flashes per second) is clear by
   a wide margin at three pulses over 3.6s.

   SAFETY, and why this cannot get stuck or shift the page:
   - only transform and opacity animate, both compositor properties, so the ring
     cannot reflow anything and the button's own #25D366 fill never moves off its
     measured contrast value.
   - iteration-count 1 + forwards with a final keyframe of opacity:0, so the
     resting state is invisible; if the animation never composites, the ring
     simply never appears.
   - z-index:-1 with pointer-events:none inside an isolated stacking context, so
     the ring paints BEHIND the button and can never intercept the tap.
   - the ring is a border on a pseudo-element inset to the button box and scaled
     to 1.75, i.e. ~42px at its widest on a 48px button. The float column sits
     20px from the viewport edge, so the growth stays inside the viewport and
     cannot create a horizontal scrollbar.
   - .js gated: a no-JS visitor gets a completely normal, usable button.
   - the whole block is inside prefers-reduced-motion:no-preference, which is what
     keeps the "Reduced motion" claim on /accessibility/ true.

   The ring is WhatsApp green, #25D366, matching the button fill. An earlier draft
   of this comment claimed the opposite - that the ring had to be white because
   green "sits at 1.8:1 against #FFFFFF and was effectively invisible". That
   conclusion was wrong, and the correction is worth recording because the mistake
   is easy to repeat. Measured on a static pure-white panel behind the button, with
   every other animation frozen, pinning the ring at its widest lit frame:

       green #25D366 on #FFFFFF : 962 px lit, CIE Lab dE76 mean 59.4  (WCAG 1.82:1)
       white #FFFFFF on #FFFFFF : 167 px lit, CIE Lab dE76 mean  2.8  (WCAG 1.07:1)

   The 1.8:1 figure is real but it is the wrong instrument. WCAG contrast is a
   ratio of relative LUMINANCE, which by construction discards hue, so it scores a
   saturated green against white almost as low as it scores white against white.
   Perceptually those two cases are nothing alike: dE76 59.4 versus 2.8, a factor
   of twenty. The white ring was the invisible one. Luminance contrast is the right
   floor for TEXT legibility (1.4.3) and it is applied that way everywhere else in
   this file; it is the wrong metric for "can this decorative shape be seen".

   Nothing here depends on the ring being perceived: it is decorative, aria-hidden
   by virtue of being a pseudo-element, and the button's own name and 4.5:1 fill
   carry the meaning. So this is a visual-quality judgement, not an access one. */
.float-wa{position:relative;isolation:isolate}
.float-wa::after{content:"";position:absolute;inset:0;border-radius:50%;
  border:2px solid #25D366;opacity:0;pointer-events:none;z-index:-1}
@media(prefers-reduced-motion:no-preference){
  /* 2.4s, not 3.6s. Sampling the ring's computed style in-page through
     requestAnimationFrame showed the last pulse finishing at 2.26s of a 3.6s
     animation, i.e. a third of the declared duration was a dead tail during which
     nothing moved. Harmless, but it makes the number in this file a false
     description of what the effect does, and the next person adjusting the timing
     would be reasoning from it. Duration now matches the motion, with the
     keyframe percentages rescaled to suit. */
  .js .float-wa::after{animation:waPulse 2.4s cubic-bezier(.22,.61,.36,1) 1 forwards}
  /* Once the pointer or keyboard arrives the ring has done its job: the visitor
     has found the button. Cancelling here also stops the ring competing with the
     :hover lift on .float-btn. */
  .js .float-wa:hover::after,.js .float-wa:focus-visible::after{animation:none;opacity:0}
}
@keyframes waPulse{
  0%{transform:scale(1);opacity:0}
  6%{transform:scale(1);opacity:.9}
  33%{transform:scale(1.75);opacity:0}
  39%{transform:scale(1);opacity:.9}
  66%{transform:scale(1.75);opacity:0}
  72%{transform:scale(1);opacity:.9}
  99%{transform:scale(1.75);opacity:0}
  100%{transform:scale(1);opacity:0}
}
.float-top{background:var(--ink-900);opacity:0;visibility:hidden;transform:translateY(8px);pointer-events:none}
.float-top.on{opacity:1;visibility:visible;transform:none;pointer-events:auto}
.float-top.on:hover{background:var(--brand-500);transform:translateY(-2px)}
/* Tooltip: pointer devices only. Touch has no hover, and aria-label already
   names both controls, so nothing is lost where this does not show. */
.float-tip{position:absolute;right:calc(100% + 10px);top:50%;transform:translateY(-50%);white-space:nowrap;background:var(--ink-950);color:#fff;font-size:12.5px;font-weight:600;padding:7px 11px;border-radius:8px;opacity:0;pointer-events:none;transition:opacity var(--dur-2) var(--ease-out)}
@media(hover:hover) and (pointer:fine){.float-btn:hover .float-tip,.float-btn:focus-visible .float-tip{opacity:1}}
/* Ask 5 (Rev 67): the WhatsApp tip carries a hook line under the label, so it is
   two lines rather than one. <b> and <i> are used as the two rows purely because
   they are the shortest elements available inside a span whose whole content is
   already aria-hidden; the accessible name lives in the link's aria-label, so
   these carry no semantics anyone reads.

   Sizing is MEASURED. My first version set a flat width:186px with
   white-space:normal, and measuring the line boxes showed BOTH rows wrapping to
   two lines each - a four-line bubble for two short phrases, which looked like a
   paragraph rather than a label. The rows now hold their own line and the bubble
   takes its width from the longer of them, so it is exactly as wide as the copy
   needs and no wider. max-width is the guard against a future longer string
   pushing the bubble off screen; at 1440 the bubble's left edge sits ~1150px, so
   there is ample room. */
.float-tip-wa{white-space:nowrap;width:max-content;max-width:230px;text-align:left;line-height:1.35;padding:9px 12px}
.float-tip-wa b{display:block;font-weight:700}
.float-tip-wa i{display:block;font-style:normal;font-weight:500;font-size:11.5px;color:#C9C9D2;margin-top:2px}
@media(max-width:520px){.float-actions{right:14px;bottom:calc(14px + env(safe-area-inset-bottom,0px));gap:10px}.float-btn{width:46px;height:46px}}

/* The floating column sits over the very bottom-right of the page, which on a
   narrow viewport is exactly where the footer's last row lands. Measured at
   390px: the copyright box overlapped the WhatsApp button's box, and although
   the wrapped TEXT itself cleared it by 11px, an 11px margin that depends on
   where a sentence happens to wrap is not a margin at all -- a longer year,
   locale or font fallback closes it. So the last footer rows explicitly reserve
   the 60px column the buttons occupy, which makes the clearance structural
   instead of coincidental. Applied only below 520px; above that the footer-bar
   is a wide flex row with hundreds of px of slack. */
@media(max-width:520px){
  .footer-bar,.footer-legal{padding-right:60px}
  /* Rev 67: was `.footer-bar>span:first-child`. Ask 2 wrapped the copyright and
     the credit in .f-bar-left, so the copyright is no longer a direct child of
     .footer-bar and that selector had stopped matching anything. A selector that
     silently stops matching is the failure mode CSS is worst at reporting, so it
     is retargeted at the element that now holds the text. */
  .f-bar-left{max-width:100%}
}
@media(prefers-reduced-motion:reduce){.float-btn,.float-top,.float-tip{transition:none!important}
  /* The pulse block above is inside no-preference so it never starts here, but
     the ring is stated explicitly too: a stuck ::after with a held transform is
     precisely the kind of thing a later edit reintroduces by accident. */
  .float-wa::after{animation:none!important;opacity:0!important}
}

/* ==========================================================================
   Asks 25 and 26 (Rev 54): dedicated testimonials page + the section explore
   link and the phone card limit.
   ========================================================================== */

/* --- Ask 26: the explore link at the end of the review section -------------
   The review band is #26262E, and that constrains the button harder than any
   other CTA on the site. Measured against that band:

     white label needs >= 4.5:1 against the fill  (WCAG 1.4.3)
     fill or border  needs >= 3.0:1 against #26262E (WCAG 1.4.11)

   Solving both at once leaves a luminance window of just [0.1599, 0.1833].
   --brand-500 #D92F4A sits inside it: boundary 3.18:1, label 4.71:1. Nothing
   darker fits, which is why the ask-9 dark-band hover #C52942 CANNOT be reused
   here: it measures 2.69:1 against this band and fails 1.4.11. Same trap as
   ask 9's --brand-700 finding, one band lighter.

   So the hover does not restyle the fill at all. It adds a ring outside the
   button, which raises prominence without moving either measured value.

   Rev 80: .csf-section is added to these three selectors rather than given its own
   copy of them. It paints the SAME #26262E fill, so the luminance window derived
   above is the same window, and the only way two bands with one constraint can be
   kept from drifting into two different answers is for them to share the rule. A
   duplicated block would have passed review and then diverged at the first hover
   tweak. */
.rev-action{margin-top:30px}
.rev-section .btn-on-dark,.csf-section .btn-on-dark{background:var(--brand-500);color:#fff;border-color:var(--brand-500);box-shadow:0 10px 30px rgba(0,0,0,.34)}
.rev-section .btn-on-dark:hover,.csf-section .btn-on-dark:hover{background:var(--brand-500);color:#fff;border-color:var(--brand-500);transform:translateY(-1px);box-shadow:0 0 0 4px rgba(217,47,74,.28),0 14px 34px rgba(0,0,0,.42)}
.rev-section .btn-on-dark:active,.csf-section .btn-on-dark:active{transform:translateY(0) scale(.98);box-shadow:0 0 0 3px rgba(217,47,74,.24)}

/* --- Ask 26: three cards on a phone ----------------------------------------
   Under 640px .rev-slide becomes display:contents, so the track is one flat
   list of all 16 cards and reaching the end takes sixteen swipes. The client
   asked for three plus a button.

   Done in CSS rather than by slicing the array so the other thirteen review
   bodies stay in the delivered HTML for crawlers and LLMs (Rev 25f: never
   truncate review content). display:none also drops them from the
   accessibility tree, so a screen reader on a phone gets the same three cards
   and the same explore link a sighted phone user gets.

   The paging dots go with them: with three cards there are no longer six pages
   to page through, and dots that scroll nothing are a control that lies. The
   arrows stay, because they still step across the three. */
@media(max-width:640px){
  .rev-card-overflow{display:none}
  .rev-dots{display:none}
  .rev-action{margin-top:22px}
  .rev-action .btn{width:100%;justify-content:center;text-align:center}
}

/* ==========================================================================
   Rev 80: the homepage case-study band.

   Brian asked for six studies on desktop, three on a phone, an explore button,
   and "the case study design should match the client review section design", so
   this reuses the review band's fill, head treatment, grid rhythm and button and
   changes only what has to change for cards that carry a photograph.

   The band fill is written into the SAME declaration as .rev-section for the
   reason given above the button rules: one colour, one place. The head colours
   are shared with it too.

   WHAT IS DIFFERENT AND WHY, all three from measurement rather than preference:

   1. The grid is .csf-grid and not the existing .cases / .cases-3 pair, even
      though the cards inside it ARE .case. .cases collapses to a single column at
      900px (style.css L883 and L4060, two separate rules), and the review rhythm
      needs 2 columns from 640 to 980. Reusing .cases-3 would have meant beating
      both of those inside two media queries, which is exactly the specificity
      fight this stylesheet has lost twice before. A fresh class has no history to
      argue with.
   2. A shadow is added to the card. On a light band .case reads as a panel because
      its #EFEFF2 border separates it from the page; on #26262E the border is
      invisible and only a cast shadow makes the card sit ON the band rather than
      float in it. The values are .rev-card's, not new ones.
   3. On a phone the grid becomes the review track: horizontal, snapped, 88% wide
      cards so the third card peeks and advertises the scroll. Cards 4 to 6 are
      hidden with :nth-child rather than with a class on the card, because the card
      is rendered by the shared CaseCard() and a homepage-only class would have to
      be threaded through a function four other pages call. The cards stay in the
      HTML, so a crawler and a screen reader still get all six.
   4. The CTA is pushed to the bottom of the card, which .case does NOT do anywhere
      else. Grid stretches every card in a row to the tallest one, and .case is
      display:block, so all of that slack piles up UNDER the last line. Measured at
      1440px before this rule: 20px, 20px, 0px on row one and 60px, 38px, 0px on row
      two of dead space below "Read the case study", and the six CTAs sat at six
      different heights (spread 61px in row two). That is the same defect the last
      revision was called out for, blank space inside a bordered box, and the fix is
      the one the review card next door already uses: .rev-foot{margin-top:auto}. The
      slack moves ABOVE the link, where it reads as a footer gap, and the links line
      up across the row. padding-top replaces the link's own margin-top so a card
      with no slack keeps its 12px of breathing room.
      Scoped to .csf-grid> so the portfolio hub, the services page and the two other
      grids that render CaseCard() are untouched.
   ========================================================================== */
.csf-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}
/* Rev 81: each card is now wrapped in a .case-cell so the shared category filter
   (public/static/app.js) can toggle [hidden] on the wrapper, exactly as the full
   portfolio index at /our-case-studies/ does. The cell is the grid/flex item; the
   .case inside is stretched to fill it, so the card art, shadow, hover and the
   CTA-at-the-bottom behaviour all read as before. */
.csf-grid>.case-cell{display:flex}
.csf-grid>.case-cell>.case{display:flex;flex-direction:column;flex:1 1 auto;box-shadow:0 6px 24px rgba(0,0,0,.22)}
.csf-grid>.case-cell>.case:hover{box-shadow:0 16px 44px rgba(0,0,0,.36)}
.csf-grid>.case-cell>.case img{aspect-ratio:1200/628}
.csf-grid>.case-cell>.case .body{flex:1 1 auto;display:flex;flex-direction:column}
.csf-grid>.case-cell>.case .body .more{margin-top:auto;padding-top:12px}
.csf-grid>.case-cell[hidden]{display:none}
.csf-action{margin-top:30px}
@media(max-width:980px){.csf-grid{grid-template-columns:repeat(2,1fr)}}
@media(max-width:640px){
  .csf-grid{grid-template-columns:none;grid-auto-flow:column;grid-auto-columns:88%;gap:14px;overflow-x:auto;scroll-snap-type:x mandatory;padding-bottom:4px;scrollbar-width:none}
  .csf-grid::-webkit-scrollbar{display:none}
  .csf-grid>.case-cell{scroll-snap-align:start}
  /* Default ("All work") on a phone keeps the approved three-card snap track: the
     rest stay in the HTML for crawlers and screen readers but fold in CSS. Once a
     real industry is chosen the script sets data-filtered on the grid and the fold
     lifts, so a filtered result is never silently clipped to three. */
  .csf-grid:not([data-filtered])>.case-cell:nth-child(n+4){display:none}
  .csf-action{margin-top:22px}
  .csf-action .btn{width:100%;justify-content:center;text-align:center}
}

/* --- Ask 25: the testimonials page ----------------------------------------- */

/* Summary strip. Every figure here is counted from the REVIEWS array at build
   time (REVIEW_FACTS), so it cannot overstate the record. */
.tst-facts{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:18px;margin-bottom:38px}
.tst-fact{display:flex;flex-direction:column;align-items:center;text-align:center;gap:8px;padding:26px 18px;background:#fff;border:1px solid var(--line);border-radius:var(--r-md);box-shadow:0 1px 2px rgba(23,23,29,.04)}
.tst-fact-ic{display:inline-flex;align-items:center;justify-content:center;width:52px;height:52px;border-radius:14px;background:var(--brand-50);color:var(--brand-500);margin-bottom:2px}
.tst-fact-ic .ico{width:26px;height:26px}
.tst-fact-v{font-family:'Plus Jakarta Sans',sans-serif;font-weight:800;font-size:29px;line-height:1.05;letter-spacing:-.02em;color:var(--ink-950)}
.tst-fact-l{font-size:13.5px;line-height:1.5;color:var(--ink-500);max-width:19ch}
.tst-agg{margin:0}

/* The archive itself. An <ol> because the order is meaningful (newest first)
   and a screen reader should be told how many there are and where it is. */
.tst-list{list-style:none;margin:0;padding:0;display:grid;gap:20px;counter-reset:tst}
.tst-list>li{margin:0}
.tst-card{background:#fff;border:1px solid var(--line);border-radius:var(--r-lg);padding:30px 32px;box-shadow:0 1px 2px rgba(23,23,29,.04);transition:border-color var(--dur-2) var(--ease-out),box-shadow var(--dur-2) var(--ease-out),transform var(--dur-2) var(--ease-out)}
.tst-card:hover{border-color:var(--brand-100);box-shadow:0 14px 36px rgba(23,23,29,.07);transform:translateY(-2px)}
.tst-head{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;flex-wrap:wrap}
.tst-name{font-family:'Plus Jakarta Sans',sans-serif;font-weight:800;font-size:17px;letter-spacing:-.01em;color:var(--ink-950);margin:0}
.tst-role{margin:4px 0 0;font-size:13.5px;color:var(--ink-500);line-height:1.5}
.tst-rating{display:flex;align-items:center;gap:9px;flex:none}
.tst-stars{color:var(--brand-500);letter-spacing:3px;font-size:15px;line-height:1}
.tst-rating b{font-family:'Plus Jakarta Sans',sans-serif;font-size:13px;color:var(--ink-900);background:var(--brand-50);border-radius:99px;padding:3px 10px}
.tst-quote{font-family:'Plus Jakarta Sans',sans-serif;font-weight:700;font-size:19px;line-height:1.45;letter-spacing:-.015em;color:var(--ink-950);margin:20px 0 0;text-wrap:balance}
/* Full text, never clamped. On this page the whole point is that you can read
   every word without operating a control first. */
.tst-body{margin:14px 0 0;font-size:15.5px;line-height:1.75;color:var(--ink-700);max-width:88ch}
.tst-metric{display:flex;align-items:flex-start;gap:9px;margin:18px 0 0;font-size:13.5px;font-weight:600;color:var(--brand-700);background:var(--brand-50);border-radius:10px;padding:11px 14px;line-height:1.55}
.tst-metric .ico-sm{margin-top:2px;flex:none;color:var(--brand-500)}
.tst-foot{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-top:22px;padding-top:18px;border-top:1px solid var(--line)}
.tst-meta{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.tst-chip{font-size:12.5px;font-weight:700;font-family:'Plus Jakarta Sans',sans-serif;color:var(--ink-700);background:var(--surface-50);border:1px solid var(--line);border-radius:99px;padding:5px 12px}
.tst-date{font-size:12.5px;color:var(--ink-500)}
.tst-verify{display:inline-flex;align-items:center;gap:7px;font-size:13px;font-weight:700;font-family:'Plus Jakarta Sans',sans-serif;color:var(--brand-500);transition:color var(--dur-1) var(--ease-out)}
.tst-verify .ico-sm{flex:none}
.tst-verify:hover{color:var(--brand-700);text-decoration:underline}

@media(max-width:980px){
  .tst-facts{grid-template-columns:repeat(2,minmax(0,1fr))}
}
@media(max-width:640px){
  .tst-facts{gap:12px;margin-bottom:30px}
  .tst-fact{padding:20px 14px}
  .tst-fact-ic{width:44px;height:44px;border-radius:12px}
  .tst-fact-ic .ico{width:22px;height:22px}
  .tst-fact-v{font-size:24px}
  .tst-card{padding:24px 20px;border-radius:var(--r-md)}
  .tst-quote{font-size:17px}
  .tst-body{font-size:15px}
  .tst-foot{align-items:flex-start;flex-direction:column;gap:12px}
}

/* ===========================================================================
   Ask 24 (Rev 61): site search
   ===========================================================================
   The client asked for "a search bar or a search icon". Which of the two was
   decided by measurement, not taste: .navwrap is a fixed 1240px space-between
   row, and the slack between the nav list and the CTA is a CONSTANT 102px from
   1280px right up to 1920px, falling to 49px at 1024px. A text input needs
   roughly 220px to be a real field rather than a decorative one, so a
   persistent bar could not fit at ANY width without dropping a nav item or
   shrinking the CTA. An icon needs 40px. Hence: icon in the header, and the
   full-width field lives in an overlay where there is room for it.

   On mobile the calculation reverses. .mainnav and .nav-cta are both hidden
   below 1020px and the mobile panel is 100% wide, so there IS room for a real
   field, and .m-search below is a genuine GET form rather than an icon. */

/* --- shared field reset ---------------------------------------------------
   All three search fields are type="search" so that phones offer a "search"
   key and assistive technology announces the role correctly. The cost of that
   input type is that Chromium paints a clear button INSIDE the field once it
   has content, drawn in the shadow DOM.

   In the overlay that produced a real defect: the native clear button landed
   immediately beside .search-close, so the field showed TWO X marks side by
   side that did different things (one cleared the text, one closed the panel).
   No geometry check could see it, because elementFromPoint returns the input
   itself at every point along that edge; only a screenshot revealed it.

   It is suppressed on all three fields rather than only the overlay, because
   the button exists in Chromium and Safari but NOT in Firefox, so leaving it
   would make the same field look different browser to browser. Clearing is
   still available everywhere: select-all, or Escape in the overlay. */
.search-input::-webkit-search-cancel-button,
.m-search-input::-webkit-search-cancel-button,
.sr-input::-webkit-search-cancel-button,
.search-input::-webkit-search-decoration,
.m-search-input::-webkit-search-decoration,
.sr-input::-webkit-search-decoration{-webkit-appearance:none;appearance:none;display:none}

/* --- the header control ---------------------------------------------------
   40x40 is not arbitrary: it is the smallest square that still clears the 24px
   WCAG 2.2 target-size minimum with room for the glyph, and it matches the
   height rhythm of .nav-cta beside it so the two do not look mismatched. */
.nav-search{display:flex;align-items:center;justify-content:center;width:40px;height:40px;margin-left:8px;border-radius:8px;color:var(--ink-700);flex:0 0 auto}
.nav-search:hover{color:var(--ink-950);background:var(--surface-50)}
.nav-search .ico{width:19px;height:19px}
/* Focus is made explicit rather than left to the UA ring, because the control
   sits on a translucent blurred header where the default outline is easy to
   lose against the page behind it. */
.nav-search:focus-visible{outline:2px solid var(--brand-500);outline-offset:2px}
@media(max-width:1140px){.nav-search{margin-left:4px}}
/* Hidden with .mainnav and .nav-cta, because below this width the whole desktop
   nav collapses; the mobile panel carries .m-search instead. */
@media(max-width:1020px){.nav-search{display:none}}

/* --- the mobile field ---------------------------------------------------- */
.m-search{display:flex;align-items:center;gap:8px;margin:0 0 8px;padding:0 0 14px;border-bottom:1px solid var(--line)}
.m-search-input{flex:1 1 auto;min-width:0;font-family:'Inter',sans-serif;font-size:15px;color:var(--ink-950);background:var(--surface-50);border:1px solid var(--line);border-radius:8px;padding:12px 14px}
.m-search-input::placeholder{color:var(--ink-500)}
.m-search-input:focus{outline:2px solid var(--brand-500);outline-offset:1px;background:#fff}
.m-search-btn{display:flex;align-items:center;justify-content:center;width:44px;height:44px;flex:0 0 auto;border:0;border-radius:8px;background:var(--brand-500);color:#fff;cursor:pointer}
.m-search-btn:hover{background:var(--brand-700)}
.m-search-btn:focus-visible{outline:2px solid var(--ink-950);outline-offset:2px}

/* --- the overlay ---------------------------------------------------------
   [hidden] is honoured explicitly with !important. The attribute's UA default
   is display:none, but .search-overlay sets display:flex, and a class beats an
   attribute selector's UA origin, so without this the panel would be visible on
   all 173 pages the moment the stylesheet loaded. That is exactly the kind of
   silent, total failure that only shows up in the rendered output. */
.search-overlay[hidden]{display:none!important}
.search-overlay{position:fixed;inset:0;z-index:60;display:flex;align-items:flex-start;justify-content:center;padding:12vh 20px 20px}
.search-backdrop{position:absolute;inset:0;background:rgba(23,23,29,.55);backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px)}
.search-panel{position:relative;width:100%;max-width:680px;background:#fff;border:1px solid var(--line);border-radius:14px;box-shadow:0 30px 80px rgba(23,23,29,.32);overflow:hidden}
.search-form{display:flex;align-items:center;gap:10px;padding:14px 16px;border-bottom:1px solid var(--line)}
.search-ico{display:flex;align-items:center;color:var(--ink-500);flex:0 0 auto}
.search-ico .ico{width:20px;height:20px}
/* The field carries no visible border of its own: the panel edge and the row
   divider already frame it, and a third box inside two others reads as clutter
   at this size. */
.search-input{flex:1 1 auto;min-width:0;font-family:'Inter',sans-serif;font-size:17px;color:var(--ink-950);border:0;background:transparent;padding:8px 0}
.search-input::placeholder{color:var(--ink-500)}
.search-input:focus{outline:0}
.search-close{display:flex;align-items:center;justify-content:center;width:34px;height:34px;flex:0 0 auto;border:0;border-radius:8px;background:var(--surface-50);color:var(--ink-700);cursor:pointer}
.search-close:hover{background:var(--line);color:var(--ink-950)}
.search-close:focus-visible{outline:2px solid var(--brand-500);outline-offset:2px}
.search-close .ico{width:17px;height:17px}
.search-hint{margin:0;padding:10px 16px;font-size:12.5px;color:var(--ink-500);background:var(--surface-50);border-bottom:1px solid var(--line)}
/* Capped and scrollable, so a long result set cannot push the panel off screen
   on a short laptop viewport. */
.search-results{max-height:min(52vh,440px);overflow-y:auto;-webkit-overflow-scrolling:touch}
.search-results:empty{display:none}

/* --- result rows, shared by the overlay and the /search/ page -------------
   One set of rules for both, because they are generated by one template on the
   server. Two stylesheets for one component drift the same way two templates
   would. */
.sr-list{list-style:none;margin:0;padding:0}
.sr-item+.sr-item{border-top:1px solid var(--line)}
.sr-link{display:grid;grid-template-columns:auto 1fr;grid-template-areas:'kind label' 'snippet snippet' 'path path';gap:3px 10px;padding:13px 16px;color:inherit}
.sr-link:hover,.sr-link:focus-visible{background:var(--surface-50)}
.sr-link:focus-visible{outline:2px solid var(--brand-500);outline-offset:-2px}
/* The kind pill is the reason a result list of 40 rows stays readable: it tells
   you whether "Fintech" is an industry or a solution before you click. */
.sr-kind{grid-area:kind;align-self:center;font-size:10.5px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--brand-700);background:var(--brand-50);border-radius:99px;padding:3px 8px;white-space:nowrap}
.sr-label{grid-area:label;font-family:'Plus Jakarta Sans',sans-serif;font-weight:700;font-size:15.5px;color:var(--ink-950);line-height:1.3}
.sr-snippet{grid-area:snippet;font-size:13.5px;color:var(--ink-700);line-height:1.55}
.sr-path{grid-area:path;font-size:12px;color:var(--ink-500);word-break:break-all}
.sr-link:hover .sr-label{color:var(--brand-700)}

/* --- empty state --------------------------------------------------------- */
.sr-empty{padding:22px 16px}
.sr-empty-lead{margin:0 0 6px;font-size:15.5px;color:var(--ink-950)}
.sr-empty p{margin:0 0 12px;font-size:14px;color:var(--ink-700)}
.sr-empty-links{display:flex;flex-wrap:wrap;gap:8px;list-style:none;margin:0;padding:0}
.sr-empty-links a{display:inline-flex;font-size:13.5px;font-weight:500;color:var(--ink-950);background:var(--surface-50);border:1px solid var(--line);border-radius:99px;padding:7px 14px}
.sr-empty-links a:hover{color:var(--brand-700);border-color:var(--brand-500);background:var(--brand-50)}

/* --- the /search/ page --------------------------------------------------- */
.hero-search{padding-bottom:30px}
.sr-form{display:flex;gap:10px;justify-content:center;max-width:620px;margin:26px auto 0}
.sr-input{flex:1 1 auto;min-width:0;font-family:'Inter',sans-serif;font-size:16px;color:var(--ink-950);background:#fff;border:1px solid var(--line);border-radius:10px;padding:14px 16px}
.sr-input::placeholder{color:var(--ink-500)}
.sr-input:focus{outline:2px solid var(--brand-500);outline-offset:1px}
.sr-submit{flex:0 0 auto}
.sr-heading{font-size:22px;margin:0 0 6px}
.sr-lead{font-size:15px;color:var(--ink-700);margin:0 0 18px}
/* On the results PAGE the rows sit in a bordered card, which the overlay does
   not need because the panel already provides that frame. */
#search-results-section .sr-list{border:1px solid var(--line);border-radius:12px;overflow:hidden;margin-top:16px}
#search-results-section .sr-empty{border:1px solid var(--line);border-radius:12px;margin-top:16px}
@media(max-width:760px){
  .search-overlay{padding:8vh 12px 12px}
  .search-input{font-size:16px}
  .sr-form{flex-direction:column}
  /* Stacked on a narrow screen: at 390px the pill plus a heading on one line
     leaves the heading about 9 characters, so it wrapped to four lines. */
  .sr-link{grid-template-columns:1fr;grid-template-areas:'kind' 'label' 'snippet' 'path'}
  .sr-kind{justify-self:start}
}
/* The overlay's "see all N results" row. Only rendered when the true match count
   exceeds the 8 shown, so it never claims there is more when there is not. */
.sr-more{margin:0;border-top:1px solid var(--line)}
.sr-more a{display:block;padding:12px 16px;font-size:13.5px;font-weight:600;color:var(--brand-700);background:var(--surface-50)}
.sr-more a:hover{background:var(--brand-50)}
.sr-more a:focus-visible{outline:2px solid var(--brand-500);outline-offset:-2px}

/* ---------- Revision 72: CEO hero (Brian, 2026-07-30) ----------
   Three defects, all of them geometry, all measured with
   tools/measure_ceo_hero.py before and after.

   1. THE SUB ORPHANED ITS LAST TWO WORDS. `.hero .sub` caps the measure at
      54ch (594px). The old 72-character line filled 548px, so "since 2015."
      fell to a second line 91px wide -- 15% of the measure, under a full line.
      Fix is the pair below: release the cap on THIS hero only (the line is a
      short attribution, not a paragraph, so a 54ch reading measure buys it
      nothing) and set `text-wrap:pretty`.
      `balance` was the first attempt here and it is WRONG, which the widow
      guard said out loud: `.sub` sits inside its prose population, and rule
      16's guard rejects `balance` on any prose block because evening the lines
      can ADD one and manufacture a widow (Rev 66, asks 4/5). `pretty` costs
      nothing on this line -- at 390px it breaks at the comma either way -- so
      the guard keeps its single meaning and the line still reads. Measured
      after: ONE line at 1440 and 1024 (641px on a 1176px measure), two
      near-even lines at 390.

   2. 172px OF DEAD SPACE UNDER THE HERO. Not one value, three stacked:
      the sub's own 30px bottom margin (a margin meant to clear a CTA row that
      this hero does not have), the hero's 58px bottom padding, and the
      #profile band's 84px top padding. The hero here is two lines of text, so
      the rhythm that reads as generous on a hero with a standfirst, buttons and
      a trustline reads as an empty screen. Now 0 + 36 + 56 = 92px, a 47%
      reduction, and the portrait is above the fold at 1440x900.
      Scoped with `.hero-ceo + .sec-band` rather than by editing `.sec-band`,
      because the 84px band rhythm is correct everywhere else on the site.

   3. The H1 wording changed in src/index.tsx, not here; the Rev 71 one-line
      sizing rule needed no change because the new string is the same length. */
.hero.hero-page.hero-ceo{padding-bottom:36px}
.hero.hero-page.hero-ceo .sub{max-width:none;text-wrap:pretty;margin:20px auto 0}
.hero-ceo+.sec-band{padding-top:56px}
/* The attribution line fits on ONE line from 660px up (603-641px of text
   against a measure that never drops below 620px there). Below 660 it cannot,
   and left to itself it broke mid-clause: at 500px, 450px of text then a
   149px line reading "Vietnam since 2015." -- Brian's original complaint at a
   narrower width. Making each clause its own block puts the break at the
   comma, where a reader would put it. Not `white-space:nowrap`, which would
   overflow the viewport on a 320px phone instead of wrapping. */
@media(max-width:660px){.hero.hero-page.hero-ceo .sub span{display:block}}
@media(max-width:900px){
  /* The mobile band rhythm is 44px (and 60px below 760px); keep the hero's
     own bottom padding tighter than desktop but let the band lead the gap, so
     the portrait sits close under the title on a phone. */
  .hero.hero-page.hero-ceo{padding-bottom:26px}
  .hero-ceo+.sec-band{padding-top:40px}
}

/* Rev 72, defect found while measuring ask 3 and NOT in the brief: the portrait
   sat 110px BELOW the top of its own grid row, so the left column opened with a
   block of dead space that read as part of the same whitespace complaint.
   Cause: `.profile-photo{position:sticky;top:110px}` (style.css:531) is a single
   class, and `.story-frame{position:relative}` (style.css:2606) is also a single
   class and comes LATER in the file, so it won on order. Rev 70 put both classes
   on the same element, which silently turned a sticky offset into a RELATIVE
   offset: the portrait stopped pinning on scroll and gained a permanent 110px
   push instead. Measured before: grid row top 416, photo top 526.
   The pair below states the intent at (0,2,0) so file order cannot decide it,
   and the mobile override is restated at the same weight -- without that, this
   rule would have re-enabled sticky on phones, where the photo is a static
   380px block above the copy. 110px clears the sticky header (34px topbar +
   70px nav) with 6px to spare. */
.profile-photo.story-frame{position:sticky;top:110px}
/* Rev 86: `relative`, NOT `static`. The override above only ever meant "stop
   pinning on a phone", but `static` also cancelled the `position:relative` that
   `.story-frame` (line 2739) sets for a completely different reason: it is the
   containing block for `.story-frame::before`, the offset backplate. With the
   figure static, that absolutely positioned pseudo-element fell through to the
   initial containing block and was laid out against the VIEWPORT. Measured at
   390x900: the backplate computed 384x894 - very nearly the whole screen -
   instead of tracing a 350x374 photo, and its `right:-12px` then pushed the
   document to 402px wide, which is the horizontal scroll the site-wide sweep
   found on this page at 320, 360 and 390.

   So this was two defects with one cause: a decorative frame drawn around the
   entire viewport, and a page that scrolled sideways. `relative` with
   `top:auto` sits in normal flow exactly as `static` did, so the anti-sticky
   intent is unchanged, and the backplate goes back to being the figure's.
   Kept at (0,2,0) for the same reason the rule above is. */
@media(max-width:900px){.profile-photo.story-frame{position:relative;top:auto}}
/* Keeps a hyphenated token whole. A hyphen is a break opportunity, so
   "Go-To" split across two lines in the CEO H1 at phone widths. */
.nb{white-space:nowrap}

/* ============================================================
   Rev 74: the homepage hero image is enlarged and the two columns balanced.

   Brian, after approving the team-building photograph: "I think you can enlarge
   the image a little bit so that the hero section looks more balanced ... I feel
   like the hero image now is too small, so it cannot match with the title, the
   description ... I want you to keep the current ratio, because it's pretty
   good." Plus: put "efficient" on the second line of the description, "and then
   you'll have more space to enlarge the hero image."

   He is right about the cause AND about the remedy, and the two asks are one
   change: the copy column was taking 569px of a 1240px grid while the picture
   got 485px, so measured at 1440 the media panel stood 315px tall against a
   481px text column, filling only 65% of the row. Capping the text column is
   what pays for the picture.

   WHY THE FRAME IS NOT SIMPLY STRETCHED. Rev 25i did exactly that and Rev 57
   had to undo it (see the long note at .hero-home .hs-frame above): a stretched
   panel produced a 481px frame around a 265px photograph, a mat of 24px at the
   sides against 108px top and bottom. That makes the panel bigger, not the
   picture, and a frame with four different border widths is not a design. So
   this revision grows the PICTURE and keeps the mat even at 24px on all four
   sides. The source is 1200px wide and the panel never exceeds ~700px, so
   nothing is upscaled.

   --hero-copy is a single source of truth on purpose. The requested line break
   is a property of the measure, so if the column and the paragraph could drift
   apart the break would silently change. Measured by sweeping the measure in
   Chromium at 1440: the break Brian asked for holds across a PLATEAU from 480px
   to 536px, i.e.
       AgileTech commits to delivering high-quality, reliable, and   467px
       efficient software outsourcing solutions that drive business  476px
       growth and productivity.                                     196px
   500px sits near the centre of that plateau with about 20px of slack either
   side, so this is a stable break rather than a knife edge. Below 480px the
   paragraph goes to four lines and "and" falls with "efficient"; at 537px and
   above it returns to the old break Brian objected to.

   Scoped to min-width:1021px because the split grid collapses to one column at
   1020px (line ~1918), and a two-class selector would otherwise beat that
   single-class rule inside its media query regardless of source order.

   A FIXED 500px cap was measured first and rejected. It enlarges the picture at
   1440 (485x265 -> 578x316) but SHRINKS it between 1021 and roughly 1180,
   because there the old proportional columns gave the copy less than 500px:
   at 1024 the image went 381x208 -> 362x198. Pinning a pixel width across a
   range that narrow trades one width's balance for another's. The cap is
   therefore fluid, and clamped so the paragraph measure stays inside a range
   that keeps "efficient" off the first line: 500px at 1250px and above (the
   exact break Brian asked for), easing to a 440px floor at 1100px and below,
   where the paragraph reads
       AgileTech commits to delivering high-quality, reliable,   432px
       and efficient software outsourcing solutions that drive   435px
       business growth and productivity.                        272px
   still three lines, still with "efficient" on the second. Verified at 1600,
   1440, 1280, 1200, 1100 and 1024 that the picture is larger than it was before
   this revision at EVERY one of those widths.

   SCOPED WITH :has(.hs-frame), and that is not decoration either. The four
   directory hubs (/services/ /industries/ /solutions/ /technologies/) reuse the
   class `hero-home` for its typography but render a BARE <img> with no frame
   panel, which is the same trap Rev 45 documented for align-self:stretch. The
   first version of this rule was unscoped and it moved them too: measured at
   1440, their hero image grew 391px -> 449px (harmless) but the narrower measure
   pushed their 278-298 character hero paragraph from five lines to SIX, leaving
   /solutions/ ending on a 95px tail against a 500px measure, and made the text
   column 722px tall against a 449px image, i.e. it traded the homepage's balance
   for a worse imbalance on four other pages. `:has(.hs-frame)` is the same
   discriminator Rev 45 used, so the hubs keep their own proportions. */
@media(min-width:1021px){
  .hero-home:has(.hs-frame){--hero-copy:clamp(440px,40vw,500px)}
  .hero-home:has(.hs-frame) .hero-split-grid{grid-template-columns:minmax(0,var(--hero-copy)) minmax(0,1fr);gap:48px}
  .hero-home:has(.hs-frame) .hs-copy .sub{max-width:var(--hero-copy)}
}

/* ============================== Rev 76: case studies =========================
   The portfolio import took the case-study library from 2 pages to 16, and the
   detail template from "hero + generic blocks" to per-kind treatments (see
   src/casestudy.tsx for the measured corpus that drove each one). Everything
   below is scoped cs-* or cases-N so none of it can restyle the older cs-meta /
   cs-impact rules the two Rev 25 pages used before, which stay for reference. */

/* Rule 16 companion (see unwidow() in src/casestudy.tsx): the renderer wraps
   each covered paragraph's final two words in this span so the last line can
   never carry one word alone. nowrap rather than an NBSP join because Chromium
   still breaks through an NBSP when the preceding word carries a hyphen
   ("high-performing\A0LMS:" wrapped alone; the nowrap span held). */
.uw{white-space:nowrap}

/* Hero. The cover is the client's own 1200x628 featured art; without this
   override the shared .hs-media rule crops it square-ish to 4:3. */
.cs-hero .hs-media img,.hero-page.hero-split.cs-hero .hs-media img{aspect-ratio:1200/628;object-fit:cover}
/* Rev 78b: a case study H1 is an ARTICLE title, not a marketing slogan. Measured
   across the 16 studies the client's own headlines run 56 to 95 characters
   ("10+ Real-Time Features: How AgileTech Delivered a Doctor Consultation App On
   Time and On Budget"), where a hub-page H1 runs about 30. Left on the shared
   56px / 13ch treatment that produced a 507px-wide, FIVE-line, 300px-tall
   headline stack on 2 of the 3 pages sampled, which pushed the outcome row and
   the cover's bottom edge apart and buried the proof. Article sizing plus a
   wider copy track (620px instead of 553px) brings the same verbatim text to
   three lines for a typical headline and four for the longest, with no wording
   changed. The media track keeps a 400px floor so the 1200x628 cover never
   shrinks below the point where its UI screenshot stops reading. */
/* Rev 86: SCOPED TO DESKTOP, and the scope is the whole fix.
   Unscoped, this rule read `minmax(400px,1fr)` at every width, and at (0,2,0) it
   outranked the `.hero-split-grid{grid-template-columns:1fr}` collapse in the
   max-width:1020px block, which is only (0,1,0). Specificity beats a media query,
   so the case-study hero NEVER collapsed to one column. Measured at 390px before
   this change: the grid computed `0px 400px`, i.e. the media track took its 400px
   floor out of a 350px content box and the copy track was left with nothing, so
   the <h1> rendered 0px wide and set one word per line while the document scrolled
   404px against a 390px viewport.
   The 400px floor was always a desktop intent (see the note above: it stops the
   1200x628 cover shrinking past the point where its UI screenshot reads). Below
   the collapse breakpoint there is only one column, so the floor cannot mean
   anything there except overflow. Scoping to min-width:1021px is the same
   discriminator `.hero-home:has(.hs-frame)` already uses a few rules above, rather
   than a second counter-override fighting the first. */
@media(min-width:1021px){
  .cs-hero .hero-split-grid{grid-template-columns:minmax(0,1.32fr) minmax(400px,1fr);gap:56px}
}
/* (0,3,1) on purpose: `.hero.hero-page h1` already scores (0,2,1) with
   clamp(38px,4vw,56px) and `.hero h1` sets max-width:13ch, so a plain
   `.cs-hero h1` (0,1,1) loses both and measured no change at all. */
.hero.hero-page.cs-hero h1{font-size:clamp(30px,2.9vw,42px);max-width:none;line-height:1.12}
/* Restored intro paragraph (see caseIntroExtra()): it sits under the lede, so it
   gives up the lede's bottom margin and drops a step in weight rather than
   reading as a second, competing standfirst. Exists on 1 of the 16 studies. */
.cs-hero .sub.sub-more{margin-top:-16px;font-size:16px;color:var(--ink-500)}
.pill.cs-cat{display:inline-flex;align-items:center;gap:7px;margin-bottom:18px}
.pill.cs-cat .ico-sm{width:15px;height:15px;vertical-align:0}
a.pill.cs-cat:hover{border-color:var(--brand-500)}

/* Fact strip under the hero: dt=label, dd=value, reading as a quiet spec row. */
.cs-facts{display:flex;flex-wrap:wrap;gap:10px 34px;margin:26px 0 0;padding:18px 24px;background:#fff;border:1px solid var(--line);border-radius:var(--r-md);box-shadow:var(--shadow-md)}
.cs-facts .cf{display:flex;flex-direction:column-reverse;gap:2px}
.cs-facts dt{font-size:11.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--ink-500)}
.cs-facts dd{margin:0;font-family:'Plus Jakarta Sans',sans-serif;font-size:16.5px;font-weight:800;color:var(--ink-900)}
.cs-facts dd a{color:var(--brand-700)}
.cs-facts dd a:hover{text-decoration:underline}

/* Rev 78 (Brian ask 3.2, "so ugly"): one left content edge for the whole study.
   Every block below used to be `margin:auto` inside its own max-width, so a band
   stacked a 760px prose column, a 1020px card grid and an 860px stack all centred
   on DIFFERENT axes - three left edges in one band, measured at 1440px on
   /portfolio/restaurant-pos-system/ as x=340 / x=210 / x=290. Anchoring every
   block to the container's left edge gives the reader one edge to track, which is
   also the edge the new left rail sits on. */
.cs-copy{max-width:760px;margin:18px 0 0;text-align:left}
.cs-copy p{font-size:15.5px;color:#3A3A44;margin-bottom:14px}
.cs-copy p:last-child{margin-bottom:0}
.cs-copy-narrow .checklist{margin-top:18px}

/* Titled card grids: one treatment, per-kind accent. */
.cs-cards{display:grid;gap:16px;margin:26px 0 0;text-align:left}
.cs-cards .ct{display:flex;gap:12px;align-items:flex-start;background:#fff;border:1px solid var(--line);border-radius:var(--r-md);padding:20px 22px;box-shadow:var(--shadow-md)}
/* Rev 84: card titles became <h3>/<h4> (semantic outline, ask 4). `:is(h3,h4)`
   keeps the old (0,2,1) specificity of `.ct b`, because :is() takes the
   specificity of its most specific argument and both are type selectors, so the
   override at the bottom of this file still wins exactly as it did. `margin:0` is
   new and load-bearing: <b> carried no default margin and a heading does. */
.cs-cards .ct :is(h3,h4){display:block;margin:0;font-size:15px;font-weight:700;color:var(--ink-900);line-height:1.35}
.cs-cards .ct p{margin-top:5px;font-size:14px;color:var(--ink-500);line-height:1.58}
/* Rev 78b (Brian: "it's too AI style"). The accent was a 32px tinted rounded square
   holding a 15px glyph, repeated on every card: measured, 368 of them over the 16
   studies, up to 14 identical squares stacked in one band. At that repetition the
   badge stops carrying meaning and becomes the giveaway texture of a generated
   template. The glyph is kept, because it does still separate a problem from a
   result at a glance, but it is set as a bare 15px mark on the type baseline with no
   box, no fill and no radius, which is how an editorial page would set it. */
.cs-cards .ct-ic{flex:none;display:block;width:15px;height:15px;margin-top:3px}
.cs-cards .ct-ic .ico-sm{width:15px;height:15px;color:var(--brand-500)}
.cs-problems .ct{border-left:3px solid var(--brand-100)}
.cs-solutions .ct-ic .ico-sm,.cs-wins .ct-ic .ico-sm{color:#1B7A3D}

/* A named group inside a section ("Customer Features" + its own items). */
.cs-group{margin:30px 0 0}
.cs-group h3,.cs-h3{font-size:19px;font-weight:800;text-align:left;margin:30px 0 0}
.cs-group .cs-cards{margin-top:16px}
/* Rev 78: a group title needs a rule to read as a divider rather than as a stray
   bold line above cards; measured, five of the studies stack three groups in one
   band and without the rule the reader cannot see where one group ends. */
.cs-group+.cs-group{margin-top:34px;padding-top:30px;border-top:1px solid var(--line)}

/* Headline metric tiles: same voice as .cs-impact but carries an optional note. */
/* Rev 78: flex, not a 3-column grid. The extracted metric count is data-driven (it
   is whatever figures the client's own copy stated) and measured 2, 3, 4 and 5
   across the 16 studies, so a fixed 3-track grid left a hole on every count that
   was not a multiple of three. Flex with a 240px basis lets the final row grow to
   fill instead, which cannot leave a gap for any count. */
/* Grid, not flex-wrap. A wrapping flex row is what let the last row of tiles grow
   wider than the first (measured 323px above 494px on the e-commerce outcomes);
   `.cs-g.gN` gives every tile one width and gridClass() picks a column count that
   the tile count divides exactly. */
.cs-metrics{gap:18px;margin:28px 0 0;text-align:left}
.cs-metrics .cm{min-width:0;background:#fff;border:1px solid var(--line);border-left:4px solid var(--brand-500);border-radius:var(--r-md);padding:24px 26px;box-shadow:var(--shadow-md)}
.cs-metrics .cm b{display:block;font-family:'Plus Jakarta Sans',sans-serif;font-size:38px;font-weight:800;color:var(--brand-700);line-height:1}
.cs-metrics .cm span{display:block;margin-top:9px;font-size:14.5px;font-weight:700;color:var(--ink-900)}
.cs-metrics .cm small{display:block;margin-top:6px;font-size:13px;color:var(--ink-500)}

/* Tech stack tiles: recognised logos, layer, verbatim rationale (Rev 78). */
.cs-stack{margin:26px 0 0;text-align:left;display:grid;gap:16px}
/* Rev 79: the logo chip sits ON the title row, with the row height reserved even
   when a tile has no logo. Before this the chip was a block ABOVE the title, so a
   tile without one started its title 52px higher than its neighbour: measured
   "HTML5 & CSS3" at y=635 against "JavaScript" at y=687 in the same row of two.
   Titles in a row now share a baseline whether a logo exists or not. */
.cs-stack .csr-top{display:flex;align-items:center;gap:11px;min-height:38px}
.cs-stack .csr{display:flex;flex-direction:column;align-items:flex-start;gap:6px;background:#fff;border:1px solid var(--line);border-radius:var(--r-md);padding:20px 22px;box-shadow:var(--shadow-md)}
.cs-stack .csr :is(h3,h4){margin:0;font-size:15px;font-weight:700;color:var(--ink-900)}
.cs-stack .csr-logos{display:flex;gap:10px;flex:none;padding:7px 10px;background:var(--surface-50);border:1px solid var(--line);border-radius:10px}
.cs-stack .csr-logos img{width:24px;height:24px;object-fit:contain;display:block}
.cs-stack .csr p{margin:0;font-size:14px;color:var(--ink-500);line-height:1.55}

/* --- Rev 78: case-study band head, notes and hole-free grids ---------------- */

/* Left-aligned band head, replacing 169 identical .center-head blocks (10.6 per page
   over the 16 studies). Rev 78b removed the `01 / 06` counter and its brand rule at
   Brian's request; the heading and the client's own opening sentence are the whole
   head now, which is also why .cs-head no longer needs to be a flex container. */
.cs-head{max-width:820px;margin:0 0 34px}
.cs-head h2{font-size:clamp(28px,2.7vw,38px);font-weight:800;letter-spacing:-.03em;line-height:1.14}
.cs-lede{margin:14px 0 0;max-width:68ch;color:var(--ink-500);font-size:16.5px;line-height:1.62;text-wrap:pretty}

/* Inline text link, the .more idiom unbound from .tile / .card-link. */
.cs-link{display:inline-flex;align-items:center;gap:6px;color:var(--brand-700);font-size:13.5px;font-weight:700}
.cs-link::after{content:"\2192";font-weight:600;transition:transform var(--dur-2) var(--ease-out)}
.cs-link:hover{color:var(--brand-500)}
.cs-link:hover::after{transform:translateX(3px)}

/* One hero summary card: the project facts, a divider, then the outcome.
   Measured before Rev 78, the first stated result sat 3,100px down the POS study and
   6,800px down the doctor consultation study, so the page answered "did it work"
   only for a reader who had already committed four screens of scrolling. The border
   and shadow move here from .cs-facts so the two rows read as one object. */
.cs-summary{margin:26px 0 0;background:#fff;border:1px solid var(--line);border-radius:var(--r-md);box-shadow:var(--shadow-md);overflow:hidden}
/* Rev 79: the facts spread across the card instead of hugging its left edge.
   Measured: 3 facts in a wrapping flex row occupied 346px of the 1146px card and
   left 800px blank, which is the same "blank white card" defect as the empty tile,
   just at row scale. `grid-auto-flow:column` with `grid-auto-columns:1fr` divides
   the row evenly for ANY number of facts, so the 2-fact and 4-fact studies need no
   separate rule, and a hairline between cells makes it read as a spec table. */
.cs-summary .cs-facts{margin:0;border:0;border-radius:0;box-shadow:none;display:grid;grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);gap:0;padding:16px 0 15px}
.cs-summary .cs-facts .cf{padding:0 24px}
.cs-summary .cs-facts .cf+.cf{border-left:1px solid var(--line)}
.cs-proof{padding:16px 24px 18px;border-top:1px solid var(--line);background:var(--surface-50)}
.cs-proof-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px 20px;margin-bottom:12px}
.cs-proof-k{flex:none;margin:0;font-family:'Plus Jakarta Sans',sans-serif;font-size:11.5px;font-weight:800;letter-spacing:.06em;text-transform:uppercase;color:var(--ink-500)}
/* Grid, not flex-wrap: equal tracks keep the three entries on one baseline even
   when one label wraps to two lines, which a wrapping flex row did not. */
/* Equal thirds, and the number sits ABOVE its label rather than beside it. Beside
   it, each entry had 263px of the 1144px strip and the labels split mid-phrase
   ("improvement in page load / speed"); stacked, each entry has about 360px and the
   label is a block of at most two lines. */
.cs-proof-m{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px 24px}
.cs-proof-m li{min-width:0;padding-left:13px;border-left:2px solid var(--brand-500)}
.cs-proof-m b{display:block;font-family:'Plus Jakarta Sans',sans-serif;font-size:25px;font-weight:800;color:var(--brand-700);line-height:1.05}
.cs-proof-m span{display:block;margin-top:4px;font-size:13.5px;font-weight:600;color:var(--ink-700);line-height:1.4}
.cs-proof-c{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:8px}
.cs-proof-c li{font-size:13px;font-weight:650;color:var(--ink-900);background:#fff;border:1px solid var(--line);border-radius:99px;padding:5px 13px}
.cs-proof .cs-link{flex:none}

/* The contextual ask at the end of the results band. Deliberately NOT another full
   band: it is the closing object of the proof it belongs to, so it inherits the
   band's own left edge and reads as part of the argument. */
.cs-ask{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:22px 34px;margin:34px 0 0;padding:26px 30px;background:var(--brand-50);border:1px solid var(--brand-100);border-radius:var(--r-md)}
.cs-ask-copy{flex:1 1 420px;min-width:0}
.cs-ask-t{margin:0 0 6px;font-family:'Plus Jakarta Sans',sans-serif;font-size:19px;font-weight:800;color:var(--ink-900);letter-spacing:-.01em}
.cs-ask-copy p:last-child{margin:0;font-size:15px;line-height:1.6;color:#3A3A44;max-width:62ch}
.cs-ask-act{display:flex;flex-direction:column;align-items:flex-start;gap:12px;flex:none}

/* Rail foot: always-on-screen conversion link, visually separated from the anchors
   above it so it cannot be mistaken for a section.
   Two overrides are load-bearing, both because `.page-rail a` scores (0,1,1) and
   would otherwise win on source order alone:
     - display. `.page-rail a` is a `13px 1fr` grid built around the timeline node.
       This link has no node, so under that grid its label would be forced into the
       13px track. It has to be a plain block.
     - the connector. `.page-rail a:not(:last-child)::after` draws the 1px line down
       to the next node, and appending this link made the LAST ANCHOR stop being
       :last-child, so it grew a line running down to a node that does not exist. */
.page-rail a.page-rail-cta{display:block;margin-top:10px;padding:11px 7px 2px 2px;border-top:1px solid var(--line);border-radius:0;color:var(--brand-700);font-size:12px;font-weight:800;min-height:0}
.page-rail a.page-rail-cta::after{content:"\00a0\2192"}
.page-rail a.page-rail-cta:hover{background:transparent;color:var(--brand-500)}
.page-rail a:has(+.page-rail-cta)::after{display:none}

/* A prose-only band (aftercare, client background) as a panel rather than a lone
   centred sentence inside full band padding. 10 bands measured. */
.cs-sec.cs-slim{padding:64px 0}
.cs-sec.cs-slim .cs-head{margin-bottom:24px}
.cs-note{display:flex;gap:16px;align-items:flex-start;max-width:900px;padding:24px 28px;background:#fff;border:1px solid var(--line);border-left:3px solid var(--brand-500);border-radius:var(--r-md);box-shadow:var(--shadow-md)}
.cs-note-ic{flex:none;width:38px;height:38px;border-radius:11px;display:grid;place-items:center;background:var(--brand-50)}
.cs-note-ic .ico-sm{color:var(--brand-700)}
.cs-note p{margin:0 0 12px;font-size:15.5px;line-height:1.65;color:#3A3A44}
.cs-note p:last-child{margin-bottom:0}

/* Hole-free grid tracks. `.cs-g.gN` scores (0,2,0) so it beats the single-class
   `.cs-cards` / `.cs-shots` / `.cs-gallery` templates AND their older responsive
   overrides regardless of source order; the collapse behaviour is therefore
   re-stated here against .cs-g rather than left to those inert rules. */
.cs-g{display:grid}
.cs-g.g1{grid-template-columns:minmax(0,820px)}
.cs-g.g2{grid-template-columns:repeat(2,minmax(0,1fr))}
.cs-g.g3{grid-template-columns:repeat(3,minmax(0,1fr))}
.cs-g.g4{grid-template-columns:repeat(4,minmax(0,1fr))}
.cs-g.g5{grid-template-columns:repeat(5,minmax(0,1fr))}

/* There is deliberately NO rule here that makes one child wider than its siblings.
   Two earlier revisions tried it and both were rejected on measurement. Rev 78
   stretched the LAST item, which painted 323px tiles above 494px tiles across 38
   grids: unequal widths in one set read as a bug, and Brian called that grid ugly.
   Rev 79's first attempt promoted the FIRST item to a full-width row instead, and
   the census of the result was worse: 15 of those lead cards held a single
   checklist sentence wrapping at 300-368px inside a 1006px card, so 591 to 672px
   of a BORDERED WHITE BOX was empty, and 22 more left up to 196px of void under a
   short title. Empty space inside a card reads as broken because the border
   promises content; an empty grid CELL is just band background and reads as the
   short last line of a paragraph. So every tile keeps one width, the last row is
   allowed to be short, and gridClass() picks the count that leaves it fullest. */

/* A set of ONE is not a grid, so its card is laid out as a line rather than a stack.
   Measured cause: recovering the 7 dropped client figures (asMetric, Rev 79) moved 5
   items out of the wins list on /online-liquor-store-app/ and left exactly one behind.
   Stacked, its 22-character title sat above its 63-character body in an 820px card and
   left 348px dead to the right of the longest line - the worst card on the site after
   the grid rewrite, and the same blank-inside-a-border defect in miniature. Set on one
   baseline the pair reads as a single 603px statement, which an 820px card holds
   comfortably. This is the ONLY single-item card set in the corpus, so the rule is
   scoped to `.cs-cards`; `.csr` has a different interior (`.csr-top` carries a logo
   chip) and nothing was measured for it, so it is left alone.
   Specificity: `.cs-cards .ct :is(h3,h4)` scores (0,2,1) (Rev 84 replaced the `b`
   with a heading; :is() takes its most specific argument, another type selector,
   so the score is unchanged), so the (0,4,1) here is still what wins. */
.cs-cards.cs-g.g1>.ct>div{display:flex;flex-wrap:wrap;align-items:baseline;column-gap:8px}
.cs-cards.cs-g.g1>.ct>div>:is(h3,h4){display:inline}
.cs-cards.cs-g.g1>.ct>div>p{margin-top:0}

/* Collapse points differ per component, so each is named explicitly. Text cards and
   stack tiles go single-column at 900px (the breakpoint the old .cs-cards rule
   used); image grids hold two columns down to 640px because a screenshot survives a
   420px box where a title-plus-paragraph card does not. */
@media(max-width:1020px){
  /* 4 steps to 3 rather than straight to 2: a 4-column tile is short text by
     construction (densityCols only returns 4 for a median under 56 characters), so
     it still reads at 3 across in a 940px column. */
  .cs-g.g4,.cs-g.g5{grid-template-columns:repeat(3,minmax(0,1fr))}
  .cs-g.g3{grid-template-columns:repeat(2,minmax(0,1fr))}
}
@media(max-width:900px){
  .cs-cards.cs-g,.cs-stack.cs-g{grid-template-columns:minmax(0,1fr)}
  .cs-metrics.cs-g{grid-template-columns:repeat(2,minmax(0,1fr))}
}
@media(max-width:640px){
  .cs-g.g1,.cs-g.g2,.cs-g.g3,.cs-g.g4,.cs-g.g5{grid-template-columns:minmax(0,1fr)}
}
@media(max-width:900px){
  .cs-proof{padding:16px 18px}
  .cs-ask{padding:22px 20px}
  .cs-ask-act{width:100%}
  .cs-note{padding:20px 20px}
}

/* Index: 16 cards in four columns, category chip on each card. */
.cases.cases-4{grid-template-columns:repeat(4,1fr)}
.cases.cases-3{grid-template-columns:repeat(3,1fr)}
.case-cell{display:block;min-width:0}
.case-cell[hidden]{display:none}
.case-cell .case{height:100%}
.case .body .case-cat{display:inline-block;font-size:11.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--brand-700);background:var(--brand-50);border-radius:99px;padding:3px 10px;margin-bottom:9px}
.cases-4 .case img{aspect-ratio:1200/628}
.cases-3 .case img{aspect-ratio:1200/628}

/* Category filter: hidden until app.js binds it (progressive enhancement). */
.cs-filter{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;margin:0 0 26px}
.cs-fbtn{font:inherit;font-size:13.5px;font-weight:700;color:var(--ink-500);background:#fff;border:1px solid var(--line);border-radius:99px;padding:9px 16px;cursor:pointer;transition:color var(--dur-1) var(--ease-out),border-color var(--dur-1) var(--ease-out),background-color var(--dur-1) var(--ease-out)}
.cs-fbtn span{font-weight:600;color:var(--ink-500);margin-left:4px}
.cs-fbtn:hover{border-color:var(--brand-100);color:var(--brand-700)}
.cs-fbtn.is-on{background:var(--brand-700);border-color:var(--brand-700);color:#fff}
.cs-fbtn.is-on span{color:#E4B9C0}
.cs-empty{text-align:center;color:var(--ink-500);font-size:15px;padding:26px 0 6px}

/* Rev 81: the same category filter, restyled for the homepage's dark #26262E band.
   The light-page chips (white fill, ink text) would vanish against #26262E, so on
   .csf-section the resting chip is a translucent white outline and the active chip
   is the brand fill - the same red the band's explore button already uses, so the
   band keeps one accent. Counts stay legible via rgba white rather than ink-500. */
.csf-filter{margin-bottom:24px}
.csf-section .cs-fbtn{color:rgba(255,255,255,.82);background:rgba(255,255,255,.06);border-color:rgba(255,255,255,.22)}
.csf-section .cs-fbtn span{color:rgba(255,255,255,.55)}
.csf-section .cs-fbtn:hover{color:#fff;border-color:rgba(255,255,255,.5);background:rgba(255,255,255,.12)}
.csf-section .cs-fbtn.is-on{background:var(--brand-500);border-color:var(--brand-500);color:#fff}
.csf-section .cs-fbtn.is-on span{color:rgba(255,255,255,.82)}
.cs-empty-dark{color:rgba(255,255,255,.7)}

@media(max-width:1100px){.cases.cases-4{grid-template-columns:repeat(2,1fr)}}
@media(max-width:900px){
  .cases.cases-4,.cases.cases-3{grid-template-columns:1fr}
  .cs-cards{grid-template-columns:1fr}
  .cs-metrics{grid-template-columns:1fr}
  .cs-facts{gap:10px 22px;padding:16px 18px}
}

/* ===== Rev 82: footer rebalance (ask 2) + cookie consent UI (ask 3) =========
   Source order matters in this stylesheet: it resolves duplicate selectors by
   position, not by specificity (see the two :root blocks and the two
   .site-footer background rules). Everything below is therefore LAST on
   purpose, and any narrow-width override it needs is restated here rather than
   left to an earlier @media block.

   MEASURED PROBLEM, ask 2. At 1440px the footer's top row was a two-column grid
   with align-items:start. The left column (.f-brand) measured 293px tall and the
   right (.f-side: newsletter + contact list) 569px, so the row stood 641px with
   roughly 280px of dead space under the trust badges - on all 173 pages. The
   left column was also 648px wide while its own content was capped at 468px
   (52ch paragraph) and 460px (badge grid), leaving 180px of that column empty
   too. That combination, a quarter-empty left half beside a cramped 480px stack
   of five contact lines, is what the client's screenshot caught.

   THE FIX IS STRUCTURAL, NOT COSMETIC. Contact moves out into a full-width band
   (.f-reach) so the two offices each get a cell and the phone and email become
   real tappable rows; the left column gains a fact grid and larger badges so it
   reaches the newsletter card's height by content rather than by padding. Both
   columns are then held together by what is in them, which is the only kind of
   balance that survives a copy change.
   ========================================================================== */

/* Slightly narrower left track than Rev 42's 1.35fr: the contact list has left
   the right column, so the two halves now carry comparable content and a 35%
   width advantage would tip the row the other way. */
.footer-top{grid-template-columns:1.05fr 1fr;gap:56px}
/* MEASURED REGRESSION, caught by re-measuring rather than by reading the diff.
   The rule above is unconditional and sits after the `@media(max-width:1020px)`
   block that collapses this grid to one column, so it re-imposed two columns on
   phones: at 390px the brand column measured 157px wide and the newsletter 250px
   starting at x=233, i.e. 113px past the 370px container. The override has to be
   restated here, after it, which is the same source-order trap Rev 42 recorded
   for .f-badges. Never edit .footer-top without re-stating this. */
@media(max-width:1020px){.footer-top{grid-template-columns:1fr;gap:44px}}

/* The six numbers lifted out of the footer paragraph. Two columns, four items,
   so the last row is always full (AGENTS.md rule 13f). The hairline on the left
   of each item is the grouping cue; a plain two-column list of numbers reads as
   a table with missing rules. Colours on the painted #17171D band: #FFFFFF value
   ~15.9:1, #9A9AA6 label 6.41:1, both clear of the 4.5:1 WCAG 1.4.3 floor. */
.f-facts{list-style:none;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px 26px;margin:22px 0 4px;max-width:520px}
.f-facts li{margin:0;display:flex;flex-direction:column;gap:1px;padding-left:13px;border-left:2px solid #35353F}
.f-facts b{color:#fff;font-family:'Plus Jakarta Sans',sans-serif;font-size:17px;font-weight:800;letter-spacing:-.02em;line-height:1.25}
.f-facts span{font-size:13px;line-height:1.45;color:#9A9AA6}

/* Badges: bigger where the column is genuinely wider, and only there. The 460px
   cap stays below 1021px, because that is where .footer-top collapses to one
   column and an uncapped grid would blow the six tiles up to ~165px each. The
   6-column grid from Rev 51 is untouched, so a row can still never orphan
   (tools/check_badge_parity.py asserts exactly one row at nine widths). */
@media(min-width:1021px){
  .footer-top .f-badges{max-width:520px;gap:12px;margin-top:26px}
}

/* --- The contact band ---------------------------------------------------- */
/* A hairline rather than a filled panel: the footer already has a card (the
   newsletter) and a second one would compete with it. */
.f-reach{border-top:1px solid #2E2E37;margin-top:44px;padding:32px 0 4px}
/* Rev 83 ask 5: four cells, not three, now that the social profiles live here.
   Four columns at desktop and two at tablet both fill COMPLETE rows (rule 13f);
   the old three-up would have left a single orphan cell in a second row the
   moment a fourth was added. `Follow us` is given a little more room than the
   address cells because its content is four pill buttons rather than one line
   of text, and equal quarters wrapped them to three ragged rows (measured). */
.f-reach-grid{display:grid;grid-template-columns:1.05fr 1fr 1fr 1.15fr;gap:22px 40px}
/* MEASURED DEFECT, caught in the first visual pass. `.footer-heading` is the
   14px letter-spaced treatment for the five LINK COLUMNS, and reusing it here
   made the band's own H2 ("Contact Us") render SMALLER than the H3 cells under it
   (14.5px), which inverts the heading hierarchy on screen while the markup says
   the opposite. A band title is a different job from a column label, so it gets
   its own size and drops the tracking, which exists to make short all-caps-ish
   column labels legible at 14px and does nothing for a title at 19px. */
.site-footer .f-reach .footer-heading{font-size:19px;letter-spacing:-.01em;margin-bottom:20px}
/* The band heading is an anchor to /contact/ and must keep reading as a heading:
   `.site-footer a` would otherwise drop it to 14px #A9A9B4 normal weight. */
.site-footer .f-reach .footer-heading a{color:#fff;font-weight:700}
.site-footer .f-reach .footer-heading a:hover{color:#E4B9C0}
/* MEASURED, Rev 83. Two defects sitting in one line of type, both invisible to
   code review and both obvious once the pixels are read.

   (1) SIZE. `.site-footer a` sets 14px, and the band title is an anchor, so the
       title painted 14px inside a parent set to 19px, i.e. SMALLER than the
       14.5px cell titles beneath it. Rev 82 spotted this inversion and fixed it
       on the PARENT, which could never work: the anchor is the element that
       paints the glyphs. font-size:inherit hands the size decision back to the
       rule that actually reasons about it.

   (2) WORD SPACE. Plus Jakarta Sans with negative tracking rendered the space in
       "Contact Us" at 1.83px against 4.00px for the same 14px size in body copy,
       and "10+ years" at 2.67px. Under half a normal space is not tight
       typography, it is two words touching, which is why the fact grid read as
       "4.9/5onClutch". word-spacing reopens the gap without touching the
       letterforms the tracking was there to tighten. */
.site-footer .f-reach .footer-heading a{font-size:inherit}
.site-footer .f-reach .footer-heading,.f-facts b{word-spacing:.14em}
/* Rule 13c: the band's own title is the H2, each named cell an H3. The colour is
   explicit because an <h3> here would otherwise inherit the band's #A9A9B4 body
   colour and read as another address line. */
/* Rev 83 ask 9: was `.f-reach-cell h3`. The footer carries no heading tags any
   more, so the selector is a class. The colour is explicit because this element
   would otherwise inherit the band's #A9A9B4 body colour and read as another
   address line. */
.f-reach-title{font-family:'Plus Jakarta Sans',sans-serif;font-size:14.5px;font-weight:700;color:#fff;line-height:1.25;margin-bottom:9px}
/* padding gives each row a >=24px activation area on its own (WCAG 2.5.8), which
   a 14px text line does not have. */
.f-reach-line{display:flex;align-items:center;gap:9px;font-size:14px;line-height:1.5;padding:5px 0;min-height:26px;color:#E4B9C0;width:max-content;max-width:100%}
.site-footer .f-reach-line{color:#E4B9C0}
.f-reach-line .ico-sm{flex:none;color:#9A9AA6;transition:color var(--dur-1) var(--ease-out)}
.site-footer .f-reach-line:hover{color:#fff;text-decoration:underline;text-underline-offset:3px}
.f-reach-line:hover .ico-sm{color:#fff}
.f-reach-line:focus-visible{outline:2px solid var(--brand-500);outline-offset:3px;border-radius:6px}
.f-reach-addr{display:flex;align-items:flex-start;gap:9px;font-size:14px;line-height:1.6;color:#A9A9B4}
.f-reach-addr .ico-sm{flex:none;margin-top:5px;color:#9A9AA6}
/* The social cell. A list, because four profiles are a set, and pills rather
   than bare glyphs because a 16px icon alone is under the 24px WCAG 2.5.8
   target floor and gives a screen reader nothing to read. Two-up so the four
   fill two complete rows inside the cell at every width. */
.f-soc-list{display:grid;grid-template-columns:1fr 1fr;gap:8px;list-style:none;margin:0;padding:0}
.f-soc-list .f-soc{width:100%;justify-content:flex-start}
/* Each profile takes its own platform colour on hover, which is what makes a
   row of identical grey pills read as four distinct destinations. */
.f-soc-x:hover{background:#000;border-color:#6B6B78}
.f-soc-yt:hover{background:#FF0000;border-color:#FF0000}
@media(max-width:1120px){
  /* Below this the four columns squeeze the pills past their label width. */
  .f-reach-grid{grid-template-columns:1fr 1fr;gap:26px 40px}
}
@media(max-width:860px){
  .f-reach-grid{grid-template-columns:1fr 1fr}
}
@media(max-width:560px){
  .f-reach{margin-top:34px;padding-top:26px}
  .f-reach-grid{grid-template-columns:1fr;gap:20px}
}

/* The consent withdrawal control in the legal row. A <button>, because it opens
   a dialog rather than navigating, so it is given the row's link treatment
   explicitly instead of inheriting a browser button's chrome. */
.f-cookie-btn{font-family:inherit;font-size:13.5px;line-height:1.6;color:#A9A9B4;background:none;border:0;padding:3px 0;min-height:24px;cursor:pointer;text-align:left}
.f-cookie-btn:hover{color:#fff;text-decoration:underline;text-underline-offset:2px}
.f-cookie-btn:focus-visible{outline:2px solid var(--brand-500);outline-offset:3px;border-radius:4px}
@media(max-width:620px){.f-cookie-btn{font-size:13px;padding:4px 0}}

/* ===== Cookie consent (ask 3) ==============================================
   Two surfaces, one rule set. Both are LIGHT panels on their own layer rather
   than dark ones matching the footer, because the banner appears over arbitrary
   page content and has to read as a system-level notice, not as part of whatever
   section happens to be on screen.

   PROMINENCE IS ENFORCED BY SHARING A CLASS. "Accept all" and "Reject all" both
   carry .cc-btn-main and nothing narrows either of them, so they compute to the
   same fill, weight and width. tools/check_consent.py measures both rather than
   trusting this comment, because a one-line override added later is exactly how
   a consent dialog turns into a dark pattern.
   ========================================================================== */

/* z-index 950 is deliberate and sits between two existing layers: above
   .float-actions (900) so the notice is not buried under the WhatsApp column,
   and below the mobile nav overlay and skip link (1000) so neither is trapped
   behind it. The preference dialog needs no z-index at all: showModal() puts a
   <dialog> in the browser's top layer, above every stacking context here.
   pointer-events:none on the wrapper keeps the transparent gutter around the
   card clickable, so the banner covers the page visually but not functionally. */
.cc-banner{position:fixed;left:0;right:0;bottom:0;z-index:950;padding:14px 16px calc(14px + env(safe-area-inset-bottom,0px));pointer-events:none}
.cc-card{pointer-events:auto;max-width:1080px;margin:0 auto;background:#fff;color:var(--ink-900);border:1px solid var(--line);border-radius:var(--r-md);box-shadow:0 -10px 44px rgba(16,16,22,.26);padding:20px 24px;display:grid;grid-template-columns:minmax(0,1fr) minmax(210px,auto);gap:16px 28px;align-items:center}
.cc-title{display:flex;align-items:center;gap:9px;font-size:18px;font-weight:800;line-height:1.25}
.cc-title .ico{color:var(--brand-500);flex:none}
.cc-text{font-size:13.8px;line-height:1.6;color:#4F4F5A;margin-top:7px}
.cc-links{margin-top:8px;font-size:13px;color:#6B6B76;display:flex;gap:9px;flex-wrap:wrap}
.cc-links a{color:var(--brand-700);text-decoration:underline;text-underline-offset:2px;padding:2px 0}
.cc-links a:hover{color:var(--ink-900)}
/* Stacked, full-width buttons on desktop: identical widths make the equal
   prominence structural rather than a happy accident of label length. */
.cc-actions{display:flex;flex-direction:column;gap:9px}
.cc-actions .cc-btn{width:100%}
.cc-btn{font-family:'Plus Jakarta Sans',sans-serif;font-size:15px;font-weight:700;line-height:1.2;padding:13px 22px;min-height:44px;border-radius:99px;border:1px solid transparent;cursor:pointer;text-align:center;transition:background-color var(--dur-1) var(--ease-out),border-color var(--dur-1) var(--ease-out),color var(--dur-1) var(--ease-out)}
.cc-btn:focus-visible{outline:2px solid var(--brand-500);outline-offset:3px}
/* #17171D on #FFFFFF is ~17.4:1 for the label and far past the 3:1 boundary
   floor (WCAG 1.4.11); the hover fill #E23A52 keeps white text at 4.6:1. */
.cc-btn-main{background:var(--ink-950);color:#fff;border-color:var(--ink-950)}
.cc-btn-main:hover{background:var(--brand-500);border-color:var(--brand-500);color:#fff}
/* Quieter, because "Manage preferences" opens a further step. The binary choice
   above it is complete without it, which is what keeps this from being the
   "reject is three clicks away" pattern. #767684 boundary is 4.5:1 on white. */
.cc-btn-quiet{background:#fff;color:var(--ink-900);border-color:#767684;font-weight:600}
.cc-btn-quiet:hover{background:#F2F2F6;border-color:var(--ink-900)}
@media(max-width:900px){
  .cc-card{grid-template-columns:1fr;padding:18px}
  .cc-actions{flex-direction:row;flex-wrap:wrap}
  .cc-actions .cc-btn{flex:1 1 46%;width:auto}
  .cc-actions .cc-btn-quiet{flex:1 1 100%}
}
@media(prefers-reduced-motion:no-preference){
  /* Entrance only, 320ms, transform and opacity only: nothing flashes, so WCAG
     2.3.1 is not in play, and the card never animates its own colours away from
     the values measured above. */
  .cc-banner[data-cc-open] .cc-card{animation:ccIn 320ms var(--ease-out) both}
}
@keyframes ccIn{from{transform:translateY(16px);opacity:0}to{transform:none;opacity:1}}

/* The floating WhatsApp / back-to-top column lifts clear of the banner while it
   is on screen. The offset is the banner's MEASURED height, published as --cc-h
   by consent.js, because the card's height depends on its own text wrapping and
   any guessed constant would be wrong at some viewport. Without this the column
   sits under the card on every viewport narrow enough for the card to reach the
   right edge. */
html[data-cc-banner] .float-actions{bottom:calc(20px + env(safe-area-inset-bottom,0px) + var(--cc-h,0px))}

/* --- The preference dialog ---------------------------------------------- */
/* A closed <dialog> is display:none by UA default, so no `hidden` attribute and
   no JS is needed to keep it out of the way. */
/* margin:auto is what centres a modal <dialog>, and it comes from the UA
   stylesheet -- which this site's `*{margin:0;padding:0}` reset at the top of the
   file destroys. Without restating it the dialog opens flush against the left
   edge of the viewport, which is exactly how the first capture rendered. */
.cc-prefs{margin:auto;max-width:660px;width:calc(100% - 32px);max-height:calc(100vh - 48px);max-height:calc(100dvh - 48px);padding:0;border:none;border-radius:var(--r-lg);background:#fff;color:var(--ink-900);box-shadow:0 30px 90px rgba(16,16,22,.38);overflow:auto;overscroll-behavior:contain}
.cc-prefs::backdrop{background:rgba(16,16,22,.55)}
/* No bottom padding: the sticky action row below supplies it, so the buttons can
   settle flush against the bottom edge of the scroll container instead of
   floating 26px above it. */
.cc-prefs-inner{padding:24px 28px 0}
.cc-prefs-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}
.cc-prefs-title{font-size:25px;line-height:1.2}
.cc-x{flex:none;width:38px;height:38px;display:flex;align-items:center;justify-content:center;border:1px solid #767684;border-radius:99px;background:#fff;color:var(--ink-900);cursor:pointer;transition:background-color var(--dur-1) var(--ease-out),border-color var(--dur-1) var(--ease-out)}
.cc-x:hover{background:#F2F2F6;border-color:var(--ink-900)}
.cc-x:focus-visible{outline:2px solid var(--brand-500);outline-offset:2px}
.cc-prefs-lede{font-size:14px;line-height:1.6;color:#4F4F5A;margin-top:10px}
.cc-cats{list-style:none;display:grid;gap:12px;margin:20px 0 0}
.cc-cat{margin:0;border:1px solid var(--line);border-radius:var(--r-sm);background:#FCFCFD;padding:14px 16px}
.cc-cat-head{display:flex;align-items:center;justify-content:space-between;gap:16px}
.cc-cat-name{font-size:16px;font-weight:700;line-height:1.3}
.cc-cat-head label{cursor:pointer}
.cc-cat-purpose{font-size:13.5px;line-height:1.55;color:#55555F;margin-top:7px}
.cc-cat-today{font-size:13px;line-height:1.5;color:#6B6B76;margin-top:6px}
.cc-cat-today b{color:var(--ink-900);font-weight:700}
/* "Always on" is a label, not a disabled switch: a control a visitor cannot move
   is a false affordance, and a greyed-out toggle is the commonest version of it
   in cookie dialogs. */
.cc-always{display:inline-flex;align-items:center;gap:6px;flex:none;white-space:nowrap;font-size:12.5px;font-weight:700;color:#4F4F5A;background:#EDEDF2;border-radius:99px;padding:6px 12px}
.cc-always .ico-sm{color:#55555F;flex:none}
/* The real checkbox is transparent and sits on top of the painted track, so the
   whole 46x26 switch is the hit area (WCAG 2.5.8 wants 24x24) and the browser's
   own checked/focus states drive the visuals. The track and knob boundaries are
   #767684, 4.5:1 on white, clear of the 3:1 floor for a control boundary
   (WCAG 1.4.11); the checked fill #E23A52 measures 4.0:1. */
.cc-sw{position:relative;flex:none;display:inline-block;width:46px;height:26px}
.cc-sw input{position:absolute;inset:0;width:100%;height:100%;margin:0;opacity:0;cursor:pointer;z-index:1}
.cc-sw-track{display:block;width:46px;height:26px;border-radius:99px;background:#E6E6EC;border:1px solid #767684;position:relative;transition:background-color var(--dur-1) var(--ease-out),border-color var(--dur-1) var(--ease-out)}
.cc-sw-dot{position:absolute;top:3px;left:3px;width:18px;height:18px;border-radius:50%;background:#fff;border:1px solid #767684;transition:transform var(--dur-2) var(--ease-out)}
.cc-sw input:checked+.cc-sw-track{background:var(--brand-500);border-color:var(--brand-700)}
.cc-sw input:checked+.cc-sw-track .cc-sw-dot{transform:translateX(20px);border-color:var(--brand-700)}
.cc-sw input:focus-visible+.cc-sw-track{outline:2px solid var(--brand-500);outline-offset:3px}
.cc-fine{margin-top:18px;padding-top:16px;border-top:1px solid var(--line);display:grid;gap:10px}
.cc-fine p{font-size:12.8px;line-height:1.6;color:#55555F}
.cc-fine b{color:var(--ink-900)}
.cc-fine a{color:var(--brand-700);text-decoration:underline;text-underline-offset:2px}
.cc-gpc{display:flex;align-items:flex-start;gap:8px;background:#F1F9F5;border:1px solid #CBE5D8;border-radius:10px;padding:10px 12px;color:#22513C}
.cc-gpc .ico-sm{flex:none;margin-top:3px;color:#2C6E52}
/* MEASURED DEFECT, caught in the second visual pass. Four category cards plus the
   three regional paragraphs make this dialog 852px tall at a 900px viewport, so
   "Save my choices", "Accept all" and "Reject all" were all below the fold: the
   visitor saw a scroll bar and no way to act. A consent dialog whose decline
   button needs a scroll to find is the dark pattern this whole file is written to
   avoid, and it arrived by accident rather than by design.
   Sticky rather than fixed, so it belongs to the dialog and disappears with it,
   and the negative side margins let its white backing span the full panel width
   while its content stays on the 28px text column. */
.cc-prefs-actions{position:sticky;bottom:0;z-index:1;display:flex;flex-wrap:wrap;gap:10px;margin:20px -28px 0;padding:16px 28px calc(18px + env(safe-area-inset-bottom,0px));background:#fff;border-top:1px solid var(--line)}
/* Equal-width columns, and this is a correctness rule rather than a tidiness
   one. Left to shrink-to-fit, "Accept all" painted 124px and "Reject all" 115px
   purely because the letterforms differ, and tools/check_consent.py measures
   painted AREA against the Cookie Policy's promise that refusing is exactly as
   easy as accepting. A 7% smaller decline button is not a defensible thing to
   have to explain, and the banner already sizes its pair from a grid, so the
   dialog matching it is also what makes the two surfaces feel like one control.
   The 560px block below overrides this to full-width stacking. */
.cc-prefs-actions .cc-btn{flex:1 1 0;min-width:0}
/* "Save my choices" is the dialog's primary action and takes the brand fill.
   Accept and Reject stay identical to each other, which is the only pair the
   equal-prominence rule is about. */
.cc-prefs-actions [data-consent-save]{background:var(--grad);border-color:transparent}
.cc-prefs-actions [data-consent-save]:hover{opacity:.93;background:var(--grad)}
.cc-state{font-size:12.8px;line-height:1.5;color:#4F4F5A;margin-top:14px}
.cc-state:empty{display:none}
@media(max-width:560px){
  .cc-prefs{width:calc(100% - 20px);max-height:calc(100dvh - 24px)}
  .cc-prefs-inner{padding:20px 18px 0}
  .cc-prefs-title{font-size:22px}
  .cc-prefs-actions{margin:18px -18px 0;padding:14px 18px calc(16px + env(safe-area-inset-bottom,0px))}
  .cc-prefs-actions .cc-btn{flex:1 1 100%}
}
@media(prefers-reduced-motion:reduce){
  .cc-btn,.cc-x,.cc-sw-track,.cc-sw-dot,.f-reach-line .ico-sm{transition:none!important}
  .cc-banner[data-cc-open] .cc-card{animation:none!important}
}

/* The newsletter becomes a panel (ask 2). On the bare band its heading, lede,
   three fields, button and consent line read as six loose objects stacked in
   space, which is most of what makes the right half look unfinished next to a
   brand column that has a logo, a paragraph, a fact grid and a badge row to hold
   it together. A panel makes them one object with one job.

   The FIELDS then have to darken. They were #1D1D23, which against a #1E1E25
   panel is a 1.03:1 difference: the inputs would vanish into it. Dropping them to
   the band's own #17171D reads as recessed into the panel and keeps every
   measured floor: the #70707E field boundary is 3.61:1 on #17171D and the panel
   edge #31313B is 3.35:1 on the band, both past the 3:1 WCAG 1.4.11 floor, and
   the label and lede greys measure 5.9:1 and 7.1:1 on the panel. */
.f-news{background:#1E1E25;border:1px solid #31313B;border-radius:var(--r-lg);padding:26px}
.f-news .f-news-row input,.f-news .f-news-field input,.f-news .f-news-field select{background:#17171D}
.f-news .f-news-field select option{background:#17171D}
/* 16px, not 26px, on a phone: the interest <select> has to hold "Custom Software
   Development" without clipping, and tools/check_newsletter.py measures every
   option against the field's real text channel at 390px. 16px leaves 260px of
   channel for a 246px longest option; 26px would leave 240px and fail. */
@media(max-width:560px){.f-news{padding:16px}}
