/* IOPSTOR theme. Design tokens, then global chrome, then one rule-group per block.
   This file is shared three ways — the public site, body.admin (templates/admin/base.html) and the
   editor canvas iframe (templates/admin/canvas.html loads it, then canvas.css). A change to .card,
   .btn, .specs or .lead-form shows up in all three. */
:root{
  --black:#0a0d12;--black-2:#0f131a;--black-3:#12171f;
  --line-dark:#1c2230;--line-dark-2:#2a3140;
  --blue:#3573b9;--blue-hover:#29588e;--blue-tint:#e5eef8;--blue-light:#78a6d8;
  --white:#fff;--grey:#edf1f6;--line:#e5e9ef;--line-2:#cfd6df;
  --ink:#2e3133;--ink-2:#374151;--muted:#5b6675;--muted-dark:#aeb6c2;--muted-dark-2:#7d8794;
  --green-bg:#e6f4ea;--green:#0a8a3a;--red-bg:#fdeaea;--red:#b42323;
  --wrap:1200px;--reading:760px;
  --head:Manrope,"Segoe UI",system-ui,sans-serif;
  --body:"IBM Plex Sans",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
  --mono:ui-monospace,Menlo,Consolas,monospace;
  /* ponytail: the old token names, mapped onto the new palette, so admin.css and canvas.css keep
     working until the admin is restyled in its own PR. Delete this line once nothing uses them. */
  --navy:var(--black);--accent:var(--blue);--accent-2:var(--blue-hover);--text:var(--ink);--card:var(--white);--radius:12px
}
*{box-sizing:border-box}
/* The <symbol> sprite base.html draws once per page. `hidden` does NOT hide an inline <svg> -- the
   UA rule for it is HTML-namespaced -- so without this it reserves 300x150 at the top of the page,
   which is the same trap .site-footer svg[hidden] already works around. */
.svg-sprite{display:none}
html{scroll-behavior:smooth}
body{margin:0;font:400 16px/1.6 var(--body);color:var(--ink);background:var(--white);overflow-wrap:break-word;-webkit-font-smoothing:antialiased}
/* break-word alone only wraps INSIDE a box; a grid track and a table column are sized from
   min-content, which a 300-character word still blows out. "anywhere" counts in that size, so
   these shrink instead. Not on body: the header nav would start breaking mid-word. */
.card,.column,.stats li,.specs th,.specs td,.rich-text th,.rich-text td{overflow-wrap:anywhere}
a{color:var(--blue-hover)}
img{max-width:100%;height:auto}
h1,h2,h3,h4,h5,h6{font-family:var(--head);font-weight:800;line-height:1.2;color:var(--ink);letter-spacing:-.01em}
h1,h2{letter-spacing:-.02em;line-height:1.1}
.wrap{max-width:var(--wrap);margin:0 auto;padding:0 20px}
.skip{position:absolute;left:-999px}
.skip:focus{left:20px;top:12px;background:var(--white);color:var(--ink);padding:10px 14px;border-radius:6px;z-index:100}
.eyebrow{font:600 13px/1 var(--body);text-transform:uppercase;letter-spacing:.12em;color:var(--blue);margin:0 0 18px}
@keyframes drop{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:translateY(0)}}
@keyframes fade{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
@keyframes slide{from{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:translateY(0)}}
/* the hero's five, straight from the mock */
@keyframes rise{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}}
@keyframes heroin{from{opacity:0;transform:translateX(40px) scale(.96)}to{opacity:1;transform:translateX(0) scale(1)}}
@keyframes glow{0%,100%{opacity:.7;transform:scale(1)}50%{opacity:1;transform:scale(1.06)}}
@keyframes float{0%,100%{transform:translateY(0)}50%{transform:translateY(-10px)}}
/* The dot for the slide showing now is wide and fills blue across its turn; the rest sit small
   and empty. One loop each, offset by the same delay as the slide it belongs to. */
@keyframes dot2{0%{width:36px;background-size:0 100%;background-color:var(--line-2)}
  49.9%{width:36px;background-size:100% 100%;background-color:var(--line-2)}
  50%,100%{width:14px;background-size:0 100%;background-color:var(--line)}}
@keyframes dot3{0%{width:36px;background-size:0 100%;background-color:var(--line-2)}
  33.2%{width:36px;background-size:100% 100%;background-color:var(--line-2)}
  33.3%,100%{width:14px;background-size:0 100%;background-color:var(--line)}}
/* One slide in, held, out again -- the mock's iopSlideIn + iopSlideOut folded into one loop so the
   rotation needs no script. The percentages are the slide's share of the whole cycle, so a set is
   needed per slide count.
   ponytail: two counts styled, 2 and 3. A fourth picture needs a fourth @keyframes. */
@keyframes slides2{0%{opacity:0;transform:translateX(60px) scale(.94)}
  3.3%,45%{opacity:1;transform:translateX(0) scale(1)}
  50%,100%{opacity:0;transform:translateX(-60px) scale(.94)}}
@keyframes slides3{0%{opacity:0;transform:translateX(60px) scale(.94)}
  2.2%,30%{opacity:1;transform:translateX(0) scale(1)}
  33.3%,100%{opacity:0;transform:translateX(-60px) scale(.94)}}
/* The four section effects an editor can pick under a section's gear (blocks.py FX + count_up).
   --cv is registered so it can be interpolated at all; an unregistered custom property animates in
   one jump. It runs 0 -> 1 and the figure's own --to is applied with calc() at the far end, rather
   than the obvious `to{--cv:var(--to)}`, because **Firefox will not interpolate a keyframe whose
   value is a var()** — it jumps 0 -> 300 in one frame, while Chrome tweens it, so the bug is
   invisible in a Chromium screenshot. A literal keyframe tweens in both. */
@property --cv{syntax:"<number>";initial-value:0;inherits:false}
@keyframes cv{to{--cv:1}}
@keyframes fxrise{from{opacity:0;transform:translateY(24px)}to{opacity:1;transform:translateY(0)}}
@keyframes fxwipe{to{background-size:100% .3em}}
@keyframes fxdrift{to{background-position:200% 0}}

/* --- header ---------------------------------------------------------------------------------
   The mega panel is a child of the Services <li> and absolutely positioned against .site-header
   (sticky is a positioned element, so it is the containing block). That is why the Services <li>
   is position:static while every other one is relative: the panel has to span the viewport, not
   the 1200px column, while the Company drop-down anchors to its own item. Opening is plain
   :hover/:focus-within; only the group/pane switch needs :has(), and without it the panel still
   opens showing the first group. */
.site-header{background:var(--white);color:var(--ink);position:sticky;top:0;z-index:50;border-bottom:1px solid var(--line)}
.site-header .wrap{display:flex;align-items:center;gap:24px;min-height:64px}
.brand{display:flex;align-items:center;gap:8px;color:var(--ink);text-decoration:none;font:800 20px/1 var(--head);letter-spacing:-.01em}
/* The header is white, so its logo stays the client's own artwork. Only the footer, which is
   still the dark band, knocks the PNG out to white.
   The whole logo is shown (client, 2026-09-21). This was a FIXED 150x26 box cropped with
   object-fit:cover, and that was a considered choice, not an oversight: a fixed height alone lets
   whitespace baked into a PNG count as logo, and the 2172x724 file an editor had uploaded rendered
   its wordmark at 8px instead of 19px (design.md, 2026-09-10). But cover only ever fits artwork at
   the box's own 5.77:1 -- anything wider loses its ends, which is what a newly uploaded logo did in
   production: "IOPSTOR" arrived as "OPSTO".
   So the box stays exactly the 150x26 the design always had and only the FIT changed, cover ->
   contain: the logo is fitted inside that footprint instead of filling it and losing its ends.
   Whichever edge runs out first decides -- a wordmark wider than 5.77:1 is capped by the width and
   comes out under 26px tall, a squarer one is capped by the height. Measured against the header
   this replaced, a 7.5:1 logo renders 150x19.9 where the cropped rule gave 150x19.3, so the logo
   reads at the size it always did and simply is not cut any more.
   The first version of this fix used max-width:200px, which was an unnecessary widening: it let a
   wide logo render a third larger than the design's own footprint, and it looked it (client,
   2026-09-21).
   The cost, and it is the reverted 2026-09-10 decision: a logo exported with wide empty margins
   renders small, because those margins are counted again. Trim the file -- NON-TECHNICAL says so
   beside the upload box.
   The padded case is not hypothetical and is worth knowing before anyone "fixes" this back: the
   file still configured on the practice site is that 2172x724 one, whose wordmark is 271 of its
   724 rows, and under contain it renders 78x10. Trimmed artwork is the answer, not cover.
   ponytail: no server-side trim, which is what would satisfy both shapes. Pillow is genuinely not
   installed (checked 2026-09-21) and one CSS line is not worth a dependency plus an upload hook;
   revisit if editors keep uploading padded artwork. */
.brand img{height:26px;width:auto;max-width:150px;object-fit:contain;object-position:left center;flex:none}
.nav-toggle{display:none}
.nav-toggle-label{display:none;margin-left:auto;width:44px;height:44px;align-items:center;justify-content:center;border:1px solid var(--line-2);border-radius:8px;cursor:pointer;color:var(--ink);font-size:18px;line-height:1}
.site-nav{margin-left:auto}
.site-nav>ul{list-style:none;margin:0;padding:0;display:flex;align-items:center}
.site-nav>ul>li{position:relative}
.site-nav>ul>li.nav-mega{position:static}
.site-nav>ul>li>a{color:var(--ink);text-decoration:none;font:500 15px/1 var(--body);padding:22px 14px;display:block;transition:color .15s}
.site-nav>ul>li>a:hover{color:var(--blue)}
.site-nav .nav-cta{margin-left:10px}
.site-nav .nav-cta>a{background:var(--blue);color:var(--white);font-weight:600;padding:10px 18px;border-radius:6px}
.site-nav .nav-cta>a:hover{background:var(--blue-hover);color:var(--white)}
/* the plain drop-down (Company) */
/* the Company card's shadow is pulled down for the same measured reason the mega panel's is
   (client, 2026-09-21): its top edge sits a pixel or two under the nav link, so an unspread
   `0 30px 60px` washed grey back up onto the header bar and the card read as a separate
   surface floating over it rather than something the header opened. */
.site-nav .sub{display:none;position:absolute;top:100%;right:0;list-style:none;margin:0;width:220px;background:var(--white);border:1px solid var(--line);border-radius:10px;padding:10px;box-shadow:0 20px 40px -14px rgba(10,13,18,.22);animation:drop .2s cubic-bezier(.2,.7,.2,1)}
@media(min-width:961px){.site-nav li:hover>.sub,.site-nav li:focus-within>.sub{display:block}}
.site-nav .sub a{display:block;color:var(--ink);text-decoration:none;font:400 15px/1.3 var(--body);padding:9px 12px;border-radius:6px}
.site-nav .sub a:hover{background:var(--grey);color:var(--blue-hover)}

/* --- services mega menu --------------------------------------------------------------------- */
/* The shadow is pulled DOWN by a negative spread, and that is not a taste change (client,
   2026-09-21: the open panel "doesn't feel the part of the header ... maybe its because of drop
   shadhows"). At `0 30px 60px` with no spread the shadow rectangle starts 30px below the panel's
   top and the 60px blur carries it back up 30 -- onto the BAR, not just to the panel's edge. The
   last ten rows of the header washed 250 -> 246 grey, which is what made the two read as separate
   surfaces. -16px of spread starts the shadow 16px down instead: the same rows now read 255 -> 253.
   The header's own hairline is NOT part of this and needs no rule -- an abspos `top:100%` resolves
   against the padding box, so the open panel already covers the border. Measured both ways before
   believing it; a `border-bottom-color:transparent` written here first turned out to change
   nothing at all. */
.mega{display:none;position:absolute;top:100%;left:0;right:0;background:var(--white);color:var(--ink);border-bottom:1px solid var(--line);box-shadow:0 24px 48px -16px rgba(10,13,18,.22);animation:drop .22s cubic-bezier(.2,.7,.2,1)}
@media(min-width:961px){.nav-mega:hover>.mega,.nav-mega:focus-within>.mega{display:block}}
/* the nav link is 59px tall in a 64px header, so a slow or diagonal approach crosses 2.5px of bare
   header and the panel closes on the way in; the bridge exists only while the panel is already
   open, so it can hold one open but never open one. 3px, to barely graze the links above it. */
.mega::before{content:'';position:absolute;bottom:100%;left:0;right:0;height:3px}
.mega>.wrap{display:block}
/* One column per service group, every service on show at once -- the client's design, 2026-09-21.
   auto-fit over a wrapped .mega-col, NOT nth-of-type placement of flat siblings: the groups come
   from content, so counting them in CSS would put a ceiling on how many services the menu holds.
   The old shape was a 260px list of group names beside ONE pane, switched by :hover and :has();
   nothing switches now, so those rules are gone rather than disabled. */
.mega-cats{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));padding:28px 0 4px}
.mega-col{display:flex;flex-direction:column;padding:0 22px;border-left:1px solid var(--line)}
.mega-col:first-child{padding-left:0;border-left:0}
.mega-col:last-child{padding-right:0}
/* the column head. Uppercase and small, so the services under it are what the eye lands on. */
.mega-g{display:flex;align-items:center;gap:8px;margin:0 0 6px;text-decoration:none;color:var(--muted);
  font:600 12px/1 var(--body);letter-spacing:.1em;text-transform:uppercase;transition:color .15s}
.mega-g:hover{color:var(--blue)}
.mg-ic,.mt-ic svg{width:18px;height:18px;flex:none;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
.mg-ic{color:var(--black)}
.mega-g:hover .mg-ic{color:var(--blue)}
.mega-pane{flex:1}
.mega-tiles{display:grid;gap:2px;align-content:start}
/* a row, not a tile: 32px tinted square, then the name. The negative margin lets the hover wash
   reach past the column's text edge without the text itself moving. */
.mega-tiles a{display:flex;align-items:center;gap:12px;padding:9px 8px;margin:0 -8px;border-radius:8px;font:400 15px/1.3 var(--body);color:var(--ink);text-decoration:none;transition:background .15s,color .15s}
.mega-tiles a:hover{background:var(--grey);color:var(--blue)}
.mt-ic{width:32px;height:32px;flex:none;display:grid;place-items:center;border-radius:8px;background:var(--blue-tint);color:var(--blue)}
.mega-foot{display:flex;gap:24px;border-top:1px solid var(--line);padding:14px 0;margin-top:20px}
/* the design leads with the blue "All services" and keeps the quote line quiet until pointed at,
   which is the opposite way round from the first build of this bar (2026-09-21) */
.mega-foot a{font:600 14px/1 var(--body);color:var(--black);text-decoration:none;transition:color .15s}
.mega-foot a:hover{color:var(--blue)}
.mega-foot a.q{color:var(--blue)}
.mega-foot a.q:hover{color:var(--blue-hover)}
/* Every pane is shut on a phone, where the label above it is the disclosure, and open on the
   desktop, where the whole point of the design is that nothing has to be clicked to be seen.
   The group/pane switch this used to carry -- :hover on a link opening the pane beside it, the
   pane holding itself open, :has() walking the highlight back, and a first-group default -- is
   gone with the two-column shape it served, not disabled. */
.mega-pane{display:none}
@media(min-width:961px){.mega-pane{display:block}}
/* The phone's disclosure, and it is the same trick the burger itself uses: a display:none checkbox
   driven by its <label>, no script. The checkbox and its label sit BEFORE the group link on purpose
   -- put them between the link and its pane and `.mega-g:hover+.mega-pane` stops being adjacent, and
   the whole desktop panel above would have had to be rewritten. This way the only desktop casualties
   were three `:first-child`s that became `:nth-of-type(1)`.
   The chain is adjacent the whole way (`+`, never `~`): the groups are flat siblings, so a `~` would
   open every group below the one that was tapped as well. */
.mg-toggle,.nv-toggle{display:none}
.mg-row,.nv-row{display:none}

@media(max-width:960px){
  .nav-toggle-label{display:flex}
  .site-nav{display:none;position:absolute;left:0;right:0;top:64px;background:var(--white);border-bottom:1px solid var(--line);padding:10px 20px 20px;max-height:calc(100vh - 64px);overflow:auto}
  .nav-toggle:checked~.site-nav{animation:slide .25s ease-out both}
  .nav-toggle:checked~.site-nav{display:block}
  .site-nav>ul{flex-direction:column;align-items:stretch}
  .site-nav>ul>li>a{padding:14px 0;font:600 16px/1.3 var(--body);color:var(--ink);border-bottom:1px solid var(--line)}
  /* A top-level item that holds others: the label is the row you tap, and the link it replaces is
     not lost -- Services repeats itself as "All services" in the panel's foot, and Company's own URL
     is /about-us, which is already its first child. Same four-part order as a service group. */
  /* `.site-nav>ul>li>a` above is (0,1,3) and sets display:block, so a bare `.nav-group>a` at (0,1,1)
     loses to it and the group renders TWICE -- once as the row, once as the link it replaces. It has
     to match that selector and add the class. */
  .site-nav>ul>li.nav-group>a{display:none}
  .nv-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 0;
    font:600 16px/1.3 var(--body);color:var(--ink);cursor:pointer;border-bottom:1px solid var(--line)}
  .nv-row .nv-chev{color:var(--muted);transition:transform .2s}
  .nv-toggle:checked+.nv-row{color:var(--blue);border-bottom-color:transparent}
  .nv-toggle:checked+.nv-row .nv-chev{transform:rotate(180deg);color:var(--blue)}
  .nv-toggle:checked+.nv-row+a+.mega,.nv-toggle:checked+.nv-row+a+.sub{display:block}
  .site-nav .sub{position:static;right:auto;width:auto;background:transparent;border:0;box-shadow:none;padding:0 0 8px;animation:none}
  .site-nav .sub a{color:var(--muted);padding:10px 16px}
  .site-nav .sub a:hover{background:transparent;color:var(--blue)}
  .site-nav .nav-cta{margin:12px 0 0}
  .site-nav .nav-cta>a{text-align:center;padding:14px 18px}
  /* The panel collapses to a list of closed groups. It used to show EVERY pane expanded, which
     turned four service groups into one flat wall of eighteen links you had to scroll past to reach
     anything else in the menu (client, 2026-09-18). Now the label is the row you tap and the pane
     under it opens; `.mega-pane{display:none}` above is already the shared default, so nothing here
     has to fight it back closed. */
  .mega{position:static;background:transparent;color:inherit;border:0;box-shadow:none;animation:none}
  .mega::before{display:none}
  .mega>.wrap{padding:0}
  .mega-cats{display:block;padding:0}
  .mega-col{display:block;padding:0;border-left:0}
  .mega-g{display:none}
  .mega-pane{border-left:0;padding:0 0 8px}
  .mg-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:13px 0;
    font:600 15px/1.3 var(--body);color:var(--ink);cursor:pointer;border-bottom:1px solid var(--line)}
  .mg-row .mg-chev{color:var(--muted);transition:transform .2s}
  .mg-toggle:checked+.mg-row{color:var(--blue);border-bottom-color:transparent}
  .mg-toggle:checked+.mg-row .mg-chev{transform:rotate(180deg);color:var(--blue)}
  .mg-toggle:checked+.mg-row+.mega-g+.mega-pane{display:block}
  .mega-tiles{grid-template-columns:1fr;gap:0}
  .mega-tiles a{background:transparent;color:var(--muted);padding:10px 16px;margin:0}
  .mega-tiles a:hover{background:transparent;color:var(--blue)}
  .mt-ic{display:none}   /* a 32px tile per row turns the phone sheet into a wall of squares */
  /* two links of this length do not fit 390 side by side, so they wrap onto lines of their own */
  .mega-foot{display:flex;flex-wrap:wrap;gap:10px 24px;border-top:1px solid var(--line);padding:12px 0 0;margin-top:0}
  .mega-foot a{color:var(--ink)}
}

/* --- footer ---------------------------------------------------------------------------------- */
.site-footer{background:var(--black);color:var(--muted-dark);border-top:1px solid var(--line-dark);padding:56px 0 28px}
.site-footer .cols{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:32px}
.site-footer h4{font:700 13px/1.3 var(--head);text-transform:uppercase;letter-spacing:.1em;color:var(--white);margin:0 0 14px}
.site-footer p{margin:0 0 10px;font-size:14px;line-height:1.6}
.site-footer .brand{margin-bottom:14px;font-size:18px}
.site-footer .brand img{filter:brightness(0) invert(1)}
.site-footer a{color:var(--muted-dark);text-decoration:none;font-size:14px}
.site-footer a:hover{color:var(--white)}
.site-footer ul{list-style:none;padding:0;margin:0}
.site-footer li{margin:0 0 10px}
.site-footer .q{color:var(--blue-light)}.site-footer .q:hover{color:var(--white)}
.site-footer .legal{border-top:1px solid var(--line-dark);margin-top:40px;padding-top:20px;font-size:13px;display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
/* The social profiles from Settings, under the footer brand. The glyphs are solid simple-icons
   paths, so they take fill, not the stroke the admin sprite uses — and currentColor means the
   .site-footer a / a:hover colours above already drive them. display:block on the glyph rather
   than line-height:0 on the link: a network with no glyph renders its hostname as text in that
   same <a>, and a zero line box would clip it. */
/* The sprite has to be hidden here: the UA's own [hidden]{display:none} carries the HTML namespace,
   so it never matches an inline <svg> and the sprite would otherwise reserve a replaced element's
   default 300x150 box above the row. admin.css:124 is the same rule scoped to .admin. */
.site-footer svg[hidden]{display:none}
.site-footer .social{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin:16px 0 0}
.site-footer .social li{margin:0}
.site-footer .social .si{display:block;width:20px;height:20px;fill:currentColor}

/* page chrome */
main{min-height:60vh}
.breadcrumb{font-size:13px;color:var(--muted);padding:0}
.breadcrumb ol{list-style:none;padding:0;margin:0;display:flex;flex-wrap:wrap}
.breadcrumb li+li::before{content:"/";margin:0 8px;color:var(--line-2)}
.breadcrumb a{color:var(--muted);text-decoration:none}.breadcrumb a:hover{color:var(--blue)}
.breadcrumb li:last-child{color:var(--black)}
.page-head{padding:48px 0 56px}
.page-title{margin:0 0 14px;font-size:clamp(34px,4.5vw,52px);line-height:1.05}
.lead{font-size:18px;line-height:1.6;color:var(--muted);margin:0 0 20px;max-width:var(--w,640px)}
.post-meta{color:var(--muted);font-size:14px;margin:0 0 20px}
.btns{display:flex;gap:12px;flex-wrap:wrap;margin:0}

/* A section is a full-width band. band-dark and band-blue are written by the block's own template
   (stats, cta), never by an editor, so a row of figures is the black strip wherever it is used. */
.section{padding:80px 0}
.band-dark{background:var(--black);color:var(--muted-dark)}
.band-dark h1,.band-dark h2,.band-dark h3{color:var(--white)}
.band-blue{background:var(--blue);color:var(--white)}
.band-blue h2{color:var(--white)}
/* The band a section sits on, chosen per section under its own gear (blocks.py section_class).
   After .band-* on purpose: both are one class on the same element, so a tone an editor picked
   beats the block's own default on source order. The design alternates white and grey down a
   page for rhythm, which is why this is a setting and not baked into each block. */
.t-grey{background:var(--grey)}
.t-blue{background:var(--blue);color:var(--white)}
.t-blue h1,.t-blue h2,.t-blue h3{color:var(--white)}
/* .eyebrow is var(--blue) -- on the blue band that is the band itself, so the little label above a
   heading vanished. Nothing hit it before an editor could put this tone on any section: cta.html is
   the only other blue band and it draws no eyebrow. */
.t-blue .eyebrow{color:var(--white)}
/* The same gap one line down: .lead is var(--muted), a dark grey that all but disappears on the blue
   band. Found on a service page's closing band once its Background became a setting (2026-09-23),
   and it was true of a Points section's subheading on Blue as well. The dark band never had it --
   `.t-dark p` whitens every paragraph -- and a Hero draws its own (.hero-lit .lead, later, wins). */
.t-blue .lead{color:var(--blue-tint)}
.t-dark{background:var(--black);color:var(--muted-dark)}
.t-dark h1,.t-dark h2,.t-dark h3{color:var(--white)}
.t-dark .card{background:var(--black-3);border-color:var(--line-dark)}
.t-dark .card p{color:var(--muted-dark)}
.t-dark .chip{border-color:var(--line-dark-2);color:var(--muted-dark)}
.t-dark .chip-industry{background:rgba(53,115,185,.15);color:var(--blue-light)}
.t-dark a.card:hover{border-color:var(--blue)}
.section-title{font-size:clamp(28px,3.5vw,40px);margin:0 0 24px}
/* the row above a grid: eyebrow and title left, "All services" right. The title's last line can run
   right up to the link, so the column gap is the whole distance between them: 24px read as touching
   (client, 2026-09-23). The row gap is for a phone, where the link wraps under the title. */
.sec-head{display:flex;align-items:flex-end;justify-content:space-between;gap:24px 48px;flex-wrap:wrap;margin:0 0 28px}
.sec-head>div{flex:1 1 320px;min-width:0}   /* or a long title's max-content basis wraps the link onto its own row */
.sec-head .section-title{margin:0;text-wrap:balance}   /* the gap above makes a long title wrap; this stops it stranding one word */
.sec-head .eyebrow{margin:0 0 10px}
.sec-link{font:600 15px/1 var(--body);color:var(--black);text-decoration:none;white-space:nowrap}
.sec-link:hover{color:var(--blue)}
.band-dark .sec-link{color:var(--white)}.band-dark .sec-link:hover{color:var(--blue-light)}

.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:20px}
.card{background:var(--white);border:1px solid var(--line);border-radius:12px;padding:28px}
/* columns block. The widths go in --cols (not straight into grid-template-columns) so the stacking
   media query below can win without !important beating an inline style. A nested block keeps its own
   <section class="section"><div class="wrap">, so both are neutralised inside a column. */
.columns{display:grid;gap:48px;grid-template-columns:var(--cols,repeat(auto-fit,minmax(240px,1fr)))}
/* a picture beside words lines up on their centre; three text columns still top-align */
.columns:has(figure){align-items:center}
@media(max-width:800px){.columns{grid-template-columns:1fr}}
.column>.section{padding:0}
/* ...unless it carries a tone. A coloured box beside a column of words is a panel in the design
   (the configuration card on a service page), so it keeps the padding and takes the card radius. */
.column>.section.t-grey,.column>.section.t-dark,.column>.section.t-blue{padding:28px;border-radius:12px;align-self:start}
.column>.section+.section{padding-top:24px}
.column .wrap{max-width:none;padding:0}
.card h3{margin:0 0 8px;font-size:20px}
.card p{margin:0;color:var(--muted);font-size:15px;line-height:1.6}
.card .date{display:block;font-size:13px;color:var(--muted);margin-bottom:6px}
.card-icon{display:flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:10px;background:rgba(53,115,185,.12);font-size:18px;margin:0 0 14px}
/* "01", "02" ... is a counter in the design, not an icon: no tile, and the deck tightens around it. */
.card-icon.num{display:block;width:auto;height:auto;border-radius:0;background:none;font:700 12px/1 var(--mono);color:var(--blue)}
.cards:has(.card-icon.num){gap:16px}
.cards:has(.card-icon.num) .card{padding:24px}
.cards:has(.card-icon.num) .card h3{font-size:18px;line-height:1.25}
.cards:has(.card-icon.num) .card p{font-size:14.5px;line-height:1.55}
a.card{text-decoration:none;color:inherit;display:block;transition:border-color .15s,transform .15s}
a.card:hover{border-color:var(--blue);transform:translateY(-2px)}
/* A Cards section's own text colour. The style attribute carries it on the card (cell_style()
   checked it); .card-ink is added only then, and passes it on to the heading, the line under it
   and an 01-style number, which otherwise draw their own ink, grey and blue. Two classes, so it
   beats `.t-dark .card p` above on source order; a background needs nothing, inline wins. */
.card.card-ink h3,.card.card-ink p,.card.card-ink .card-icon{color:inherit}
.chips{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 10px}
.chips-kids{margin:12px 0 0}
.chip{font-size:12.5px;line-height:1.4;padding:4px 9px;border:1px solid var(--line);border-radius:999px;color:var(--muted)}
/* the tail of a capped chip list: a count, not a thing you can go to, so it is drawn as an outline */
.chip-more{border-style:dashed;color:var(--muted-dark-2)}
/* Four buttons. Each variant names its own colour: .btn paints white on blue, so a variant that
   drops the fill is white-on-white until it says otherwise, and each needs its own :hover for the
   same reason — .btn:hover and .btn.ghost have equal specificity, so the fill would come back. */
.btn{display:inline-block;background:var(--blue);color:var(--white);border:1px solid transparent;padding:14px 24px;border-radius:6px;text-decoration:none;font:600 15px/1 var(--body);cursor:pointer;transition:background .15s,border-color .15s,color .15s}
.btn:hover{background:var(--blue-hover);color:var(--white)}
.btn.dark{background:var(--black)}
.btn.dark:hover{background:var(--blue)}
/* Same story as .eyebrow above: cta.html hardcodes `btn dark` on its blue band, so a plain .btn
   never met one until the Background dropdown could put t-blue on any section. BEFORE .btn.ghost on
   purpose -- equal specificity, so a ghost button keeps its transparent fill on the blue band. */
.t-blue .btn{background:var(--black)}
/* on the blue band the generic dark-button hover IS the background, so the button vanishes under
   the pointer. There it goes the other way, which is what the design does. */
.band-blue .btn.dark:hover,.t-blue .btn.dark:hover{background:var(--white);color:var(--black)}
.btn.ghost{background:transparent;color:var(--black);border-color:var(--line-2)}
.btn.ghost:hover{background:transparent;color:var(--black);border-color:var(--black)}
.btn.ghost-dark{background:transparent;color:var(--white);border-color:var(--line-dark-2)}
.btn.ghost-dark:hover{background:transparent;color:var(--white);border-color:var(--white)}
@media(max-width:700px){.btn{min-height:44px;padding:15px 22px}}   /* the 44px touch target */
.pagination{display:flex;gap:12px;margin:40px 0 0}
.featured{border-radius:12px;margin:0;width:100%;max-height:440px;object-fit:cover}
.terms a{display:inline-block;background:var(--blue-tint);color:var(--blue-hover);padding:4px 10px;border-radius:999px;text-decoration:none;font-size:13px;margin:0 6px 6px 0}
/* The meta strip: one label-and-value pair per field. dl > div > dt + dd is valid HTML5 and is
   what lets each pair be one grid cell — a flat dl scatters every dt and dd into a cell of its own. */
/* A type's short fields read as page content, not as a callout: no card, no band (see .meta-strip)
   -- the label/value pairing is the whole design (user, 2026-09-19). The grid stays, so an event's
   three dates still sit in a row rather than stacking. */
.details{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px 32px;margin:0;padding:0;background:none;border:0}
.details dt{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted)}
.details dd{margin:4px 0 0;font-weight:600}
.error-page{background:var(--black);color:var(--muted-dark);min-height:60vh;display:flex;align-items:center;padding:80px 0}
.error-page>.wrap{width:100%}   /* a flex item shrink-wraps, and margin:0 auto would then centre it */
.error-page .code{margin:0;font:800 clamp(80px,16vw,160px)/1 var(--head);color:var(--blue);letter-spacing:-.04em}
.error-page h1{margin:8px 0 12px;font-size:clamp(28px,3.5vw,40px);color:var(--white)}
.error-page p{margin:0 0 28px;font-size:17px;max-width:520px}
.error-page .btns{display:flex;gap:12px;flex-wrap:wrap}

/* Section alignment, set per section under the section's own ⚙ in /admin (blocks.py align_class()).
   al-* lines up what is inside the section — text, buttons, an image, a caption. alb-* moves the
   section's own box, which only shows on the ones narrower than the page (rich text 820px, the
   contact form 640px); a full-width section has nowhere to go. .cta and table cells keep their own
   text-align on purpose, so a centred section does not restyle a CTA band or a spec table. */
.al-left{text-align:left}.al-center{text-align:center}.al-right{text-align:right}
.al-center .rich-text img{margin-inline:auto}.al-right .rich-text img{margin-inline:auto 0}
.alb-left>.wrap>*{margin-inline:0 auto}
.alb-center>.wrap>*{margin-inline:auto}
.alb-right>.wrap>*{margin-inline:auto 0}

/* Section width, set per section under ⚙. --w IS the section's content measure — every max-width
   in the theme falls back from it, so an unset section looks exactly as it was designed. The two
   named steps set it here, an exact number of pixels arrives inline from blocks.py section_style(),
   and only w-full also lets the section out of the page's 1200px column. */
/* --w-def is each block's designed measure, declared on the block itself. The shared rule below
   is (0,2,0) and every block rule is (0,1,0) or (0,1,1), so before this the fallback `none` simply
   beat .rich-text's 760px, .testimonial's 900px and the rest: a section's measure was only ever
   whatever --w said, and the designed defaults never applied. Reading --w-def inside the fallback
   keeps --w the override it is documented to be, and gives the blocks their measure back. */
.section>.wrap>*,.hero>.wrap>*{max-width:var(--w,var(--w-def,none))}
.w-wide{--w:100%}
.w-full{--w:100%}.w-full>.wrap{max-width:none}
.column{--w:initial}   /* a section inside a column keeps its own measure, not the grid's */

/* Section effects, set per section under ⚙ (blocks.py FX + count_up; keyframes at the top of this
   file). Four classes, all CSS; the site's one script touches them only to hold a counting figure at
   zero until it is scrolled to (fx-wait, below the count). Effects compose — a
   Numbers band can count up AND fade in — so each one is its own class. A Numbers figure carries
   the same classes on its own <li> (stats.html), which is what lets one figure differ from the
   band it sits in; that is why every rule below names both the section and the figure.

   Every effect runs on the document timeline and plays once as the page loads — the same thing
   .hero-text has always done, and the same thing in every browser. The one exception is a Numbers
   band ticked "Start counting when it scrolls into view", whose START site.js decides (fx-wait).

   Do not re-time them against the scroll with animation-timeline: view(). That was tried, and a
   scroll timeline pins the animation to where the reader is rather than to a clock: a section
   sitting in the first viewport is already part-way through its own range at scroll position 0, so
   it loads part-way through its animation. The Numbers band under the hero painted 270 for a figure
   the editor had typed as 300 — a wrong number on the client's home page, in every browser that
   resolves view(). It looked healthy here only because the Firefox on this machine is 140 ESR,
   which cannot resolve view() and so fell back to the document timeline these rules now use.
   Waiting until a figure is seen is therefore a start signal from an IntersectionObserver, never a
   scroll timeline. */

/* fx-gradient and fx-sweep are two values of one select, so they never meet: both paint the
   section's big text — every heading, plus a Numbers figure, which is a <strong> and not a heading
   at all — and they share that selector. The gradient loops on time either way. */
.fx-gradient :is(h1,h2,h3),.fx-gradient .stats strong,.stats li.fx-gradient strong{
  background:linear-gradient(90deg,var(--blue),var(--blue-light),var(--blue)) 0 0/200% 100%;
  -webkit-background-clip:text;background-clip:text;color:transparent;animation:fxdrift 6s linear infinite}

.fx-rise>.wrap,.stats li.fx-rise{animation:fxrise .7s cubic-bezier(.2,.7,.2,1) both}

/* A marker pen drawn under the words as they arrive. Painted as the element's own background
   rather than an ::after with z-index:-1, which would sit behind the section's band instead —
   which is also why the box shrinks to the words: a heading is a block and its bar would
   otherwise run the width of the column. */
.fx-sweep :is(h1,h2,h3),.fx-sweep .stats strong,.stats li.fx-sweep strong{width:fit-content;
  background:linear-gradient(var(--blue-tint),var(--blue-tint)) no-repeat left bottom/0 .3em;
  animation:fxwipe .6s cubic-bezier(.2,.7,.2,1) both}
.al-center.fx-sweep :is(h1,h2,h3){margin-inline:auto}
/* band-dark and t-dark are classes on the section itself, not an ancestor — stats.html writes
   the first one, an editor's tone the second — so the tint that reads on white is swapped. */
.band-dark.fx-sweep :is(h1,h2,h3),.band-dark.fx-sweep .stats strong,.stats li.fx-sweep strong,
.t-dark.fx-sweep :is(h1,h2,h3),.t-blue.fx-sweep :is(h1,h2,h3){
  background-image:linear-gradient(rgba(53,115,185,.35),rgba(53,115,185,.35))}

/* Figures that roll up from zero. The digits an editor typed are really in the HTML (stats.html)
   inside .cv; the counter is drawn into the empty .cr that follows it, so it rolls exactly where
   the number was and the words either side of it never move.
   The roll is animated on .cr and not on the <strong>, because `animation` is one property and one
   element can only hold one: on the <strong> it silently replaced the sweep's wipe and the
   gradient's drift, so a counted figure could never also be swept. One effect, one element.
   counter-reset takes the calc() and rounds it to an integer, which is what turns the 0 -> 1 the
   keyframe animates into the figure's own number.
   ponytail: needs @property (Chrome 85+, Safari 16.4+, Firefox 128+) for --cv to interpolate.
   Below that the figure reads 0 — untick "Count up from zero" for it. The floor is older than the
   :has() the mega panel already needs, so nothing that can open the menu is caught by it. */
.stats li.fx-count .cr{counter-reset:cv calc(var(--to) * var(--cv));animation:cv 2s ease-out both}
.stats li.fx-count .cr::before{content:counter(cv)}
.stats li.fx-count .cv{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
/* "Start counting when it scrolls into view" (blocks.py count_in_view). site.js puts .fx-wait on a
   figure that is off screen and takes it off once half of the figure is showing; .fx-view, which
   stats.html writes, only says which figures to watch.
   The script adds the class and the template never does, so a page whose script is blocked or fails
   to load simply counts as it opens, as before -- it cannot sit at a wrong 0.
   animation:none rather than paused: the figure may be a few frames into the count by the time the
   deferred script reaches it, and none resets that to 0 while it is off screen, so taking the class
   off starts a fresh count from zero. Preview's #main swap brings in figures with no .fx-wait, so
   there they count on arrival, like every other effect replays on a swap. */
.stats li.fx-count.fx-wait .cr{animation:none}

/* --- blocks ---------------------------------------------------------------------------------
   One rule-group per type, in the order they appear in BLOCKS. */

/* hero: words left, picture right. .hero-dark is the full-bleed variant, where the picture is a
   faded backdrop behind the words instead of the art beside them. */
/* overflow-x:clip is load-bearing, not tidiness. Three of the hero's entry animations start the
   element off to the RIGHT and slide it home -- `heroin` at translateX(40px) on .hero-media, and
   slides2/slides3 at translateX(60px) per slide -- and a transform counts toward scrollable
   overflow. On a desktop the band has slack either side and nothing shows; on a phone it has none,
   so the whole PAGE grew and slid sideways under the reader's thumb. Measured on the real home page
   before this line: 61px of horizontal scroll at 390, 81px at 320, and toggling the animations off
   one at a time accounted for every pixel of it -- the testimonial row below contributed zero.
   `clip` and not `hidden`, which would make the band a scroll container. And it belongs HERE, not on
   `body`: `body{overflow-x:clip}` was measured and changed nothing at all (still 61/81), and it
   would also have cost .site-header its `position:sticky`, because any non-visible overflow on an
   ancestor takes that away. It clips the overshoot only -- .hero-media keeps its exact width. */
.hero{position:relative;overflow-x:clip;background:var(--white);border-bottom:1px solid var(--line);padding:72px 0 56px}
.hero>.wrap{display:grid;gap:40px;align-items:center}
.hero-split>.wrap{grid-template-columns:repeat(auto-fit,minmax(300px,1fr))}
/* The other two arrangements need no grid of their own: .hero>.wrap is already a single column, so
   "below the words" is what the markup does on its own and only "above" has anything to say. It is
   `order`, not a DOM swap, so the heading is still read first however the picture is placed. */
.hero-above .hero-media{order:-1}
.hero-text{min-width:0}
/* Only the hero with art beside it moves. That is the one hero the design animates -- every
   other screen's opening band is still, and a headline that fades in on every page is a tic. */
.hero-split .hero-text,.hero-above .hero-text,.hero-below .hero-text{animation:rise .7s cubic-bezier(.2,.7,.2,1) both}
/* :is(h1,h2) because the hero's heading is demoted to an h2 when the page already has an h1
   above it (an article always draws its own head). Rank changes, appearance must not. */
.hero :is(h1,h2){margin:0 0 20px;font-size:clamp(36px,5vw,60px);line-height:1.05;max-width:var(--w,820px);text-wrap:pretty}
.hero .lead{max-width:540px;margin-bottom:30px;text-wrap:pretty}
.hero-media{position:relative;min-width:0;min-height:220px;display:flex;align-items:center;justify-content:center;animation:heroin .8s cubic-bezier(.2,.7,.2,1) both}
.hero-media::before{content:"";position:absolute;inset:10% 0;border-radius:50%;background:radial-gradient(closest-side,rgba(53,115,185,.16),transparent);animation:glow 5s ease-in-out infinite}
.hero-media>img{position:relative;display:block;width:100%;max-width:620px;margin-inline:auto;animation:float 6s ease-in-out infinite;filter:drop-shadow(0 30px 40px rgba(10,13,18,.25))}
/* Several pictures take turns with no JavaScript: every slide runs the same loop over the whole
   cycle, each one delayed by its own turn. --i and --n arrive inline from the block. */
.hero-slides{position:relative;width:100%;max-width:620px;aspect-ratio:3/2;margin-inline:auto;animation:float 6s ease-in-out infinite}
.hero-slide{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;padding:6% 4%;opacity:0;
  animation:slides3 calc(var(--n) * 4.5s) cubic-bezier(.2,.7,.2,1) calc(var(--i) * 4.5s) infinite both}
.hero-slides[style*="--n:2"] .hero-slide{animation-name:slides2}
.hero-slide img{max-width:100%;max-height:100%;object-fit:contain;filter:drop-shadow(0 30px 40px rgba(10,13,18,.25))}
/* no fill mode on the dot: `both` would hold the first frame through the delay and leave every dot
   wide until its turn came round. Un-filled, a dot waits at its own 14px base. */
/* Positioned against .hero-media, not .hero-slides: the slides element carries the `float`
   loop and anything inside it drifts up and down with the picture. An indicator that bobs
   is hard to read, so it is a sibling of the pictures rather than a child. */
.hero-dots{position:absolute;left:0;right:0;bottom:-6px;display:flex;align-items:center;justify-content:center;gap:8px}
.hero-dots span{height:4px;width:14px;border-radius:999px;background-color:var(--line);background-image:linear-gradient(var(--blue),var(--blue));background-repeat:no-repeat;background-size:0 100%;
  animation:dot3 calc(var(--n) * 4.5s) linear calc(var(--i) * 4.5s) infinite}
.hero-dots[style*="--n:2"] span{animation-name:dot2}
.hero-dark{background:var(--black);border-bottom:0;padding:96px 0 80px;overflow:hidden}
.hero-dark .lead{max-width:640px}
/* Light text, on whichever band is dark. This used to hang off .hero-dark -- the tick -- which made
   the tick answer two questions at once: how the section is laid out, and what colour it is. The
   Background dropdown can now overrule the second, so hero.html works out `lit` from the tick AND
   the tone and writes this class; see the comment on the tone rules below. */
.hero-lit{color:#d1d6de}
.hero-lit :is(h1,h2){color:var(--white)}
.hero-lit .lead{color:#d1d6de}
/* "Hold the picture still": the two perpetual loops stop, the one-shot entrance does not. The
   picture still arrives the way the design draws it and then sits, which is what was asked for --
   so this names the two elements that carry `float` and the ::before that carries `glow`, and
   deliberately not .hero-media, whose animation IS the entrance. .hero-slide keeps its own
   slides2/slides3, so several pictures go on taking turns; the dots go on marking which. */
.hero-still .hero-media::before,.hero-still .hero-media>img,.hero-still .hero-slides{animation:none}
/* The wash itself, gone rather than stilled. Worth knowing the two are not the same control and
   why both exist: `still` only stops the keyframes, and `glow` runs 0%/100% at opacity .7, so a
   stilled glow sits at the element's own opacity 1 and reads as MORE present, not less. This one
   takes the ::before out. Nothing to do on a dark hero -- there the picture is .hero-bg and
   .hero-media is never rendered at all. */
.hero-noglow .hero-media::before{display:none}
.hero-bg{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;opacity:.18}
.hero-dark>.wrap{position:relative}
/* The page's breadcrumb, when the hero is the first thing on the page (user, 2026-09-24: the white
   strip .crumb-bar left above a dark band read as detached, and the mock draws the crumb inside
   every band). Its own row above the grid: display:block beats .hero>.wrap's grid -- same
   specificity, later here -- and the rule above keeps it over .hero-bg. 16px is .page-head
   .breadcrumb's gap, so a dark hero and a head-banner put the crumb in the same place. On a lit
   band it takes the band's own light text, not .band-dark's --muted-dark, which is about 2.4:1 on
   --blue; the page you are on is white, as in the mock. */
.hero>.hero-crumb{display:block;margin-bottom:16px}
:is(.hero-lit,.head-lit) .breadcrumb,:is(.hero-lit,.head-lit) .breadcrumb a,:is(.hero-lit,.head-lit) .breadcrumb li+li::before{color:inherit}
:is(.hero-lit,.head-lit) .breadcrumb li:last-child,:is(.hero-lit,.head-lit) .breadcrumb a:hover{color:var(--white)}
/* The hero is the ONE block that draws its own band, and .hero/.hero-dark sit here, 180 lines after
   the tone group at the top of this file -- same specificity, later in the source, so a tone an
   editor picked lost to them and the section's own "Background" dropdown did nothing on a hero
   (client, 2026-09-22). These put back the rule that group's comment states. Do not move a hero
   background above them, and do not "tidy" them away as duplicates of .t-*: they are the same
   colours at (0,2,0) precisely so the dropdown wins. The `dark` tick owns the LAYOUT -- the
   full-width band, no bottom border, the picture as a backdrop -- and the dropdown owns the colour,
   which is what makes a banded hero in grey or blue possible at all.
   Background only, and that is the point of the split above: the *colour* of the text follows
   .hero-lit, which the template computes from the tick and the tone together, so no combination of
   the two needs a rule of its own here.
   t-page is the fourth, and it is why "Page background" is a stored value rather than an empty one
   (blocks.py TONES): the tick blacks the band out on its own, so only an explicit pick can put the
   page's white back, and an absent tone has to go on meaning "leave the block's own band alone" or
   every hero saved before today would turn white. It is scoped to .hero deliberately -- a CTA or a
   Numbers strip set to Page background keeps the band its own template writes, exactly as now. */
.hero.t-page{background:var(--white)}
.hero.t-grey{background:var(--grey)}
.hero.t-blue{background:var(--blue)}
.hero.t-dark{background:var(--black)}
@media(max-width:700px){.hero{padding:48px 0 40px}.hero-dark{padding:64px 0 56px}}

/* Prose typed in the admin's document editor. Everything an editor can produce with the
   toolbar needs a rule here, or it falls through to the browser default and looks unfinished. */
.rich-text{--w-def:var(--reading);max-width:var(--w,var(--reading));font-size:17px;line-height:1.7;color:var(--ink-2)}
.rich-text p{margin:0 0 1rem}
.rich-text h1{font-size:34px;margin:2rem 0 1rem}.rich-text h2{font-size:28px;margin:2.2rem 0 .8rem}
.rich-text h3{font-size:21px;margin:1.8rem 0 .6rem}.rich-text h4{margin:1.4rem 0 .4rem;font-size:18px}
.rich-text h5{margin:1.2rem 0 .35rem;font-size:16px}
.rich-text h6{margin:1.1rem 0 .3rem;font-size:15px}
.rich-text ul,.rich-text ol{margin:0 0 1rem;padding-left:1.35rem}.rich-text li{margin-bottom:.4rem}
.rich-text li>ul,.rich-text li>ol{margin:.4rem 0 0}
.rich-text blockquote{border-left:3px solid var(--blue);margin:1.5rem 0;padding:.25rem 0 .25rem 1.25rem;font-style:italic;color:var(--muted)}
.rich-text blockquote p:last-child{margin:0}
.rich-text table{width:100%;border-collapse:collapse;background:var(--white);border:1px solid var(--line);border-radius:12px;overflow:hidden;margin:1.5rem 0;font-size:15px}
.rich-text th,.rich-text td{padding:.7rem 1rem;border-bottom:1px solid var(--line);text-align:left}
.rich-text th{background:var(--grey);font-weight:600;color:var(--ink)}.rich-text tr:last-child td{border-bottom:0}
/* cells only carried a border-bottom, which reads as a list; a real grid needs the column lines too */
.rich-text th+th,.rich-text td+td{border-left:1px solid var(--line)}
/* only a table whose columns have been resized is pinned to those widths; the rest still auto-fit */
.rich-text table:has(colgroup){table-layout:fixed}
.rich-text img{display:block;margin:1.5rem 0;border-radius:12px}
.rich-text hr{border:0;border-top:1px solid var(--line);margin:2.5rem 0}
.rich-text code{background:var(--grey);padding:.1rem .35rem;border-radius:4px;font:.92em var(--mono)}
/* A spec table pasted into prose is a spec table, not a prose table: the element rules above are
   (0,1,1) and would otherwise keep the white box and the grey header cells. */
.rich-text table.specs{background:none;border:0;border-radius:0;margin:0 0 1rem;font-size:15px}
.rich-text .specs th+th,.rich-text .specs td+td{border-left:0}
/* prose on a dark panel -- the configuration card beside a service page's ZFS list */
.t-dark .rich-text{color:var(--muted-dark)}
/* A prose link takes the bare a{} rule, which is --blue-hover: 2.7:1 on this band. The dark card
   beside the ZFS list is a rich_text with tone:dark, so this is reachable content, not theory.
   Scoped to .rich-text on purpose -- a.card sets its own colours just above. */
.t-dark .rich-text a{color:var(--blue-light)}
.t-dark .rich-text .specs th{color:var(--muted-dark)}
.t-dark .rich-text .foot{margin:14px 0 0;font-size:12px;color:var(--muted-dark-2)}
.t-dark .rich-text dl.zfs>div,.t-dark .definitions dl>div{border-top-color:var(--line-dark)}
.t-dark .rich-text dl.zfs dt,.t-dark .definitions dl dt{color:var(--white)}
.t-dark .rich-text dl.zfs dd,.t-dark .definitions dl dd,.t-dark .rich-text .founders i,.t-dark .people i{color:var(--muted-dark)}
.t-dark .points ul.dash{color:var(--muted-dark)}
/* A rich_text block can carry a whole section: its eyebrow, its heading, its list and its button.
   Each needs to out-specify the element rules above -- `.rich-text h2` is (0,1,1) and would beat a
   bare `.section-title`. The dash list is the design's feature list: a blue em dash, not a bullet. */
.rich-text .eyebrow,.points .eyebrow{margin:0 0 10px}
.rich-text .section-title,.points .section-title,.definitions .section-title{margin:0 0 18px;font-size:clamp(28px,3.5vw,40px)}
.rich-text .page-title{margin:0 0 14px;font-size:clamp(34px,4.5vw,52px);line-height:1.05;color:var(--ink)}
.rich-text .lead,.points .lead{margin:0 0 24px;max-width:none;font-size:17px;line-height:1.6}
.rich-text ul.dash,.points ul.dash{margin:0 0 28px;padding:0;list-style:none;display:grid;gap:10px;color:var(--ink);font-size:15.5px}
.rich-text ul.dash li,.points ul.dash li{display:flex;gap:12px;margin:0}
.rich-text ul.dash li::before,.points ul.dash li::before{content:"\2014";flex:none;color:var(--blue);font-weight:700}
/* The same shapes as their own blocks (points, definitions) rather than as classes inside a rich_text
   -- Quill drops a bare class, so a section written that way could never be co-edited (12.3). They
   carry their own measure because .rich-text's is not inherited: the Width control reads --w-def. */
.points,.definitions{--w-def:var(--reading);max-width:var(--w,var(--reading))}
.points>*:first-child,.definitions>*:first-child{margin-top:0}
.points .points-go{margin:0}
/* Two more shapes the design draws inside prose, so they need no block of their own: the labelled
   contact list, and the striped box standing in for the map until an embed_html replaces it. Same
   trusted-staff HTML the dash list uses.
   The third was the founder pair on About, which is the `people` block now (0017) -- the same rules,
   one more selector, so the page renders as it always did. .founders stays until every database has
   run that migration. The 24px above the chips is the prose version's only difference: as its own
   section the gap is .column>.section+.section's padding instead. */
.rich-text .founders,.people{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:14px}
.rich-text .founders{margin:24px 0 0}
.rich-text .founders>div,.people>div{background:var(--grey);border-radius:10px;padding:20px}
.rich-text .founders .ava,.people .ava{display:block;width:56px;height:56px;border-radius:999px;margin-bottom:12px;object-fit:cover;background:repeating-linear-gradient(135deg,#dfe4ea 0 6px,#eef1f5 6px 12px)}
.rich-text .founders b,.people b{display:block;font-weight:700;color:var(--black)}
.rich-text .founders i,.people i{display:block;font-style:normal;font-size:14px;color:var(--muted)}
/* the design's "ZFS features, in plain terms": term and description on one ruled row */
.rich-text dl.zfs,.definitions dl{margin:0;display:grid}
.rich-text dl.zfs>div,.definitions dl>div{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,2fr);gap:16px;padding:16px 0;border-top:1px solid var(--line)}
.rich-text dl.zfs dt,.definitions dl dt{font:700 15px/1.4 var(--body);color:var(--black)}
.rich-text dl.zfs dd,.definitions dl dd{margin:0;font-size:14.5px;line-height:1.55;color:var(--muted)}
.rich-text dl.contact-dl{display:grid;gap:18px;margin:0 0 32px;font-size:15.5px}
.rich-text dl.contact-dl dt{font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}
.rich-text dl.contact-dl dd{margin:4px 0 0;color:var(--ink-2);line-height:1.5}
.rich-text .map-ph{aspect-ratio:16/9;border-radius:12px;display:flex;align-items:center;justify-content:center;text-align:center;padding:16px;font:12px/1.5 var(--mono);color:var(--muted);background:repeating-linear-gradient(135deg,#e9edf2 0 10px,#f4f6f9 10px 20px)}
.rich-text>*:first-child{margin-top:0}
/* Inside a column, .column>.section+.section already sets the 24px between two stacked
   sections, so a trailing paragraph margin is added on top of it: the founder chips measured
   40px below the prose where the design draws 24 (2026-09-22, the `people` block). Scoped to a
   column -- on a full-width page the section's own padding is the gap and prose keeps its
   trailing margin. */
.column .rich-text>*:last-child{margin-bottom:0}

/* image / gallery */
figure{margin:0}figure img{display:block;width:100%;border-radius:12px}
figcaption{color:var(--muted);font-size:13.5px;margin-top:10px}
.gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px}
.gallery img{width:100%;border-radius:8px;object-fit:cover;aspect-ratio:4/3}

/* cta: the blue band, with the words and the button on one row until they will not fit */
.band-blue.section{padding:64px 0}
.cta{display:flex;align-items:center;justify-content:space-between;gap:24px;flex-wrap:wrap}
.cta h2{margin:0 0 6px;font-size:clamp(26px,3vw,34px)}
.cta p{margin:0;font-size:17px;color:rgba(255,255,255,.9);max-width:640px}

/* faq */
.faq details{--w-def:800px;max-width:var(--w,800px);border-top:1px solid var(--line);padding:18px 0}
.faq summary{display:flex;align-items:center;justify-content:space-between;gap:16px;cursor:pointer;font:600 17px/1.4 var(--body);list-style:none}
.faq summary::-webkit-details-marker{display:none}
.faq summary::after{content:"+";flex:none;color:var(--blue);font:700 20px/1 var(--body)}
.faq details[open] summary::after{content:"\2212"}
.faq details>div{padding-top:12px;color:var(--muted);font-size:15.5px;line-height:1.7}

/* stats: on the page's own background since 2026-09-21, so a figure is ink on white. It used to
   be white on black -- stats.html wrote band-dark itself -- and the colours below are the whole of
   what that class was doing for it. .stats-band carries the slim padding band-dark.section gave it;
   without a rule of its own the band would inherit .section's 80px and the strip would double in
   height. An editor can still put the row on a dark tone from the gear panel, and the two
   overrides at the end are what keep the figures legible when they do -- without them Tone: Dark
   is a control that silently makes the numbers invisible. */
.stats{list-style:none;padding:0;margin:0;display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:24px}
.stats li{border-left:2px solid var(--blue);padding-left:18px}
.stats strong{display:block;font:800 40px/1 var(--head);color:var(--ink);letter-spacing:-.02em}
.stats li>span{display:block;margin-top:8px;font-size:14px;line-height:1.5;color:var(--muted)}   /* > : a counted figure carries a span of its own */
.stats-band.section{padding:40px 0}
.t-dark .stats strong,.band-dark .stats strong{color:var(--white)}
.t-dark .stats li>span,.band-dark .stats li>span{color:var(--muted-dark)}
.band-dark.section{padding:40px 0}

/* testimonial */
.testimonial{--w-def:900px;max-width:var(--w,900px);margin:0;background:var(--grey);border-radius:14px;padding:32px}
.testimonial p{margin:0;font:500 clamp(20px,2.4vw,26px)/1.4 var(--head);color:var(--black)}
.testimonial footer{display:flex;align-items:center;gap:12px;margin-top:20px;font-size:14px;color:var(--muted)}
.testimonial .avatar{flex:none;width:44px;height:44px;border-radius:999px;background:var(--line)}
/* Two quotes beside each other are not two panels: the grey box, the padding and the portrait ring
   all drop away, and the words carry it on their own -- flat 20px, italic, on the page's background.
   A testimonial on its own keeps the card, which is what the design draws it as. */
.column .testimonial{background:none;border-radius:0;padding:0}
.column .testimonial p{font:500 italic 20px/1.5 var(--head);color:var(--ink)}
.column .testimonial footer{margin-top:14px}
.column .testimonial .avatar{display:none}
.testimonial.t-dark{background:var(--black-3);padding:40px}
.t-dark p{color:var(--white)}.t-dark footer{color:var(--muted-dark)}.t-dark .avatar{background:var(--line-dark-2)}

/* pdf / embed_html */
.pdf iframe{width:100%;height:min(80vh,900px);border:1px solid var(--line);border-radius:12px;background:var(--white)}
.pdf p{margin:16px 0 0}
/* download: one file on a white card -- the PDF mark, the words, the size, a solid button -- on a
   light grey band by default (download.html). Below the t-* rules on purpose: `.t-blue h2` and
   `.t-dark p` tie with `.download h2` / `.download p` at (0,1,1), so source order is what keeps the
   card's words dark when an editor puts the band on Blue or Dark. No --w-def: it takes the column. */
.download{display:flex;align-items:center;gap:20px 28px;flex-wrap:wrap;background:var(--white);border:1px solid var(--line);border-radius:12px;padding:28px 32px;text-align:left}
.dl-mark{flex:none;display:flex;align-items:center;justify-content:center;width:52px;height:64px;border:2px solid var(--black);border-radius:4px 14px 4px 4px;font:700 11px/1 var(--mono);letter-spacing:.06em;color:var(--black)}
.dl-text{flex:1;min-width:220px}
.download h2{margin:0;font-size:clamp(22px,2.4vw,28px);line-height:1.2;color:var(--ink)}
.download p{margin:6px 0 0;max-width:640px;font-size:16px;color:var(--muted)}
.download .dl-meta{margin-top:12px;font:600 12px/1 var(--mono);letter-spacing:.08em;color:var(--muted)}
@media(max-width:700px){.download{padding:24px}.download .btn{width:100%;text-align:center}}
.embed iframe,.embed video,.embed embed,.embed object{width:100%;border:1px solid var(--line);border-radius:12px;display:block}

/* --- post_list, one card styled per post type ------------------------------------------------
   One template; the parts a type does not want are switched off here rather than branched in
   Jinja, which is what "reuse the card variants by post_type" means in practice. pl-<slug> is
   written from the resolved post_types row, never from block data. */
.pl .card-img,.pl .card-n,.pl .chips,.card-date,.card-pdf,.card-foot,.card-where{display:none}
.pl .card-img img{display:block;width:100%}
/* the design's empty state for a picture the CMS does not have yet */
.card-img-empty{background:repeating-linear-gradient(135deg,#e9edf2 0 10px,#f4f6f9 10px 20px)}
.pl-service .card-n{display:block;margin:0 0 14px;font:700 12px/1 var(--mono);color:var(--blue)}
.pl-service .chips-kids{display:flex}
/* The mock draws no picture on a service card, so none was ever switched back on -- and a Featured
   image an editor set was in the HTML and invisible (2026-09-23). The empty placeholder stays off:
   a service with no picture keeps exactly the card it had. Same crop as a case-study card. */
.pl-service .card-img:not(.card-img-empty){display:block;margin:0 0 16px;border-radius:10px;overflow:hidden}
.pl-service .card-img img{height:160px;object-fit:cover}
.pl-product .card{padding:0;overflow:hidden}
/* The picture area is a FIXED height, not a minimum, and that is the whole point of these two rules.
   A product photo's aspect ratio is whatever the client sent: the tower is 600x375, the rack unit
   518x135. With `min-height:150px` the box only grew for a picture tall enough to reach it, so the
   short one sat in a 176px box and the tall one in a 191px box -- and since the rule under it draws
   the divider, the line and the title were 15px out of step between two cards sitting side by side.
   Fixed height instead, picture centred in it and capped at 100% rather than a number, so every card
   in the row rules off at the same place whatever shape the next photo turns out to be.
   190 = 150 for the picture + 20 above and below. */
.pl-product .card-img{display:flex;align-items:center;justify-content:center;height:190px;padding:20px;background:var(--white);border-bottom:1px solid var(--line)}
.pl-product .card-img img{width:auto;max-width:100%;max-height:100%;object-fit:contain}
.pl-product .card h3{margin:20px 24px 8px}
.pl-product .card p{margin:0 24px 24px}
.pl-case_study .card-img{display:block;min-height:150px;margin:0 0 16px;border-radius:10px}
.pl-case_study .card-img img{height:150px;object-fit:cover;border-radius:10px}
.pl-case_study .chips{display:flex}
.pl-case_study .chip-industry{background:rgba(53,115,185,.1);border-color:transparent;color:var(--blue-hover)}
.pl-partner .cards{grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
/* On a page the partner list is a logo strip -- no card, no name, just the marks in a row, in the
   colour the client's own files carry. The mock greys them back and fades them to .7; the client
   asked for full colour instead (2026-09-16), so do not restore the filter from it. The archive
   (.arch-body) keeps the cards, which is where the names and roles belong.
   The hover needs its own rule: .card, not a.card -- a partner has no page, so the strip is a row
   of <div>s and the lift in `a.card:hover` above cannot reach one. The mark grows instead of
   lifting: a logo has no card edge to rise off, so a scale reads where a 2px nudge did not. The
   ease-out curve runs the growth almost all the way in the first half of .3s and settles, which is
   what stops it looking like a snap. It is decoration, not an affordance (there is nowhere to
   click), which is why it is a size change and not a colour or border one.
   transform-origin is the default centre, so the mark grows in place and the grid never reflows;
   `position:relative` only raises the growing tile above its neighbours' edges while it is big. */
.pl-partner:not(.arch-body){border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:56px 0}
.pl-partner:not(.arch-body) .sec-head{justify-content:center;margin-bottom:28px}
.pl-partner:not(.arch-body) .eyebrow{color:var(--muted);text-align:center;margin:0}
.pl-partner:not(.arch-body) .cards{grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:12px;align-items:center}
.pl-partner:not(.arch-body) .card{height:64px;display:flex;align-items:center;justify-content:center;padding:10px 16px;border:0;background:none;border-radius:0;transition:transform .3s cubic-bezier(.2,.7,.3,1)}
.pl-partner:not(.arch-body) .card:hover{transform:scale(1.14);position:relative;z-index:1}
.pl-partner:not(.arch-body) .card h3,.pl-partner:not(.arch-body) .card p{display:none}
.pl-partner:not(.arch-body) .card-img{height:100%;margin:0}
.pl-partner:not(.arch-body) .card-img img{max-height:100%;max-width:100%;object-fit:contain}
.pl-partner .card-img{display:flex;align-items:center;height:64px;margin:0 0 14px}
.pl-partner .card-img img{width:auto;max-height:64px;object-fit:contain}
.pl-partner .card h3{font-size:17px}.pl-partner .card p{font-size:13.5px}
/* On a phone the 130px floor above only ever fits TWO tracks, which turns fourteen partners into a
   675px wall of brand marks half the screen wide each -- the opposite of the quiet strip this is
   meant to be, and what the client reported (2026-09-18). 80px is not a guess: it is the largest
   floor that still fits three tracks at 320, measured across the phone range, so the count stays
   the same everywhere instead of flipping between two and three on a few pixels of viewport.
     floor   430    390    360    320
     130    2x7    2x7    2x7    2x7     <- today, 675px tall
     100    3x5    3x5    2x7    2x7
      90    3x5    3x5    3x5    2x7
      80    3x5    3x5    3x5    3x5     <- 523px tall
   The side padding drops with it: 16px of a 169px track was fine, 16px of an 85px one is a fifth of
   the space the mark has to read in. Nothing here applies at 760px and up, where the `pl-cols-*`
   count from `even_cols()` takes over. */
@media(max-width:700px){
  .pl-partner:not(.arch-body) .cards{grid-template-columns:repeat(auto-fit,minmax(80px,1fr))}
  .pl-partner:not(.arch-body) .card{padding:10px 8px}
}
.pl-post .card{padding:0;overflow:hidden}
.pl-post .card-img{display:block;aspect-ratio:16/9;background:var(--grey)}
.pl-post .card-img img{height:100%;object-fit:cover}
.pl-post .card .date{margin:20px 24px 6px}
.pl-post .card h3{margin:0 24px 8px}
.pl-post .card p{margin:0 24px 24px}

/* --- testimonials: the cards, then the sliding row they sit in -------------------------------
   Card first, because the archive at /testimonials uses it too, in a plain grid.
   The card is a grid and not a stack: the photo has to sit beside the name AND the job title, so
   those three share two columns while the stars and the quote span both. Nothing here is a gap --
   `row-gap` is applied between empty tracks as well as full ones, and a card with no stars would
   have carried a band of dead air at the top for a row that renders nothing. Margins belong to the
   elements, so an absent element costs no space. */
.pl-testimonial .card{display:grid;grid-template-columns:auto 1fr;column-gap:14px;
  grid-template-rows:auto 1fr auto auto;
  grid-template-areas:"stars stars" "quote quote" "avatar name" "avatar role";position:relative;overflow:hidden}
.pl-testimonial .stars{grid-area:stars;margin:0 0 14px;font-size:15px;letter-spacing:2px;color:var(--line-2)}
.pl-testimonial .stars b{font-weight:inherit;color:#e0a32e}
/* :not(.card-where) -- the role line is a <p> too, and it belongs on the last row, not the quote's */
.pl-testimonial .card>p:not(.card-where){grid-area:quote;margin:0 0 24px;font:500 italic 16.5px/1.65 var(--head);color:var(--ink-2)}
/* .pl hides every card picture by default; here it is the portrait, so it comes back as a disc.
   With no photo the macro still emits .card-img-empty, whose hatched placeholder would read as a
   broken image at 48px -- it becomes the same flat grey circle the testimonial block already draws. */
.pl-testimonial .card-img{grid-area:avatar;display:block;width:48px;height:48px;border-radius:999px;overflow:hidden;background:var(--line)}
.pl-testimonial .card-img img{width:100%;height:100%;object-fit:cover}
/* No photo is the common case -- an editor adds a quote long before they have a portrait -- so the
   fallback is a person rather than a blank disc. It is a background-image and not markup, because
   the mark is decoration: nothing to read out, no branch in _card.html, and the archive gets it from
   the same rule. The grey is written into the data URI because a var() cannot reach inside one; it
   is a placeholder tone rather than a brand colour, so it does not follow the palette. If it ever
   has to, the upgrade is a mask-image on a ::after with background:var(--muted-dark-2).
   ponytail: one hardcoded grey, #9aa5b4. */
.pl-testimonial .card-img-empty{background:var(--line)
  url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239aa5b4' stroke-width='1.8' stroke-linecap='round'%3E%3Ccircle cx='12' cy='9' r='3.6'/%3E%3Cpath d='M5.6 19.6a6.6 6.6 0 0 1 12.8 0'/%3E%3C/svg%3E")
  center/26px no-repeat}
.pl-testimonial .card h3{grid-area:name;align-self:end;margin:0;font-size:16px}
.pl-testimonial .card-where{grid-area:role;display:block;align-self:start;margin:2px 0 0;font-size:13.5px;color:var(--muted)}
/* The watermark the design puts in the bottom-right of the card -- decoration, so it is CSS and
   never markup, and nothing reads it out. It has to sit BEHIND the words: ::after paints after its
   siblings by default, and at 390 the card is 289px wide, which is where a long job title runs under
   the mark and came out as "Head of IT, Ascent Fina█i". z-index:-1 is not the fix -- .card has no
   stacking context of its own, so that would drop the mark behind the card's own white background
   and lose it entirely. Lifting the real children over it is. */
.pl-testimonial .card::after{content:"\201D";position:absolute;right:14px;bottom:-26px;
  font:800 110px/1 var(--head);color:var(--grey);pointer-events:none}
.pl-testimonial .card>*{position:relative;z-index:1}

/* The sliding row: testimonials, and product lists since 2026-09-23 -- `pl-rail` is set by blocks._rail()
   and only ever by post_list, so an archive (/testimonials, /products: the full lists) stays a grid.
   The centred heading is the testimonials' own look and is keyed to them, not to the row.
   .cards stops being a grid and becomes the scroll container: that one line is what gives the wheel,
   a trackpad and a finger on a phone real control of the row with no script involved. site.js only
   adds the arrows, the dots and the drift on top of it. The scrollbar is hidden because the dots say
   the same thing; the scrolling itself is untouched. */
.pl-testimonial:not(.arch-body) .sec-head{justify-content:center;text-align:center}
.pl-testimonial:not(.arch-body) .sec-head .eyebrow{margin:0}
/* padding-top + the matching negative margin: a product card is a link, and a.card:hover lifts it 2px,
   which a scroll container clips. The pair leaves the row exactly where it was. */
.pl-rail .cards{display:flex;grid-template-columns:none;gap:20px;
  overflow-x:auto;scroll-snap-type:x mandatory;scrollbar-width:none;padding:4px 0;margin-top:-4px}
/* `safe center` and not plain `center`: two published quotes do not fill the row, and left-aligned
   under a centred heading they read as a layout that broke. Plain `center` would fix that and break
   the overflowing case instead -- a centred flex line puts its overflow on BOTH sides and the first
   card becomes unreachable, because a scroll container cannot scroll to a negative offset. `safe`
   is the keyword for exactly this: centre while it fits, fall back to flex-start the moment it does
   not. Measured both ways below. */
.pl-testimonial.pl-rail .cards{justify-content:safe center}
.pl-rail .cards::-webkit-scrollbar{display:none}
/* A phone shows one card and a slice of the next, and the slice is what says "there are more" before
   anybody has touched it -- so it is the SLICE that is held constant, not the card. `100%` on a
   flex-basis resolves against the rail's inner width, so the card takes everything except a fixed
   68px: the 20px gap plus a 48px sliver. Above about 420px the 340px arm wins and nothing changes.
   The earlier `74vw` did the opposite of what it was for: the card and the container both shrank
   with the viewport, so the peek fell away exactly when it was needed most -- measured 41px at 390,
   34px at 360, 23px at 320. Now it is 48px at all three.
   Not `100vw`: that includes the scrollbar, and would put back the very overflow `.hero` fixes. */
.pl-rail .card{flex:0 0 min(340px, 100% - 68px);scroll-snap-align:start}
/* The first card sits flush with the scroller's left edge, and a scroller clips whatever is outside
   it -- so the browser's own focus ring, drawn just outside the card, lost its left side. Inside the
   edge instead, the same ring the accordion's rows use. Only a link card can take focus at all. */
.pl-rail a.card:focus-visible{outline:2px solid var(--blue);outline-offset:-2px}
.rail-nav{display:flex;align-items:center;justify-content:center;gap:16px;margin-top:28px}
/* This line is load-bearing, not tidiness. The nav is served with `hidden` so a blocked script
   leaves no dead buttons, and site.js also re-hides it when the row has nothing to scroll (two
   published quotes fit 1440). `display:flex` on a class beats the UA sheet's `[hidden]{display:none}`
   on specificity, so without this the attribute does nothing and the arrows show in both cases --
   which is what the first measured screenshot showed. */
.rail-nav[hidden]{display:none}
.rail-arrow{width:40px;height:40px;border:1px solid var(--line);border-radius:999px;background:var(--white);
  color:var(--ink);font:400 20px/1 var(--head);cursor:pointer;transition:background .2s,border-color .2s,color .2s}
.rail-arrow:hover{background:var(--blue);border-color:var(--blue);color:var(--white)}
.rail-arrow[disabled]{opacity:.35;cursor:default;background:var(--white);border-color:var(--line);color:var(--ink)}
.rail-dots{display:flex;gap:8px}
.rail-dot{position:relative;width:8px;height:8px;padding:0;border:0;border-radius:999px;background:var(--line-2);cursor:pointer;
  transition:width .25s,background .25s}
.rail-dot[aria-current="true"]{width:22px;background:var(--blue)}
/* An 8px dot is a fair mark and an unfair target. The hit area is grown with a transparent ::after
   rather than by padding the button, so the painted dot stays 8px and the `transition:width` on the
   active one still animates the thing you can see. */
.rail-dot::after{content:"";position:absolute;inset:-12px}
@media(max-width:700px){
  /* 24px is the card padding .arch-body.pl-service already uses on a phone; 28px left only 181px of
     text inside a 237px card at 320. 44px is this stylesheet's own touch target (.btn, above) -- the
     arrows were 40px, which is under it. */
  .pl-testimonial .card{padding:24px}
  .rail-arrow{width:44px;height:44px}
}

/* "Items per row" on an Automatic list. The count is worked out in blocks.py (`even_cols()` when the
   editor picks Even rows) and arrives as a class, so nothing an editor typed reaches a style
   attribute -- the same reason `tone` and `width` are whitelists. Two classes plus .cards matches
   the specificity of `.pl-partner:not(.arch-body) .cards` above, so this group must stay below it.
   `minmax(0,1fr)` and not the per-type minimum: a fixed count only fits if the tracks may shrink.
   760px is where it starts, chosen by looking: at 834 seven logos across are smaller but perfectly
   legible, and 7 + 7 beats the 5 + 5 + 4 that wrapping by width gives there. Below it the strip
   wraps by width again -- at 390 that is two per row, which for fourteen is seven even rows anyway.
   ponytail: one breakpoint for every count. A list of 8 could hold its row lower than one of 7;
   if that ever matters, give the class its own floor rather than moving this one. */
@media(min-width:760px){
  .pl.pl-cols-2 .cards{grid-template-columns:repeat(2,minmax(0,1fr))}
  .pl.pl-cols-3 .cards{grid-template-columns:repeat(3,minmax(0,1fr))}
  .pl.pl-cols-4 .cards{grid-template-columns:repeat(4,minmax(0,1fr))}
  .pl.pl-cols-5 .cards{grid-template-columns:repeat(5,minmax(0,1fr))}
  .pl.pl-cols-6 .cards{grid-template-columns:repeat(6,minmax(0,1fr))}
  .pl.pl-cols-7 .cards{grid-template-columns:repeat(7,minmax(0,1fr))}
  .pl.pl-cols-8 .cards{grid-template-columns:repeat(8,minmax(0,1fr))}
  /* ...but not inside a column: a fixed count in half the width squashes the tracks, and a
     column is already the editor choosing a width. One rule, 4 classes, so it beats the seven. */
  .column .pl[class*="pl-cols-"] .cards{grid-template-columns:repeat(auto-fit,minmax(130px,1fr))}
  /* A Cards section's Columns: the count arrives as --per, an int from blocks._cols(). Inside a
     column it is dropped for the same reason, and the cards wrap at their own 260px again. */
  .cards[style*="--per"]{grid-template-columns:repeat(var(--per),minmax(0,1fr))}
  .column .cards[style*="--per"]{grid-template-columns:repeat(auto-fit,minmax(260px,1fr))}
}

/* --- post_list as an accordion (List style; the default for a top-level services list) ---------
   The client's design option 1a (2026-09-21): one row per service group, one open at a time, hover
   or tap, the open one on black. It is a radio group, so the browser itself enforces the "one" and
   nothing here counts rows -- unlike the mega panel there is no ceiling on how many groups fit.
   Four things are load-bearing:
   1. .acc-t is hidden with opacity, NEVER display:none. It IS the keyboard control -- the arrow
      keys walk a radio group and check as they go, which opens each row in turn -- and
      display:none takes it out of the tab order. The header's .mg-toggle may be display:none
      because it is pointer-only and its label is the whole control.
   2. Keyboard focus opens a row (:focus-visible inside it; on a phone, any focus). A closed body is
      0fr + overflow:hidden, which hides the child links without removing them from the tab order
      -- the same pairing the mega panel uses.
   3. The hover rule sits in @media(hover:hover). A touch browser leaves :hover on the last thing
      tapped, and a stuck hover on one row would keep every other row shut.
   4. There is no "close the others" rule, on purpose: the checked row's own selector is guarded
      with :not(:has(.acc-row:hover)), so while a pointer is inside the accordion it simply stops
      matching and the row falls back to the closed default. A close-all rule would out-specify
      the rule meant to open the hovered one.
   Every class is acc-*: .chips, .card-img, .card-n and .card-foot are all display:none under a
   bare .pl and each .pl-<slug> puts its own back, so a pill called .chip would arrive invisible. */
/* The open row's palette lives on .acc, so a variant is three lines rather than a second copy of
   the open-selector group below. Editors pick it with "Colour when open" (client, 2026-09-21);
   only these two class names can ever be written, because post_list.html compares the value. */
.acc{background:var(--white);border:1px solid var(--line);border-radius:12px;overflow:hidden;
  --o-bg:var(--black);--o-nm:var(--white);--o-mt:var(--muted-dark);--o-kid:var(--white);--o-kln:var(--line-dark-2);--o-lnk:var(--blue-light)}
.acc.acc-blue{--o-bg:var(--blue);--o-mt:var(--blue-tint);--o-kln:var(--blue-light);--o-lnk:var(--white)}
.acc.acc-light{--o-bg:var(--grey);--o-nm:var(--black);--o-mt:var(--muted);--o-kid:var(--ink);--o-kln:var(--line-2);--o-lnk:var(--blue)}
.acc-row{--rows:0fr;--bg:var(--white);--nm:var(--black);--mt:var(--muted);--chev:rotate(0);
  background:var(--bg);border-top:1px solid var(--line);transition:background .3s}
.acc-row:first-child{border-top:0}
.acc-t{position:absolute;opacity:0;pointer-events:none;margin:0}
.acc-hr{position:relative;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:16px;align-items:center;padding:22px 28px}
.acc-hr h3{margin:0;font:800 clamp(20px,2.4vw,26px)/1.2 var(--head);color:var(--nm);transition:color .3s}
/* the label holds the name, and its ::after is the header strip's hit area: the control that opens a
   shut row. An OPEN row is a link to the group's page instead -- .acc-all::after, below. */
.acc-hd{cursor:pointer}
.acc-hd::after{content:"";position:absolute;inset:0}
.acc-n{font:400 13px/1 var(--body);color:var(--mt);white-space:nowrap;transition:color .3s}
.acc-n i{display:inline-block;margin-left:10px;font-style:normal;transform:var(--chev);transition:transform .3s}
.acc-body{display:grid;grid-template-rows:var(--rows);transition:grid-template-rows .35s cubic-bezier(.2,.7,.2,1);position:relative}
.acc-body>div{overflow:hidden}
.acc-in{padding:0 28px 26px;display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:24px;align-items:start}
.acc-blurb{display:flex;flex-direction:column;align-items:flex-start;gap:12px;max-width:480px}
.acc-blurb p{margin:0;font:400 16px/1.6 var(--body);color:var(--mt)}
.acc-all{font:600 14px/1 var(--body);color:var(--o-lnk);text-decoration:none}
.acc-all:hover{color:var(--nm)}
/* The open row is one link to the group's page (user, 2026-09-24: "make this clickable ... the
   sections as a whole"). This ::after is its hit area, and it needs no open/shut switch of its own:
   on a phone it is anchored to .acc-body, which is 0px tall while the row is shut, so the first tap
   still lands on the label and opens it; with a mouse (the hover block below) it is anchored to the
   whole row, which is only ever clickable while the pointer is on it, i.e. while it is open. The
   overflow:hidden child does not clip it, because its containing block is outside that child.
   The pills stay clickable because they come later in the tree and are position:relative, which
   paints them above this without a z-index -- drop that and every pill opens the group instead. */
.acc-all::after{content:"";position:absolute;inset:0}
.acc-kids{display:flex;flex-wrap:wrap;gap:8px;align-content:flex-start}
.acc-kids a{position:relative;padding:10px 14px;border:1px solid var(--o-kln);border-radius:8px;color:var(--o-kid);font:500 14px/1.3 var(--body);white-space:nowrap;text-decoration:none;transition:border-color .15s,color .15s}
.acc-kids a:hover{border-color:var(--o-lnk);color:var(--o-lnk)}
/* Open: the checked row, and the row the keyboard is in, while nothing is hovered. Nothing else
   -- no row is checked in the markup, so the section loads with every row shut and opens only under
   a pointer or a tap (client, 2026-09-21: "first section should be closed as well if the mouse is
   not over any services"). That also makes two accordions on one page a non-event: they share a
   radio name, so clicking in one unchecks the other, and all-shut is now the ordinary state.
   Keyboard focus is :focus-visible and not :focus-within, and it gives way to the pointer like the
   checked row does (user, 2026-09-24, two rows open at once). A mouse click on the row's link
   focuses it too, so with :focus-within the clicked row stayed open -- through the wait for the next
   page, after Back, after a ctrl-click -- and hovering another opened a second one. */
.acc:not(:has(.acc-row:hover)) .acc-row:has(.acc-t:checked),
.acc:not(:has(.acc-row:hover)) .acc-row:has(:focus-visible){--rows:1fr;--bg:var(--o-bg);--nm:var(--o-nm);--mt:var(--o-mt);--chev:rotate(180deg)}
@media(hover:hover){
  .acc-row:hover{--rows:1fr;--bg:var(--o-bg);--nm:var(--o-nm);--mt:var(--o-mt);--chev:rotate(180deg)}
  /* mouse only: the heading and the count join the link. Never outside this block -- on a phone the
     row-sized hit area would cover a shut row's header, and the first tap would leave the page */
  .acc-row{position:relative}.acc-body{position:static}
}
/* a phone keeps what it had: a tap on the header focuses the radio, and its stuck :hover is on the
   tapped row, which is exactly what switches the checked rule above off -- so there the focus is
   what holds the row open, and :focus-visible does not match a tap */
@media(hover:none){
  .acc-row:focus-within{--rows:1fr;--bg:var(--o-bg);--nm:var(--o-nm);--mt:var(--o-mt);--chev:rotate(180deg)}
}
.acc-row:has(.acc-t:focus-visible){outline:2px solid var(--blue);outline-offset:-2px}
@media(max-width:700px){
  .acc-hr{padding:18px}
  .acc-in{padding:0 18px 22px}
}

/* An archive holds however many posts happen to be published, so its grid is auto-FILL: the empty
   tracks stay, and one blog post keeps a tile's width instead of stretching into a billboard across
   the page. A deck inside a page is auto-fit, because there the editor chose the count. 300px is the
   design's measure for the blog and case-study grids -- three across at 1200. */
.arch-body .cards{grid-template-columns:repeat(auto-fill,minmax(300px,1fr))}
.arch-body.pl-partner .cards{grid-template-columns:repeat(auto-fill,minmax(220px,1fr))}

/* The four shapes an archive gives a type that a card deck cannot. Each keys off .arch-body, so the
   same list inside a page (a post_list section) stays the deck the home page wants. */
/* products: the archive card is taller, prices and sells */
.arch-body.pl-product .cards{grid-template-columns:repeat(auto-fill,minmax(280px,1fr))}
.arch-body.pl-product .card{display:flex;flex-direction:column}
/* the archive had it worse -- 217px against 180px, a 37px step. 216 = 160 + 28 above and below */
.arch-body.pl-product .card-img{height:216px;padding:28px}
.arch-body.pl-product .card-img img{max-height:100%}
.arch-body.pl-product .card h3{margin:20px 24px 8px;font-size:22px}
.arch-body.pl-product .card h3 a{color:inherit;text-decoration:none}
.arch-body.pl-product .card h3 a:hover{color:var(--blue)}
.arch-body.pl-product .card p{margin:0 24px 12px;font-size:14.5px}
.arch-body.pl-product .card-foot{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;margin:auto 24px 24px}
.arch-body.pl-product .card-foot b{font:800 17px/1 var(--head);color:var(--black)}
.arch-body.pl-product .card-foot .btn{padding:12px 18px}
/* services: one full-width row per group, its children as tiles you can click */
.arch-body.pl-service .cards{grid-template-columns:1fr;gap:20px}
/* the words in the first column, the child tiles in the second: the card's children are a flat list,
   so each one names the column it belongs to rather than being wrapped in a div for it. Four
   `auto` rows for picture, number, title and blurb -- a card with no picture leaves the first one
   0px tall -- then the 1fr that takes the slack when the tiles run longer. The tiles' span below
   counts these rows, and the two change together. */
.arch-body.pl-service .card{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));grid-template-rows:auto auto auto auto 1fr;column-gap:24px;padding:32px}
.arch-body.pl-service .card-img,.arch-body.pl-service .card-n,.arch-body.pl-service .card h3,.arch-body.pl-service .card p{grid-column:1}
.arch-body.pl-service .card h3{margin:0 0 10px;font-size:26px}
.arch-body.pl-service .card h3 a{color:inherit;text-decoration:none}
.arch-body.pl-service .card h3 a:hover{color:var(--blue)}
.arch-body.pl-service .card p{margin:0}
.arch-body.pl-service .chips-kids{display:grid;grid-column:2;grid-row:1 / span 5;align-content:start;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:8px;margin:0}
.arch-body.pl-service .chips-kids .chip{display:flex;justify-content:space-between;gap:8px;padding:12px 14px;border-radius:8px;font:500 15px/1.3 var(--body);color:var(--ink);text-decoration:none;transition:border-color .15s,color .15s}
.arch-body.pl-service .chips-kids .chip:hover{border-color:var(--blue);color:var(--blue)}
/* Under 700px auto-fit drops the card to one track, but .chips-kids is still pinned to column 2 --
   that makes an implicit auto-sized track with a 180px floor, and the card runs off the side of the
   phone. Unpinning it is the whole fix: the template columns can say what they like, a stale
   grid-column recreates the implicit track anyway. Stacked, it is the shape a service page already
   ends on (.siblings .tiles). */
@media(max-width:700px){
  .arch-body.pl-service .card{grid-template-columns:1fr;grid-template-rows:none;padding:24px}
  .arch-body.pl-service .chips-kids{grid-column:1;grid-row:auto;margin:18px 0 0}
}
/* events: a row led by the date, cut out of start_date */
.arch-body.pl-event .cards{grid-template-columns:1fr;gap:16px}
.arch-body.pl-event .card{display:grid;grid-template-columns:auto minmax(0,1fr);gap:24px;align-items:center;padding:24px}
.arch-body.pl-event .card-date{display:block;width:84px;text-align:center;background:var(--black);border-radius:10px;padding:12px 8px;grid-row:span 3}
.arch-body.pl-event .card-date b{display:block;font:800 26px/1 var(--head);color:var(--white)}
.arch-body.pl-event .card-date i{display:block;margin-top:4px;font-style:normal;font-size:12px;color:var(--muted-dark)}
.arch-body.pl-event .card h3{font-size:22px}
.arch-body.pl-event .card-where{display:block;margin:0;font-size:15px;color:var(--muted)}
/* datasheets: a row with the PDF mark and its own download button */
.arch-body.pl-datasheet .cards{grid-template-columns:1fr;gap:12px}
.arch-body.pl-datasheet .card{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:16px;padding:20px 24px;border-radius:10px}
.arch-body.pl-datasheet .card-pdf{display:flex;align-items:center;justify-content:center;width:44px;height:54px;border:2px solid var(--black);border-radius:4px;font:700 10px/1 var(--mono);grid-row:span 2}
.arch-body.pl-datasheet .card h3{margin:0;font:700 16px/1.3 var(--body)}
.arch-body.pl-datasheet .card h3 a{color:inherit;text-decoration:none}
.arch-body.pl-datasheet .card h3 a:hover{color:var(--blue)}
.arch-body.pl-datasheet .card p,.arch-body.pl-datasheet .card-where{display:block;margin:0;font-size:13.5px;color:var(--muted)}
.arch-body.pl-datasheet .card-foot{display:block;grid-column:3;grid-row:1 / span 2}
/* the sibling / child row a service page ends on */
.siblings .tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px}
.siblings .tiles a{display:flex;justify-content:space-between;gap:8px;padding:20px;background:var(--white);border:1px solid var(--line);border-radius:10px;color:var(--ink);text-decoration:none;font-weight:600;transition:border-color .15s,color .15s}
.siblings .tiles a:hover{border-color:var(--blue);color:var(--blue)}

/* spec_table */
.specs{--w-def:800px;width:100%;max-width:var(--w,800px);border-collapse:collapse;margin:0;background:none;border:0}
.specs th,.specs td{padding:14px 0;border-bottom:1px solid var(--line);text-align:left;font-size:15px;vertical-align:top}
.specs th{width:35%;background:none;font-weight:500;color:var(--muted)}
.specs caption{caption-side:top;text-align:left;margin-bottom:16px;font:800 22px/1.2 var(--head);color:var(--ink)}
/* the design's configuration panel is this table on a dark card: values right, rules in the dark line */
.t-dark .specs th,.t-dark .specs td{border-bottom-color:var(--line-dark)}
.t-dark .specs th{color:var(--muted-dark)}
.t-dark .specs td{text-align:right;color:var(--white)}
.t-dark .specs caption{color:var(--white)}
.t-dark .specs tr:last-child th,.t-dark .specs tr:last-child td{border-bottom:0}

/* table: the spreadsheet section. The scroller only scrolls, so a table wider than a phone moves
   sideways instead of crushing every column; the card -- the frame and its 12px corners -- is drawn
   by the cells themselves (user, 2026-10-08). It used to be the scroller's border, and a collapsed
   table ignores a cell's border-radius, so when an editor gave the cells dark lines the card cut
   them off square inside its own light curve. Separate borders let a corner cell round, in the
   line's own colour; table_edges() gives each edge to one cell -- top and left, plus the right of
   the last column and the bottom of the last row -- so no line is drawn twice. The corner selectors
   go through table>:first-child / :last-child so they hold with or without a <thead>.
   No --w-def: a table of figures takes the whole column unless the editor's Width says otherwise
   (the shared rule above). A cell's own colours and lines are an inline style from cell_style(), so
   they win over all of this without trying. */
.gt-scroll{overflow-x:auto}
.grid-table table{width:100%;border-collapse:separate;border-spacing:0;font-size:15px;color:var(--ink-2)}
.grid-table th,.grid-table td{padding:.7rem 1rem;border:0 solid var(--line);border-width:1px 0 0 1px;background:var(--white);text-align:left;vertical-align:top;min-width:6rem}
.grid-table tr>:last-child{border-right-width:1px}
.grid-table table>:last-child>tr:last-child>*{border-bottom-width:1px}
.grid-table table>:first-child>tr:first-child>:first-child{border-top-left-radius:12px}
.grid-table table>:first-child>tr:first-child>:last-child{border-top-right-radius:12px}
.grid-table table>:last-child>tr:last-child>:first-child{border-bottom-left-radius:12px}
.grid-table table>:last-child>tr:last-child>:last-child{border-bottom-right-radius:12px}
.grid-table th{background:var(--grey);font-weight:600;color:var(--ink)}

/* contact_form: the plain white card, and the grey panel a quote or career form gets */
.lead-form{--w-def:640px;max-width:var(--w,640px);background:var(--white);border:1px solid var(--line);border-radius:12px;padding:28px}
.lead-form .form-title{margin:0;font-size:22px;line-height:1.15}
.lead-form label{display:block;margin:14px 0 0;font:500 14px/1.4 var(--body);color:var(--ink)}
.lead-form input,.lead-form textarea,.lead-form select{width:100%;margin-top:6px;padding:12px 14px;border:1px solid var(--line-2);border-radius:6px;background:var(--white);color:var(--ink);font:inherit;font-size:15px}
.lead-form textarea{min-height:120px}
.lead-form .btn{margin-top:20px}
.field-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:0 16px}
.lead-form.cf-grey{background:var(--grey);border-color:transparent}
.notice{background:var(--green-bg);border:1px solid rgba(10,138,58,.25);color:var(--green);padding:14px 16px;border-radius:8px;margin-bottom:20px;font-size:15px}

/* warranty_check: the box on the left, the answer on the right, nothing on the right until asked */
.warranty{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:32px;align-items:start}
.warranty-check{margin:0}
.warranty-check label{margin-top:0}
.warranty-check input[name=sn]{padding:14px 16px;border-radius:8px;font:500 15px/1.2 var(--mono);text-transform:uppercase}
.warranty .hint{margin:8px 0 0;font-size:13px;color:var(--muted)}
.warranty-result{background:var(--white);border:1px solid var(--line);border-radius:12px;overflow:hidden}
.warranty-banner{padding:24px 28px;color:var(--white)}
.warranty-result.in .warranty-banner{background:var(--green)}
.warranty-result.out .warranty-banner{background:var(--red)}
.warranty-label{margin:0;font-size:12px;text-transform:uppercase;letter-spacing:.08em;opacity:.85}
.warranty-state{margin:4px 0 6px;font:800 26px/1.1 var(--head)}
.warranty-note{margin:0;font-size:14px;opacity:.9}
.warranty-result dl{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:16px;margin:0;padding:24px 28px}
.warranty-result dt{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted)}
.warranty-result dd{margin:4px 0 0;font-weight:600}
.warranty-result dd.mono{font-family:var(--mono);font-weight:500}
.warranty-remarks{margin:0 28px 24px;padding:14px 16px;background:var(--grey);border-radius:8px;font-size:14px}
.warranty-foot{display:flex;gap:20px;flex-wrap:wrap;margin:0;padding:16px 28px;border-top:1px solid var(--line)}
.warranty-foot a{font:600 14px/1 var(--body);color:var(--blue);text-decoration:none}
.warranty-result.none{padding:28px}
.warranty-result.none h3{margin:0 0 8px;font-size:20px}
.warranty-result.none p{margin:0 0 16px;font-size:15px;color:var(--muted)}

/* spacer / divider: the two sections that are not content. Both zero .section's own 80px first --
   a gap that is 160px before anyone picks a height is not a gap. The heights are scoped to .spacer
   rather than left bare like .fx-*, because "height" is a plausible future field on another block
   (see the ponytail note on pdf in blocks.py) and sp-* is emitted by section_class for whoever
   declares it. */
.spacer{padding:0;height:40px}
.spacer.sp-small{height:16px}.spacer.sp-medium{height:40px}.spacer.sp-large{height:80px}.spacer.sp-huge{height:140px}
.divider{padding:24px 0}
.divider hr{border:0;border-top:1px solid var(--line);margin:0}
/* t-dark is a class on the divider itself, not an ancestor, so this is .divider.t-dark and not a descendant */
.divider.t-dark hr,.divider.t-blue hr{border-top-color:var(--line-dark-2)}
/* Inside a column the stacking gap is padding on the section that FOLLOWS (.column>.section+.section
   near the top of this file). A spacer is that gap, so it neither takes one nor hands one on --
   otherwise a Small spacer stands 40px tall instead of the 16px it says. Written .column>.section.spacer
   to tie that (0,3,0) rule and win on source order. */
.column>.section.spacer{padding-top:0}
.column>.section.divider{padding:16px 0}
.column>.spacer+.section,.column>.divider+.section{padding-top:0}

/* How much air a section keeps, set per section under its own gear (blocks.py PADS -> pad-*).
   Only ever LESS than the design's own: adding air is a Spacer's job, and keeping the two apart is
   what lets both exist (design.md 2026-09-10). 0 / 24 / 40 are all numbers already in this file.

   THREE SELECTORS PER STEP, AND THE PLACEMENT IS THE MECHANISM. Every rule this has to beat is a
   specificity TIE, so the group lives here, after the last padding rule in the file, and wins on
   source order -- the same trick .column>.section.spacer plays just above:
     .section.pad-*          (0,2,0)  beats .section (252) and .divider (926); ties .band-blue.section
                                      (573) and .band-dark.section (591)
     .hero.pad-*             (0,2,0)  .hero is NOT a .section, so the first selector never matches it;
                                      ties nothing but beats .hero (442), .hero-dark (474) and the
                                      max-width:700px pair (491) -- a media query adds no specificity
     .column>.section.pad-*  (0,3,0)  ties .column>.section.t-* (293), .column>.section+.section (294)
                                      and .column>.section.divider (935)
   Move this group earlier and it silently loses to whichever of those it now precedes.

   padding-block, not padding: .column>.section.t-* makes a toned section in a column a card, and the
   shorthand would strip the 28px at its sides and put the words against the edge.
   :not(.spacer) because a Spacer's Height IS its spacing -- pad-medium would otherwise win at
   (0,2,0) and stand a Small spacer 16px tall plus 80px of padding, two controls on one gap. */
.section.pad-none:not(.spacer),.hero.pad-none,.column>.section.pad-none:not(.spacer){padding-block:0}
.section.pad-small:not(.spacer),.hero.pad-small,.column>.section.pad-small:not(.spacer){padding-block:24px}
.section.pad-medium:not(.spacer),.hero.pad-medium,.column>.section.pad-medium:not(.spacer){padding-block:40px}

/* --- archives and detail pages ----------------------------------------------------------------
   Two header treatments: the catalogue types get the black band (band-dark, the same class the
   stats block uses), the reading types get the white one. archive.html picks which. */
.arch-head{background:var(--white);border-bottom:1px solid var(--line);padding:56px 0 40px}
.arch-head .page-title{margin-top:14px}
.arch-head .lead{max-width:640px}
.arch-head.band-dark{background:var(--black);border-bottom:0;padding:64px 0 56px}   /* names the background again: .arch-head is later in the file than .band-dark and ties on specificity */
.band-dark .breadcrumb,.band-dark .breadcrumb a{color:var(--muted-dark)}
.band-dark .breadcrumb li:last-child{color:var(--white)}
.band-dark .breadcrumb li+li::before{color:var(--muted-dark-2)}
.band-dark .lead{color:var(--muted-dark)}
.filters{display:flex;flex-wrap:wrap;gap:8px;margin:24px 0 0}
.filters .chip{padding:6px 14px;font-size:14px;text-decoration:none;transition:border-color .15s,color .15s}
.filters .chip:hover{border-color:var(--blue);color:var(--blue)}
.band-dark .filters .chip{border-color:var(--line-dark-2);color:var(--muted-dark)}
.band-dark .filters .chip:hover{border-color:var(--blue);color:var(--white)}
.arch-body{background:var(--grey);padding:56px 0 48px}
/* the sitemap page: a link list per content type, not the deck of cards an archive draws. Sixty
   pages as cards is a scroll, and somebody who opens a sitemap is looking for one page.
   Multi-column, not grid. A grid puts the groups in rows, so the tallest in a row sets its height --
   Services has 22 entries and Main has 1, which left three-quarters of the first row empty and
   pushed everything after it below the fold (measured at 1440: 5839px of page). Columns flow
   instead, so a short group is followed by the next one rather than by whitespace. break-inside
   keeps a group whole, and the count collapses to one on a phone with no media query of its own. */
.sm-groups{columns:3 260px;column-gap:32px}
.sm-group{break-inside:avoid;margin:0 0 32px}
.sm-group h2{font-size:15px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin:0 0 12px}
.sm-group h2 a{color:inherit;text-decoration:none}
.sm-group h2 a:hover{color:var(--blue)}
.sm-group ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px}
.sm-group a{text-decoration:none}
.sm-group li a{color:var(--ink);font-size:15px}
.sm-group li a:hover{color:var(--blue)}

/* a detail page's head: words left, featured image right when there is one */
.page-head>.wrap{display:grid;gap:48px;align-items:center}
.page-head.has-media>.wrap{grid-template-columns:repeat(auto-fit,minmax(300px,1fr))}
.page-head-text{min-width:0}
.page-head .breadcrumb{margin:0 0 16px}
.page-head .chips{margin:0 0 20px}
/* A page that leads with a hero or a columns section has no .page-head. When a hero is the first
   thing on it, the crumb is the hero's own first row (.hero-crumb, 2026-09-24); otherwise -- a
   columns section, or the short-field strip or long fields drawn above the hero -- post.html draws
   it on its own in a bare .wrap, and .wrap is the side gutter and nothing else, which left the crumb
   10px under the header rule (2026-09-19, when every service, About, Careers and Contact page took
   this branch). 48px is .page-head's own top padding, so the crumb sits at the same height
   whichever of the two branches drew it. Nothing below: what follows always brings its own top
   gap (.meta-strip 32px, the hero's padding, a .section for details). */
.crumb-bar{padding-block:48px 0}
.page-media img{max-width:440px;margin-inline:auto}
/* An article stacks: title, date, then the picture across the whole column, then the rule that
   divides the head from the writing. The rule is the section's own bottom border, the same way the
   hero and an archive head draw theirs, so it spans the page rather than the 1200px column.
   Both selectors below list every type post.html calls an `article` -- the two must agree, or the
   markup stacks while the picture keeps the banner crop meant for a card. */
.pt-post .page-head,.pt-case_study .page-head{padding-bottom:40px;border-bottom:1px solid var(--line)}
/* and it keeps its own dimensions. .featured is a banner crop -- width:100% and a 440px ceiling
   with object-fit:cover -- which is right for a card or a product shot and wrong for a picture
   inside an article: a small one was blown up to 1200 wide and then had its top and bottom cut off.
   Here the browser sizes it from the file, and only shrinks it if it is wider than the column. */
.pt-post .page-media img,.pt-case_study .page-media img{width:auto;height:auto;max-width:100%;max-height:none;object-fit:initial}
/* "Put the image in the title just like hero section" (client, 2026-09-19). The head takes the
   dark hero's band: the Featured image full-bleed behind it at .hero-bg's own opacity, the words
   on top. Almost nothing new -- `band-dark` on the same element already turns the heading white
   and restyles the breadcrumb and the lead (archive.html has done this since it was written), and
   `.hero-bg` is reused exactly as hero.html uses it, which is why the two bands match without a
   single measurement being copied between them.
   THIS GROUP MUST STAY BELOW .pt-post .page-head (1001), which draws a 1px --line hairline across
   the bottom -- a light rule under a black band. Both are (0,2,0), so it is a specificity tie and
   source order is the only thing deciding it, exactly as .arch-head.band-dark (970) and the pad-*
   group rely on. Moved up, it renders and reads as a bug in the markup.
   The two rules below exist because band-dark does NOT cover them: .post-meta has no dark variant,
   and band-dark's only chip rule is .band-dark .filters .chip, which is the archive's filter row. */
.page-head.head-banner{position:relative;overflow:hidden;border-bottom:0;padding:96px 0 80px}
.page-head.head-banner>.wrap{position:relative}
.page-head.head-banner .post-meta{color:var(--muted-dark)}
.page-head.head-banner .chip{background:none;border-color:var(--line-dark-2);color:var(--muted-dark)}
.page-head:is(.head-banner,.head-lit) .chip:hover{border-color:var(--blue);color:var(--white)}
@media(max-width:700px){.page-head.head-banner{padding:64px 0 56px}}
/* The title band's own settings (client, 2026-10-08; blocks.page_head(), meta._head). Background
   and alignment are the section ⚙'s t-* and al-* classes on .page-head, so they arrive styled; what
   is here is only what a head needs on top of them. head-lit is a Dark or Blue background: the
   crumb takes the hero's lit rule (above), the date and chips go light like the banner's, and the
   article hairline -- a light rule under a dark band -- goes. A picture-behind band with no
   Background chosen is still `band-dark head-banner` alone and never gets head-lit, which is what
   keeps every banner already published as it was. Height overrides the banner's padding too, so
   this group stays BELOW the banner rules and their phone step: all of them are (0,2,0) or less
   and only source order separates them. head-left moves the picture only beside the words --
   an article stacks, and a picture above its title is not what "on the left" means. */
.page-head.head-lit{border-bottom:0}
.page-head.head-lit .post-meta{color:inherit}
.page-head.head-lit .chip{background:none;border-color:var(--line-dark-2);color:inherit}
.page-head.al-center .breadcrumb ol,.page-head.al-center .btns{justify-content:center}
.page-head.al-center .lead{margin-inline:auto}
.page-head.has-media.head-left .page-media{order:-1}
.page-head.head-compact{padding:24px 0 32px}
.page-head.head-tall{padding:128px 0 112px}
@media(max-width:700px){.page-head.head-tall{padding:80px 0 72px}}
a.chip{text-decoration:none}
a.chip:hover{border-color:var(--blue);color:var(--blue)}
.chip-industry{background:rgba(53,115,185,.1);border-color:transparent;color:var(--blue-hover)}
.meta-strip{padding:32px 0}
/* long fields read as the article; short ones are the label-and-value pairs above. white-space:pre-line keeps the
   editor's paragraph breaks without letting any markup through. */
.meta-prose{max-width:var(--reading)}
.meta-prose section+section{margin-top:32px}
.meta-prose h2{margin:0 0 12px;font-size:24px}
.meta-prose p{margin:0;font-size:17px;line-height:1.7;color:var(--ink-2);white-space:pre-line}

/* checkout: the design's modal, as a page. site.js drives the sliding row and holds no modal machinery */
.checkout-card{max-width:460px;margin:32px 0 0;background:var(--white);border:1px solid var(--line);border-radius:14px;padding:28px}
.checkout-card h1{margin:0 0 20px;font-size:22px}
.checkout-card .total{display:flex;align-items:center;justify-content:space-between;margin:0;padding:16px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line);font-size:15px;color:var(--muted)}
.checkout-card .total strong{font:800 20px/1 var(--head);color:var(--ink)}
.checkout-card label{display:block;margin:16px 0 0;font:500 14px/1.4 var(--body)}
.checkout-card input{width:100%;margin-top:6px;padding:12px 14px;border:1px solid var(--line-2);border-radius:6px;font:inherit;font-size:15px}
.checkout-card .btn{width:100%;margin-top:20px;text-align:center}
.checkout-card .hint{margin:14px 0 0;font-size:12.5px;color:var(--muted)}


/* Everything that moves, stopped in one place, and !important because this is a user preference
   and has to win: @media adds no specificity, so without it `.hero-text{animation:none}` lost to
   `.hero-split .hero-text` further up and the hero kept moving for a reader who asked it not to.
   The blanket `animation-duration:.01ms` the mock uses would leave every hero slide on its final
   frame, which is the hidden one -- so the slides are stood down explicitly and the first picture
   is simply left showing. */
@media(prefers-reduced-motion:reduce){
  a.card:hover,.pl-partner:not(.arch-body) .card:hover{transform:none}
  .hero-text,.hero-media,.hero-media::before,.hero-media>img,.hero-slides,.site-nav .sub,.mega,.mega-pane,.nav-toggle:checked~.site-nav{animation:none!important}
  .hero-slide{animation:none!important;opacity:0}
  .hero-slide:first-child{opacity:1}
  .hero-dots{display:none}
  .fx-rise>.wrap,.stats li.fx-rise,
  .fx-gradient :is(h1,h2,h3),.fx-gradient .stats strong,.stats li.fx-gradient strong,
  .fx-sweep :is(h1,h2,h3),.fx-sweep .stats strong,.stats li.fx-sweep strong,
  .stats li.fx-count .cr{animation:none!important}
  .fx-sweep :is(h1,h2,h3),.fx-sweep .stats strong,.stats li.fx-sweep strong{background-size:100% .3em}
  /* stopping the count is not enough: --cv would stay at 0, so the counter is stood down and the
     real digits stats.html wrote come back out of hiding. */
  .stats li.fx-count .cr::before{content:none}
  .stats li.fx-count .cv{position:static;width:auto;height:auto;overflow:visible;clip-path:none}
  /* the accordion is transitions, not keyframes: the row still opens and closes, it just arrives */
  .acc-row,.acc-hr h3,.acc-n,.acc-n i,.acc-body,.acc-kids a{transition:none!important}
}
