/* nactrack.com design system, v2.
   One stylesheet for every generated page. The v1 site kept three near identical inline copies
   that had already started to drift, so this is deliberately the only place a colour or a spacing
   value is defined.
   One palette, light, for every visitor. There is deliberately no dark theme: the site used to
   follow prefers-color-scheme, so a visitor whose OS was dark saw a near black site with no way
   to change it, while the products we sit beside stay light for everyone. Two identities chosen
   by someone else's OS setting is not a design.
   stamp, so an explicit choice wins over the operating system in both directions. */

:root{
  --teal-900:#042f2e; --teal-800:#0b3f3c; --teal-700:#0f766e; --brand:#0f766e;
  --signal:#2dd4bf; --lime:#84cc16; --lime-bright:#a3e635;
  /* --brand is a FILL that must carry white text. --brand-ink is TEXT on the page ground.
     One value cannot do both: #0d9488 scores 3.74 against white in either direction. */
  --brand-ink:#0f766e;
  --ink:#0a1917; --ink-2:#3d5450; --ink-3:#5a716c;
  /* The grounds are warm, the text is not. Every neutral here used to sit on the same teal axis
     as the brand: white page, mint section bands, mint rules, and a shadow cast in teal black.
     Consistent, and it read like a datasheet printed on a hospital wall. The grounds and the
     rules now carry a faint warm cast instead, near enough to paper that nobody will name the
     change and far enough off mint that the page stops feeling clinical.
     The ink tokens are deliberately untouched. They are the only neutrals carrying a contrast
     obligation, warming a near black is invisible anyway, and warming the two mid greys would
     have spent real contrast on nothing: --ink-3 stays at 5.23:1 against the page ground. */
  --ground:#ffffff; --surface:#ffffff; --surface-2:#f4f5f0; --surface-tint:#edf5f3;
  --surface-3:#eaebe3;
  --line:#dfe0d8; --line-strong:#c4c6ba;
  --ok:#15803d; --warn:#b45309; --stop:#b91c1c;
  --shadow-1:0 1px 2px rgba(28,32,22,.05);
  --shadow-2:0 2px 6px rgba(28,32,22,.06),0 14px 40px rgba(28,32,22,.07);
  --radius:5px; --radius-lg:6px;
  /* Headings are IBM Plex Sans, not IBM Plex Serif and not Inter.
     Serif headings gave every page the register of a printed standard, which is exactly the
     "this is a document, not a product" verdict the rebuild was judged on. Inter is the default
     face of every generated landing page and reads as nobody in particular. Plex Sans keeps the
     family already carrying the labels and the code, has the squarer terminals and open apertures
     of a technical face, and pairs with Plex Mono without a seam. The serif is gone from the font
     request as well, so the change also removes a webfont download rather than adding one. */
  --sans:'IBM Plex Sans','Helvetica Neue',Arial,sans-serif;
  --mono:'IBM Plex Mono',ui-monospace,'SFMono-Regular',Menlo,monospace;
  --wrap:1240px;
  --nav-h:66px;
}

*,*::before,*::after{box-sizing:border-box}
html{scroll-behavior:smooth}
body{margin:0;background:var(--ground);color:var(--ink);font-family:var(--sans);
  font-size:17px;line-height:1.65;-webkit-font-smoothing:antialiased;
  text-rendering:optimizeLegibility}
img,svg,video{max-width:100%;height:auto;display:block}
h1,h2,h3,h4{margin:0;font-family:var(--sans);font-weight:650;letter-spacing:-.018em;line-height:1.16;text-wrap:balance}
h1{font-size:clamp(34px,3.1vw + 16px,50px);font-weight:700;letter-spacing:-.028em;line-height:1.08}
h2{font-size:clamp(25px,2.1vw + 12px,35px);font-weight:700;letter-spacing:-.024em}
h3{font-size:19px;font-weight:650;letter-spacing:-.012em}
h4{font-size:16px}
p{margin:0}
a{color:var(--brand-ink);text-decoration-thickness:1px;text-underline-offset:3px}
ul,ol{margin:0;padding:0;list-style:none}
:focus-visible{outline:2px solid var(--signal);outline-offset:3px;border-radius:4px}
.skip{position:absolute;left:-9999px;top:0;background:var(--brand);color:#fff;padding:10px 16px;
  border-radius:0 0 8px 0;z-index:100}
.skip:focus{left:0}

.wrap{max-width:var(--wrap);margin:0 auto;padding:0 24px;width:100%}
.col{max-width:var(--wrap)}
/* Constrain the children, not the wrap: setting max-width on the wrap itself centred the
   whole section and broke the left alignment it shares with every other section. */
.col > *{max-width:780px}
.eyebrow{display:block;font-family:var(--mono);font-size:11.5px;font-weight:600;letter-spacing:.16em;
  text-transform:uppercase;color:var(--brand-ink);margin-bottom:12px}
/* REMOVED 2026-08-29, owner's call, and the reason generalises.
   A short accent rule used to sit before every section label: content:"" drawn at 18x2px, so
   never a character and invisible to a scan for U+2014. It rendered on 326 section heads across
   the site, and what a reader sees before a label is a dash. The no-dashes rule is about what
   lands on the page, not about which codepoint produced it, so a drawn dash breaks it exactly as
   a typed one would. Do not reintroduce a horizontal mark here; if the accent needs somewhere to
   sit at section scale, give it the label's own colour or a rule that is not horizontal. */

/* ------------------------------------------------------------------ nav */
.nav{position:sticky;top:0;z-index:50;background:color-mix(in srgb,var(--ground) 88%,transparent);
  backdrop-filter:saturate(1.6) blur(10px);border-bottom:1px solid transparent;transition:border-color .2s}
.nav.stuck{border-bottom-color:var(--line)}
/* The bar carries more than the content column: eight items, a language control and two
   buttons need about 1230px, where .wrap caps at 1180. A nav bar wider than the text
   column is normal; a primary call to action cut off the right edge is not. */
.nv{display:flex;align-items:center;gap:18px;height:var(--nav-h);max-width:1280px}
/* The real mark, not a typeset wordmark. The rebuilt site had been setting "NacTrack" in the
   body font, which is a different thing from the logo the product and every document use, and
   it also spelled it with a capital T the artwork does not have. Sized by height so the aspect
   ratio comes off the intrinsic width/height attributes and nothing reflows while it loads. */
.brand{display:inline-flex;align-items:center;text-decoration:none;line-height:0}
.brand img{height:34px;width:auto;display:block}
@media (max-width:900px){.brand img{height:28px}}
.nv-links{display:flex;align-items:center;gap:4px;flex:1}
.nv-item{position:relative;white-space:nowrap;font-size:14.5px;font-weight:500;color:var(--ink-2);
  padding:8px 12px;border-radius:8px;text-decoration:none;background:none;
  border:0;appearance:none;-webkit-appearance:none;
  font-family:inherit;cursor:pointer;display:inline-flex;align-items:center;gap:6px}
/* Navigation surfaces answered instantly and therefore felt dead: a colour that snaps reads as a
   repaint, the same colour over 140ms reads as a response. Colour and ground only, no movement. */
.nv-item,.nv-sub a,.ft-cols a,.lang{transition:color .14s ease,background-color .14s ease,
  border-color .14s ease}
.nv-item:hover{color:var(--ink);background:var(--surface-2)}
.nv-item[aria-current="page"]{color:var(--brand-ink)}
.nv-btn i{width:6px;height:6px;border-right:1.6px solid currentColor;border-bottom:1.6px solid currentColor;
  transform:rotate(45deg) translate(-1px,-1px);transition:transform .18s;opacity:.6}
.nv-btn[aria-expanded="true"] i{transform:rotate(-135deg) translate(-2px,-2px)}
.nv-wrap{position:relative;display:inline-flex}
.has-sub{position:relative}
.nv-sub{position:absolute;top:calc(100% + 8px);left:0;min-width:320px;background:var(--surface);
  border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow-2);padding:8px;
  display:none;flex-direction:column;gap:2px}
.nv-btn[aria-expanded="true"] + .nv-sub{display:flex}
.nv-sub a{display:block;padding:10px 12px;border-radius:8px;text-decoration:none;color:var(--ink);
  font-size:14.5px;font-weight:500}
.nv-sub a span{display:block;font-size:13px;color:var(--ink-3);font-weight:400;margin-top:2px;
  line-height:1.4}
.nv-sub a:hover{background:var(--surface-2)}
.nv-right{display:flex;align-items:center;gap:10px}
.lang{font-family:var(--mono);font-size:12.5px;font-weight:600;color:var(--ink-3);text-decoration:none;
  padding:6px 9px;border-radius:7px;border:1px solid var(--line)}
.lang:hover{color:var(--ink);border-color:var(--line-strong)}
.nv-toggle{display:none;width:40px;height:36px;border:1px solid var(--line);border-radius:9px;
  background:none;cursor:pointer;position:relative}
.nv-toggle span,.nv-toggle span::before,.nv-toggle span::after{position:absolute;left:11px;width:16px;
  height:1.8px;background:var(--ink);content:"";border-radius:2px}
.nv-toggle span{top:17px}
.nv-toggle span::before{top:-5px;left:0}
.nv-toggle span::after{top:5px;left:0}

.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;font-weight:600;
  font-size:15.5px;padding:13px 24px;border-radius:10px;text-decoration:none;border:1px solid transparent;
  transition:transform .12s,box-shadow .18s,background .18s;white-space:nowrap}
.btn.sm{font-size:13.5px;padding:8px 15px;border-radius:8px}
.btn.primary{background:var(--brand);color:#fff;box-shadow:var(--shadow-1)}
.btn.primary:hover{background:var(--teal-700);transform:translateY(-1px);box-shadow:var(--shadow-2)}
.btn.ghost{border-color:var(--line-strong);color:var(--ink)}
.btn.ghost:hover{border-color:var(--brand-ink);color:var(--brand-ink)}

/* ------------------------------------------------------------------ hero */
/* Two columns: what the product is on the left, the product itself on the right.
   The rebuilt hero was text with an empty right half and the screenshot dropped below the fold,
   which is why it read as a document about a product rather than as a product. The wash is two
   soft radial mixes of the brand tokens over the page ground, so it warms the fold in light and
   deepens it in dark from the same two rules; a literal gradient would have been right in one
   theme and wrong in the other. The faint grid over the top is masked out before it reaches the
   text, so it never competes with the headline. */
.hero{position:relative;padding:76px 0 84px;background:var(--ground);
  border-bottom:1px solid var(--line);color:var(--ink);overflow:hidden}
.hero::before{content:"";position:absolute;inset:0;pointer-events:none;
  background:
    radial-gradient(900px 460px at 8% -12%, color-mix(in srgb,var(--brand) 13%,transparent), transparent 62%),
    radial-gradient(760px 420px at 92% -18%, color-mix(in srgb,var(--signal) 12%,transparent), transparent 64%)}
.hero::after{content:"";position:absolute;inset:0;pointer-events:none;opacity:.5;
  background-image:linear-gradient(color-mix(in srgb,var(--ink) 6%,transparent) 1px,transparent 1px),
    linear-gradient(90deg,color-mix(in srgb,var(--ink) 6%,transparent) 1px,transparent 1px);
  background-size:46px 46px;
  -webkit-mask-image:linear-gradient(to bottom,#000,transparent 78%);
  mask-image:linear-gradient(to bottom,#000,transparent 78%)}
.hero-in{position:relative;z-index:1;display:grid;grid-template-columns:minmax(0,.92fr) minmax(0,1.08fr);
  gap:52px;align-items:center;text-align:left;max-width:1240px}
/* An interior page carries no shot. One column beats a grid with an empty cell in it. */
.hero-in.solo{display:block}
.hero h1{color:var(--ink);max-width:19ch}
.hero-in.solo h1{max-width:22ch}
.hero .pill{display:inline-block;font-family:var(--mono);font-size:12px;font-weight:500;
  letter-spacing:.1em;text-transform:uppercase;color:var(--brand-ink);
  padding:0;margin-bottom:18px;border:0;background:none}
/* One sentence. The reference page won partly on this: its subhead is a single line and the
   rebuild's was a paragraph, so the fold asked to be read instead of scanned. */
.hero-sub{font-size:19px;color:var(--ink-2);margin:18px 0 0;max-width:46ch;line-height:1.55}
.hero-ctas{display:flex;gap:12px;margin-top:28px;flex-wrap:wrap}
/* Three facts under the buttons, in the mono face, so the fold carries something checkable
   instead of stopping at a claim and a button. */
.hero-facts{display:flex;flex-wrap:wrap;gap:8px 22px;margin-top:26px;padding-top:20px;
  border-top:1px solid var(--line)}
.hero-facts li{position:relative;padding-left:15px;font-family:var(--mono);font-size:12.5px;
  color:var(--ink-3);letter-spacing:.01em}
.hero-facts li::before{content:"";position:absolute;left:0;top:8px;width:5px;height:5px;
  border-radius:50%;background:var(--brand-ink)}
.hero-vis{min-width:0}
.hero .btn.primary{background:var(--brand);color:#fff}
.hero .btn.primary:hover{background:var(--teal-800)}
.hero .btn.ghost{border-color:var(--line-strong);color:var(--ink);background:var(--surface)}
.hero .btn.ghost:hover{border-color:var(--brand);color:var(--brand-ink)}

/* ------------------------------------------------------------------ browser chrome
   One component, used by the hero visual, the standalone shot block and every panel of the
   product tour. A screenshot with a window frame around it reads as a running product; the same
   pixels without one read as an illustration, which is most of what the reference page was doing
   differently. Every value is a token: the three window dots are --line-strong rather than the
   usual red, amber and green, because baked hex would be the one part of this page that does not
   invert with the theme, and monochrome keeps the frame from competing with its own contents.
   The address in the bar is a real route, written by the build, never decoration. */
.frame{margin:0;border:1px solid var(--line);border-radius:var(--radius-lg);overflow:hidden;
  background:var(--surface);box-shadow:var(--shadow-2)}
.fr-bar{display:flex;align-items:center;gap:12px;padding:9px 14px;background:var(--surface-2);
  border-bottom:1px solid var(--line)}
.fr-dots{display:inline-flex;gap:6px;flex:none}
.fr-dots i{width:9px;height:9px;border-radius:50%;display:block;background:var(--line-strong)}
.fr-url{flex:1;min-width:0;text-align:center;font-family:var(--mono);font-size:11.5px;
  color:var(--ink-3);background:var(--ground);border:1px solid var(--line);border-radius:6px;
  padding:3px 12px;max-width:340px;margin:0 auto;overflow:hidden;text-overflow:ellipsis;
  white-space:nowrap}
.fr-shot{background:var(--surface-2);line-height:0}
.fr-shot img{display:block;width:100%;height:auto}
.frame figcaption{padding:13px 18px;font-size:14px;color:var(--ink-3);
  border-top:1px solid var(--line);background:var(--surface-2);line-height:1.5}

/* ------------------------------------------------------------------ proof strip
   Large numerals, one line of label, four across, with the source of the figures stated under
   them. Every number here is checked against a file in the product tree before it is written:
   the page it replaced carried "7 vendors" and "12 modules" long after both had moved, and a
   figure on a landing page cannot tell you it has gone stale. */
.proof{background:var(--surface-2);border-bottom:1px solid var(--line);padding:38px 0 34px}
.pf-lead{font-family:var(--mono);font-size:12px;letter-spacing:.13em;text-transform:uppercase;
  color:var(--ink-3);margin-bottom:24px}
.pf-row{display:grid;grid-template-columns:repeat(4,1fr);gap:22px}
.pf{padding-left:24px;border-left:1px solid var(--line)}
.pf:first-child{padding-left:0;border-left:0}
.pf b{display:block;font-family:var(--mono);font-size:clamp(30px,3.4vw,42px);font-weight:600;
  color:var(--brand-ink);line-height:1;font-variant-numeric:tabular-nums;letter-spacing:-.03em}
.pf span{display:block;font-size:14px;color:var(--ink-2);margin-top:9px;line-height:1.4;max-width:22ch}
.pf-note{margin-top:24px;font-size:13px;color:var(--ink-3);max-width:90ch}

/* ------------------------------------------------------------------ sections */
.sec{padding:clamp(56px,6.4vw,100px) 0}
.sec.alt{background:var(--surface-tint);border-top:1px solid var(--line);border-bottom:1px solid var(--line)}
.sec-head{margin-bottom:46px;max-width:720px}
.sec-sub{font-size:19px;color:var(--ink-2);margin-top:14px;line-height:1.58}
.prose p{margin-bottom:18px;color:var(--ink-2);font-size:17.5px}
.prose p:last-child{margin-bottom:0}

/* A section that reads as a reference document rather than a landing panel: the heading sits in
   a left rail and the text runs beside it. Before this, a headed prose section was a 780px
   column inside a 1240px wrap, so every interior page had a wall of text down the left and an
   empty right half. The rail is the same idea as a marginal heading in a printed standard, which
   is the register the site is aiming at, and it costs no content change.
   The measure on the right is still capped, because full 1240px lines are unreadable. */
.article{display:grid;grid-template-columns:minmax(250px,340px) minmax(0,1fr);gap:22px 72px;
  align-items:start}
.article > .sec-head{margin-bottom:0;max-width:none}
/* A rail heading is a marginal note, not a section banner: at the full h2 clamp a six word title
   broke into four ragged lines in a 300px column. Smaller sits better and reads as a document. */
.article > .sec-head h2{font-size:clamp(21px,1.9vw,26px);line-height:1.24;text-wrap:balance}
.article > .sec-head .sec-sub{font-size:16px;margin-top:10px}
.article .prose{max-width:700px}
/* The opening paragraphs of an article, which carry no heading of their own. Set larger so the
   piece has an entry point instead of starting at body size straight under the h1. */
.prose.lede p{font-size:20px;line-height:1.62;color:var(--ink-2);max-width:760px}
.prose.lede p:first-child{color:var(--ink)}
/* The rail only earns its keep when there is room for both columns beside each other. */
@media (max-width:1080px){
  .article{grid-template-columns:1fr;gap:26px}
  .article .prose{max-width:780px}
}
/* A product screenshot section: the same chrome the hero and the tour use, so all three read
   as one family instead of three different treatments of the same kind of picture. */
.shot-sec{padding-top:0}

/* ------------------------------------------------------------------ product tour
   Seven screens behind a tab strip. The strip is the strongest device the reference page had and
   it happens to be an exact contents list of what the product does.
   It degrades, and the enhancement is the strip itself. The HTML on disk carries every panel
   open, so with JavaScript off a reader gets seven captioned screenshots down the page. The strip
   is display:none until the script runs and adds .tour-on; the two rejected alternatives were
   hiding the panels in the markup, which gives a no-script reader nothing at all, and shipping the
   strip visible, which gives them seven buttons that do not work. */
.tour-tabs{display:none;flex-wrap:wrap;gap:8px;margin-bottom:22px}
.tour-on .tour-tabs{display:flex}
.tour-tab{font:600 14px var(--sans);color:var(--ink-2);background:var(--surface);cursor:pointer;
  border:1px solid var(--line);border-radius:999px;padding:9px 17px;
  transition:border-color .16s,color .16s,background .16s}
.tour-tab:hover{border-color:var(--brand-ink);color:var(--brand-ink)}
.tour-tab[aria-selected="true"]{background:var(--brand);border-color:var(--brand);color:#fff}
/* Without the script every panel is open, so they need air between them. With it, only one is
   ever visible and the gap collapses to nothing. */
.tour-panels{display:flex;flex-direction:column;gap:26px}
.tour-on .tour-panels{display:block}
.tour-panel[hidden]{display:none}
/* The incoming panel settles. Seven screens of the same product look alike enough from across a
   desk that swapping one for another with no acknowledgement can read as nothing having
   happened, and a control that seems not to have worked is the one thing worse than no control.
   Armed by the first click or arrow key, never on load: .tour-live is added by the handler, not
   at init, because a panel that animates itself into place on arrival is the page performing
   rather than the page answering. */
.tour-live .tour-panel:not([hidden]){animation:tour-in .16s ease-out}
@keyframes tour-in{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}

/* ------------------------------------------------------------------ facts and specs
   The two shapes prose gets broken into. A fact is one line, one claim, no connective tissue;
   a spec is a name and a value a reader came to check and cannot be paraphrased. */
.facts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px 44px}
.facts.one{grid-template-columns:minmax(0,760px)}
.facts li{position:relative;padding-left:26px;font-size:16.5px;color:var(--ink-2);line-height:1.5}
/* A small rotated square, not a short rule. A rule reads as a dash, and on this site that is
   the one shape nobody should have to look twice at. */
.facts li::before{content:"";position:absolute;left:3px;top:9px;width:7px;height:7px;
  background:var(--brand-ink);transform:rotate(45deg);border-radius:1px}
.facts li b,.facts li strong{color:var(--ink);font-weight:600}
.specs{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 44px;margin:0}
.sp{display:grid;grid-template-columns:minmax(140px,34%) minmax(0,1fr);gap:18px;
  padding:13px 0;border-top:1px solid var(--line);align-items:baseline}
.sp dt{font-family:var(--mono);font-size:11.5px;font-weight:600;letter-spacing:.08em;
  text-transform:uppercase;color:var(--ink-3);margin:0}
.sp dd{margin:0;font-size:16px;color:var(--ink);line-height:1.5}
@media (max-width:900px){
  .facts,.specs{grid-template-columns:minmax(0,1fr)}
}
.split{display:grid;grid-template-columns:1.15fr .85fr;gap:56px;align-items:start}
.ticks{display:flex;flex-direction:column;gap:12px}
.ticks li{position:relative;padding-left:30px;font-size:16px;color:var(--ink-2)}
.ticks li::before{content:"";position:absolute;left:0;top:8px;width:14px;height:8px;
  border-left:2px solid var(--brand);border-bottom:2px solid var(--brand);
  transform:rotate(-45deg)}

/* cards */
.cards{display:grid;gap:18px}
.cards.c2{grid-template-columns:repeat(2,1fr)}
.cards.c3{grid-template-columns:repeat(3,1fr)}
.cards.c4{grid-template-columns:repeat(4,1fr)}
.card{position:relative;background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);
  padding:26px 26px 28px;box-shadow:var(--shadow-1);text-decoration:none;color:inherit;display:block;
  transition:border-color .18s,box-shadow .18s,transform .12s}
.card.lnk:hover{border-color:var(--brand-ink);box-shadow:var(--shadow-2);transform:translateY(-2px)}
.card h3{margin-bottom:9px}
.card p{color:var(--ink-2);font-size:15.5px;line-height:1.58}
.c-tag{display:inline-block;font-family:var(--mono);font-size:10.5px;font-weight:600;letter-spacing:.1em;
  text-transform:uppercase;color:var(--brand-ink);background:color-mix(in srgb,var(--brand) 12%,transparent);
  padding:4px 9px;border-radius:6px;margin-bottom:13px}
.c-go{position:absolute;right:22px;bottom:20px;font-size:22px;color:var(--line-strong);line-height:1}
.card.lnk:hover .c-go{color:var(--brand-ink)}

/* steps */
.steps{display:flex;flex-direction:column;gap:2px;counter-reset:s}
.st{display:grid;grid-template-columns:46px 1fr;gap:22px;padding:24px 0;
  border-top:1px solid var(--line);align-items:start}
.st:first-child{border-top:0}
.st-n{display:grid;place-items:center;width:34px;height:34px;border-radius:50%;background:var(--brand);
  color:#fff;font-family:var(--mono);font-size:14px;font-weight:600}
.st h3{margin-bottom:7px}
.st p{color:var(--ink-2);font-size:16.5px}
.st-note{margin-top:10px;font-size:14.5px;color:var(--ink-3);border-left:2px solid var(--line-strong);
  padding-left:14px}

/* tables */
.tablewrap{overflow-x:auto;border:1px solid var(--line);border-radius:var(--radius);
  background:var(--surface);box-shadow:var(--shadow-1)}
/* A horizontally scrolling region that gives no sign it scrolls is a region whose right hand
   columns do not exist for the reader. On a 390px phone the coverage matrix is 844px wide inside
   a 358px column, clipped mid table with nothing to say there was more.
   Two gradient pairs, and background-attachment is the whole trick. The cover pair is `local`, so
   it travels with the content and slides out of the way as you scroll; the shadow pair is
   `scroll`, so it stays pinned to the box and is revealed underneath. The hint therefore appears
   only on a side that has more content and disappears at each end, with no script and no state.
   Declared after .flowwrap's own background further down would be cleaner, but this pairs the
   rule with the comment that explains it; the later .flowwrap rule is amended in place instead. */
.tablewrap,.flowwrap{
  background:
    linear-gradient(to right,var(--surface) 40%,rgba(255,255,255,0)) left center / 34px 100% no-repeat local,
    linear-gradient(to left,var(--surface) 40%,rgba(255,255,255,0)) right center / 34px 100% no-repeat local,
    linear-gradient(to right,rgba(15,23,42,.10),rgba(15,23,42,0)) left center / 18px 100% no-repeat scroll,
    linear-gradient(to left,rgba(15,23,42,.10),rgba(15,23,42,0)) right center / 18px 100% no-repeat scroll,
    var(--surface)}
/* Focusable only while it actually scrolls; the script in base.html adds and removes this. A
   permanent tab stop on a desktop where nothing overflows is an empty stop, and a scrollable box
   with no tab stop is unreachable by keyboard, so it has to be conditional rather than either. */
.scrollable-x:focus-visible{outline:2px solid var(--brand-ink);outline-offset:2px}
table{border-collapse:collapse;width:100%;font-size:15px;min-width:620px}
th,td{text-align:left;padding:13px 18px;border-bottom:1px solid var(--line);vertical-align:top}
th{font-family:var(--mono);font-size:11px;font-weight:600;letter-spacing:.09em;text-transform:uppercase;
  color:var(--ink-3);background:var(--surface-2);white-space:nowrap}
tbody tr:last-child td{border-bottom:0}
td:first-child{font-weight:600}
/* A reading aid, not a decoration, and the one hover on this site that earns its place on a
   non-interactive element. The coverage matrices run to seventeen rows and the task a reader
   came to do on them is tracking one row across to a verdict several columns right of the name.
   The tint is what stops the eye dropping a line, which is the same reason a printed table gets
   banded rows. Deliberately a ground change and not a colour or a weight change: a row that
   changed colour on hover would read as a link, and none of these rows go anywhere. */
.tablewrap tbody tr{transition:background-color .12s ease-out}
.tablewrap tbody tr:hover{background:var(--surface-2)}
/* A verdict in a coverage matrix. Shape first, colour second: a reader who cannot separate green
   from amber from grey still sees a full disc, a half disc and an empty ring. The word is always
   present in the markup, hidden from sight in the cell and spelled out in the legend above the
   table, so a hundred cells do not become a hundred repetitions of the same three words.
   Grey rather than red for what is not covered. The gaps here are scope, not failure: spanning
   tree on a router is not a defect, and a column of red said otherwise. */
.mk{position:relative;display:inline-flex;align-items:center;gap:8px;font-weight:600;line-height:1.4}
.mk-g{flex:none;width:13px;height:13px;border-radius:50%;border:1.6px solid currentColor}
.mk.yes .mk-g{background:currentColor}
.mk.part .mk-g{background:linear-gradient(90deg,currentColor 0 50%,transparent 50% 100%)}
.mk.yes{color:var(--ok)}
.mk.no{color:var(--ink-3)}
.mk.part{color:var(--warn)}
.mk-l{color:var(--ink-2);font-weight:500}
.t-legend{display:flex;flex-wrap:wrap;gap:10px 26px;margin:0 0 18px;font-size:14px}
.t-legend .mk-l{color:var(--ink-3)}
/* Visually hidden, still read aloud. clip-path rather than display:none, which would take the
   word out of the accessibility tree along with the pixels.
   position:absolute here needs a positioned ancestor, and .mk above is that ancestor. Without it
   these spans resolved against the initial containing block instead: in the seventeen row
   coverage matrix the cells sit out at x=700, the hidden words went with them, and they escaped
   the table's own overflow:auto scroller. On a 390px phone documentElement.scrollWidth read 668
   against a clientWidth of 390, so the whole body scrolled sideways into blank space while the
   table itself was correctly clipped. Measured by hiding .vh and watching 668 drop to 390. */
.vh{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;
  clip-path:inset(50%);white-space:nowrap;border:0}
tbody th{font-family:var(--sans);font-size:15px;font-weight:600;letter-spacing:0;
  text-transform:none;color:var(--ink);background:none;white-space:normal}
.tk{display:inline-block;font-family:var(--mono);font-size:12.5px;color:var(--ink-2);
  background:var(--surface-2);border:1px solid var(--line);padding:2px 8px;border-radius:5px;
  margin:0 5px 5px 0;white-space:nowrap}
.t-note{margin-top:14px;font-size:14.5px;color:var(--ink-3);max-width:80ch}

/* faq */
.faqs{max-width:820px}
.faqs .faq:first-child{border-top:1px solid var(--line)}
.faq{border-bottom:1px solid var(--line)}
.faq summary{cursor:pointer;padding:20px 40px 20px 0;font-size:18px;font-weight:600;position:relative;
  list-style:none}
.faq summary::-webkit-details-marker{display:none}
.faq summary::after{content:"";position:absolute;right:8px;top:28px;width:9px;height:9px;
  border-right:2px solid var(--ink-3);border-bottom:2px solid var(--ink-3);
  transform:rotate(45deg);transition:transform .2s}
.faq[open] summary::after{transform:rotate(-135deg)}
.faq-a{padding:0 40px 22px 0}
.faq-a p{color:var(--ink-2);font-size:16.5px;margin-bottom:14px}
.faq-a p:last-child{margin-bottom:0}

/* quote */
.quote{margin:0;background:var(--surface);border:1px solid var(--line);border-left:3px solid var(--brand);
  border-radius:0 var(--radius) var(--radius) 0;padding:30px 34px;box-shadow:var(--shadow-1)}
.quote blockquote{margin:0;font-size:21px;line-height:1.55;letter-spacing:-.012em}
.quote figcaption{margin-top:16px;font-family:var(--mono);font-size:13px;color:var(--ink-3)}

/* chips */
.chips{display:flex;flex-wrap:wrap;gap:10px}
.chip{font-family:var(--mono);font-size:14px;color:var(--ink-2);background:var(--surface);
  border:1px solid var(--line);padding:9px 17px;border-radius:999px}

/* cta band */
.cta-band{padding:74px 0;background:linear-gradient(150deg,var(--teal-900),var(--teal-700));color:#fff}
.cta-band h2{color:#fff}
.cta-band p{color:#dff3f0;font-size:19px;margin-top:14px;max-width:60ch}
.cta-band .btn.primary{background:var(--lime-bright);color:#082018}
.cta-band .btn.ghost{border-color:rgba(255,255,255,.34);color:#fff}

/* footer */
.ft{background:var(--surface-2);border-top:1px solid var(--line);padding:56px 0 0;margin-top:0}
.ft-in{display:grid;grid-template-columns:minmax(240px,1fr) 2.4fr;gap:48px;padding-bottom:44px}
.ft-brand p{color:var(--ink-3);font-size:15px;margin-top:12px;max-width:34ch}
.ft-cols{display:grid;grid-template-columns:repeat(4,1fr);gap:32px}
.ft-cols h2{font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;
  color:var(--ink-3);margin-bottom:14px;font-weight:600}
.ft-cols a{display:block;color:var(--ink-2);text-decoration:none;font-size:15px;padding:5px 0}
.ft-cols a:hover{color:var(--brand-ink)}
.ft-legal{border-top:1px solid var(--line);padding-top:22px;padding-bottom:30px;
  font-size:13.5px;color:var(--ink-3)}

@media (max-width:1000px){
  .ft-cols{grid-template-columns:repeat(2,1fr)}
  .cards.c4{grid-template-columns:repeat(2,1fr)}
}
@media (max-width:1280px){
  body{font-size:16px}
  .nv-links{display:none}
  .nv-toggle{display:block}
  .nav.open .nv-links{display:flex;position:absolute;top:var(--nav-h);left:0;right:0;
    flex-direction:column;align-items:stretch;background:var(--surface);border-bottom:1px solid var(--line);
    padding:12px;gap:2px;box-shadow:var(--shadow-2)}
  .nav.open .nv-sub{position:static;box-shadow:none;border:0;min-width:0;padding:0 0 0 12px}
  .split{grid-template-columns:1fr;gap:32px}
  .ft-in{grid-template-columns:1fr;gap:34px}
}

/* TABLET TIER. 1280px is where the NAVIGATION has to collapse: 42 rows do not fit.
   It was also flattening every card grid to one column, so a 1279px window and a
   390px phone rendered identically and a tablet read as a stretched phone, twice as
   long as it needed to be with 786px cards holding one short paragraph each.
   The nav breakpoint and the layout breakpoint are now separate concerns. */
@media (max-width:1280px){
  .cards.c3,.cards.c4{grid-template-columns:repeat(2,1fr)}
}
/* Phone. NOT aligned to the 820px diagram swap, and that is a deliberate reversal.
   Aligning them gave one clean reflow, which is the nicer story, and it put 768px
   in the phone tier: iPad portrait got one column of 720px cards, which is the
   exact defect this pass exists to remove. A diagram breaks where the drawing
   stops being legible; a card breaks where its text measure gets too wide. They are
   different content and they do not owe each other a shared number. */
@media (max-width:700px){
  .cards.c2,.cards.c3,.cards.c4{grid-template-columns:1fr}
}
/* The hero grid needs room for a headline and a screenshot side by side. Below this it stacks,
   and the shot keeps its frame rather than being dropped: it is the reason the fold works. */
@media (max-width:980px){
  .hero{padding:52px 0 56px}
  .hero-in{grid-template-columns:minmax(0,1fr);gap:38px}
  .hero h1,.hero-in.solo h1{max-width:24ch}
  .hero-sub{max-width:60ch}
}
@media (max-width:860px){
  .pf-row{grid-template-columns:repeat(2,1fr);gap:26px 22px}
  .pf:nth-child(3){padding-left:0;border-left:0}
  .pf b{font-size:30px}
}
@media (max-width:520px){
  .pf-row{grid-template-columns:1fr}
  .pf{padding-left:0;border-left:0;border-top:1px solid var(--line);padding-top:16px}
  .pf:first-child{border-top:0;padding-top:0}
  .tour-tab{font-size:13px;padding:8px 14px}
  .fr-url{font-size:10.5px;padding:3px 8px}
  .hero-facts{gap:8px 16px}
}
/* Phone. The bar carried the brand, a language control, two buttons and the menu toggle, which
   needs about 494px: at 390 the primary call to action and the toggle both sat off the right edge
   and the whole body scrolled sideways to reach them. Measured, not guessed: documentElement
   scrollWidth 494 against clientWidth 390. The secondary button is the one that goes, because the
   demo it points at is also the first item inside the menu. */
@media (max-width:640px){
  .wrap{padding:0 16px}
  .nv{gap:10px}
  .nv-right{gap:8px}
  .nv-right .btn.ghost{display:none}
  .nv-right .btn.sm{font-size:12.5px;padding:8px 12px}
  .brand{font-size:19px}
}
@media (prefers-reduced-motion:reduce){
  html{scroll-behavior:auto}
  /* The delay matters as much as the duration, and the blanket rule this replaces only covered
     the duration. A .01ms animation that still waits its 780ms turn in a stagger is a connector
     that is invisible for most of a second, which is precisely the content-depends-on-motion
     failure this rule exists to prevent. */
  *,*::before,*::after{animation-duration:.01ms!important;animation-delay:0s!important;
    animation-iteration-count:1!important;transition-duration:.01ms!important;
    transition-delay:0s!important}
  /* Belt and braces on the only two animations that start from an invisible state. The script
     already refuses to add .fl-draw when this setting is on, so these should be unreachable; if
     the class ever does arrive the drawing still has to be complete, because a diagram is the
     one thing on this site a reader cannot do without. */
  .flow.fl-draw .fl-conn{opacity:1;animation:none}
  .flow.fl-draw .fl-link{stroke-dashoffset:0;animation:none}
  .tour-live .tour-panel:not([hidden]){animation:none}
}
@media print{
  .nav,.cta-band,.ft,.skip{display:none}
  .hero{background:none;color:#000;padding:0 0 24px}
  .hero h1,.cta-band h2{color:#000}
}

/* ------------------------------------------------------------------ dual audience
   The site has two readers who cannot be split into two sites: the engineer verifies but cannot
   buy, the director buys but will not evaluate. So every substantive page carries one exec strip
   at the top and the technical detail below it, and both readers share one URL. */
.brief{background:var(--surface-2);border-bottom:1px solid var(--line);padding:26px 0}
.bf{display:grid;grid-template-columns:minmax(150px,auto) 1fr;gap:20px 34px;align-items:start}
.bf-tag{font-family:var(--mono);font-size:11px;font-weight:600;letter-spacing:.14em;
  text-transform:uppercase;color:var(--brand-ink);border:1px solid var(--line-strong);
  padding:6px 12px;border-radius:999px;white-space:nowrap;justify-self:start}
/* One grid for all three rows, not one grid per row. Per row, each label sized its own column,
   so the longest label ("Où vivent les données", 167px) pushed its value 6px right of the other
   two and the values did not line up. display:contents puts every label and value into the one
   shared track. */
.bf-rows{display:grid;grid-template-columns:minmax(160px,max-content) minmax(0,1fr);
  gap:12px 22px;align-items:baseline}
.bf-row{display:contents}
.bf-k{font-family:var(--mono);font-size:11.5px;letter-spacing:.09em;text-transform:uppercase;
  color:var(--ink-3)}
.bf-v{font-size:17px;color:var(--ink);line-height:1.5}

/* the second conversion action: brief the other person */

/* the two on ramps */
.aud-row{display:grid;grid-template-columns:repeat(2,1fr);gap:20px}
.aud{display:flex;flex-direction:column;gap:10px;background:var(--surface);border:1px solid var(--line);
  border-radius:var(--radius-lg);padding:30px 30px 26px;text-decoration:none;color:inherit;
  box-shadow:var(--shadow-1);transition:border-color .18s,box-shadow .18s,transform .12s}
.aud:hover{border-color:var(--brand-ink);box-shadow:var(--shadow-2);transform:translateY(-2px)}
.aud-role{font-family:var(--mono);font-size:11.5px;font-weight:600;letter-spacing:.13em;
  text-transform:uppercase;color:var(--brand-ink)}
.aud-line{font-size:23px;font-weight:700;letter-spacing:-.016em;line-height:1.22}
.aud-asks{display:flex;flex-direction:column;gap:9px;margin-top:6px}
.aud-asks li{position:relative;padding-left:22px;font-size:15.5px;color:var(--ink-2);line-height:1.5}
.aud-asks li::before{content:"";position:absolute;left:2px;top:9px;width:6px;height:6px;
  border-radius:50%;background:var(--brand-ink)}
.aud-go{margin-top:auto;padding-top:12px;font-weight:600;font-size:15px;color:var(--brand-ink)}
@media (max-width:860px){
  .bf{grid-template-columns:1fr;gap:14px}
  /* Narrow: label above its value. This stays a one column grid rather than a block, because
     bf-k and bf-v are spans: as blocks they stay inline and the label runs straight into the
     value ("CE QUE C'ESTUn inventaire reseau..."). */
  .bf-rows{display:flex;flex-direction:column;gap:14px}
  .bf-row{display:grid;grid-template-columns:1fr;gap:2px}
  .aud-row{grid-template-columns:1fr}
}

/* ------------------------------------------------------------------ demo request form */
.dform{margin-top:8px}
.fd-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:18px 20px}
.fd{display:flex;flex-direction:column;gap:7px}
.fd-wide{grid-column:1 / -1}
.fd label{font-size:14.5px;font-weight:600;color:var(--ink)}
.fd .rq{color:var(--stop)}
.fd input,.fd select,.fd textarea{font:inherit;font-size:16px;color:var(--ink);
  background:var(--surface);border:1px solid var(--line-strong);border-radius:9px;
  padding:11px 13px;width:100%;transition:border-color .15s,box-shadow .15s}
.fd input:focus,.fd select:focus,.fd textarea:focus{outline:none;border-color:var(--brand-ink);
  box-shadow:0 0 0 3px color-mix(in srgb,var(--brand-ink) 18%,transparent)}
.fd textarea{resize:vertical;min-height:84px}
.fd-hint{font-size:13.5px;color:var(--ink-3);line-height:1.45}
/* The honeypot must be unreachable for people and present for machines. Not display:none:
   some bots skip hidden inputs, so it is taken out of the flow and out of the tab order instead. */
.hp{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}
.fd-consent{margin-top:20px;font-size:14px;color:var(--ink-3);line-height:1.55;max-width:78ch}
.fd-actions{display:flex;align-items:center;gap:16px;margin-top:20px;flex-wrap:wrap}
.fd-msg{font-size:14.5px;font-weight:500}
.fd-msg.bad{color:var(--stop)}
.fd-msg.ok{color:var(--ink-3)}
.fd-done{margin-top:8px;padding:18px 20px;border-left:3px solid var(--ok);background:var(--surface-2);
  border-radius:0 10px 10px 0;font-size:16.5px;color:var(--ink);line-height:1.55}
@media (max-width:860px){.fd-grid{grid-template-columns:1fr}}

/* ------------------------------------------------------------------ flow schema
   The collection pipeline, drawn. Every colour here is a token rather than a literal, because
   the drawing has to invert with the page: an SVG with baked hex fills looks correct in the
   theme it was authored in and unreadable in the other one, and nothing in the build would say
   so. Geometry is in build.py, which measures each label against its card and fails rather than
   shipping text sliced by the edge of a box.
   Below about 860px the diagram scrolls inside its own frame, the same treatment tables get:
   scaled to a phone the labels would be four pixels tall. */
/* ------------------------------------------------------------------ diagrams
   One visual language for every figure on the site. The rules that make a set of drawings look
   like a set rather than like six people's work:

   - ONE element per figure carries the brand colour, and it is the element the figure is about.
     Everything else is ink and line. A diagram where three things are teal has no subject.
   - Type has three sizes and stops. A title, a supporting line, and a mono eyebrow. A fourth
     size is almost always a layout problem being solved with typography.
   - Strokes are hairlines. Weight comes from fill and from shadow, not from thicker outlines,
     because a 2px border reads as a wireframe and a 1px border with a soft ground reads as a
     card.
   - Radii are consistent: 10 on cards, 5 on chips. Mixed radii is the single most common tell
     of a drawing assembled rather than designed. */
.flowwrap{margin:0;overflow-x:auto;border:1px solid var(--line);border-radius:var(--radius-lg);
  box-shadow:var(--shadow-1);padding:34px 30px}   /* background: see the scroll hint above */
.flow{display:block;width:100%;height:auto;min-width:860px}

/* ------------------------------------------------------------------ the collection pipeline
   The gradient is the argument: dense and unreadable at 01, sparse and legible at 07. It is
   carried by LEGIBILITY and by the saturation ramp on the index grid, NOT by where colour is.
   The accent runs through every stage and is heaviest on the left. What belongs to 07 alone is
   semantic colour: --ok, --warn and --stop, because only there does anything have a verdict.
   An earlier version of this comment said "do not tint the left half", which was false of the
   figure it sat above. Do not act on it if you find it quoted elsewhere. */
/* 1040, not the 1240 of the viewBox: .flowwrap carries 30px of padding each side, so inside a
   1280 wrap the drawable width is about 1128. A min-width above that reintroduces the sideways
   scroll this geometry exists to remove, and puts stage 07 and the rail's closing line behind it.
   Below 1128 the figure scales down instead of scrolling, and below 820 it hands to the stack. */
.flow.pipe{min-width:1040px}
.pp-n{font-family:var(--mono);font-size:10.5px;font-weight:600;letter-spacing:.14em;fill:var(--brand)}
.pp-h{font-size:15px;font-weight:600;fill:var(--ink)}
.pp-s{font-size:11.5px;fill:var(--ink-3)}
.pp-f{font-family:var(--mono);font-size:10px;fill:var(--ink-3)}
.pp-dev{fill:var(--brand)}
.pp-bar{fill:var(--line-strong)}
.pp-cmd{font-family:var(--mono);font-size:10px;fill:var(--ink-3)}
.pp-raw{fill:var(--surface-2);stroke:var(--line)}
.pp-link{stroke:var(--line-strong);stroke-width:1;fill:none}
.pp-field{fill:var(--surface-tint);stroke:var(--brand);stroke-width:1.1}
.pp-fl{font-family:var(--mono);font-size:11px;fill:var(--brand);text-anchor:middle}
.pp-ix{fill:var(--brand)}
.pp-node{fill:var(--surface);stroke:var(--brand);stroke-width:1.5}
.pp-card{fill:var(--surface);stroke:var(--line);filter:url(#shadow)}
.pp-big{font-size:13px;font-weight:600;fill:var(--ink)}
.pp-sm{font-size:10.5px;fill:var(--ink-3)}
.pp-col{fill:var(--brand)}
.pp-ok{fill:var(--ok)} .pp-warn{fill:var(--warn)} .pp-stop{fill:var(--stop)}
.pp-rl{font-size:12.5px;font-weight:600;fill:var(--ink-3)}
.pp-rr{font-size:12.5px;font-weight:600;fill:var(--brand)}

/* the catalogue: names, scannable rather than readable */
.cat{margin-top:22px}
.cat-h{font-family:var(--mono);font-size:11px;font-weight:600;letter-spacing:.13em;
  text-transform:uppercase;color:var(--brand);margin:0 0 14px}
.cat .tablewrap table{min-width:520px}
.cat-f{font-size:14.5px;font-weight:600;color:var(--ink);width:232px}
.cat-p{font-family:var(--mono);font-size:13px;color:var(--ink-2)}
.cat-cs{display:flex;flex-wrap:wrap;gap:7px}
.cat-c{font-size:13px;color:var(--ink-2);background:var(--surface-2);border:1px solid var(--line);
  border-radius:999px;padding:5px 12px;white-space:nowrap}
.cat-c.lab{background:var(--surface-tint);border-color:var(--brand);color:var(--ink)}
.cat-c i{font-style:normal;font-family:var(--mono);font-size:10px;letter-spacing:.09em;
  text-transform:uppercase;color:var(--brand);margin-left:7px}

/* ------------------------------------------------------------------ diagrams on a phone
   The seven figures are single SVGs 1120 units wide with a 860px floor, so on a narrow screen
   they do not shrink, they scroll. Scaling them instead is not an option: at 358 CSS pixels the
   scale is 0.32 and the 14 unit labels would render near 4px. So below 820px the drawing is
   replaced by the same content re-laid as a stack, generated from the same YAML the drawing is
   generated from, which is what keeps the two from drifting apart.
   display:none on the hidden half, deliberately, so exactly one of the two is in the
   accessibility tree and a screen reader is never read the figure twice. */
/* The estate canvas. Same contract as the seven SVG figures: it owns the wide view,
   and below 820px it hands over to the stack rather than shrinking into illegibility. */
.estate{display:block;width:100%;height:470px;max-width:100%}
@media (max-width:820px){ .estate{display:none} }
.estate-wrap{background:var(--surface);padding:10px}

.figstack{display:none}
@media (max-width:820px){
  .flowwrap > .flow{display:none}
  .flowwrap{padding:22px 20px}
  .figstack{display:block}
  /* No scroll hint and no tab stop on a figure that no longer scrolls. */
  .flowwrap{background:var(--surface)}
}
.figstack .fs-h{margin:0 0 10px;font-family:var(--mono);font-size:11.5px;font-weight:600;
  letter-spacing:.11em;text-transform:uppercase;color:var(--ink-3)}
.figstack .fs-h:not(:first-child){margin-top:22px}
.figstack ul,.figstack ol{margin:0;padding:0 0 0 0;list-style:none;counter-reset:fs}
.figstack ol > li{counter-increment:fs}
.figstack li{position:relative;padding:12px 0 12px 34px;border-top:1px solid var(--line)}
.figstack li:first-child{border-top:0}
/* The number is the ring's running order, kept because on the cycle and the chain the order IS
   the claim. An unordered group gets the same small rotated square the fact lists use, so the
   two shapes stay visually related without pretending to be sequential. */
.figstack ol > li::before{content:counter(fs);position:absolute;left:0;top:12px;
  width:22px;height:22px;border-radius:999px;background:var(--brand);color:#fff;
  font:600 12px var(--mono);display:flex;align-items:center;justify-content:center}
.figstack ul > li::before{content:"";position:absolute;left:6px;top:19px;width:7px;height:7px;
  background:var(--brand-ink);transform:rotate(45deg);border-radius:1px}
.figstack .fs-l{display:block;font-weight:600;font-size:15.5px;color:var(--ink);line-height:1.4}
.figstack .fs-s{display:block;margin-top:3px;font-size:14.5px;color:var(--ink-2);line-height:1.5}

/* ---- small viewport figure kinds ----------------------------------------------
   A stacked list is the right container for a phone, but not every diagram is a
   list. Three of them were being flattened into the same bullets and losing the
   thing they were drawn to say. Each kind below restores that one property, and
   nothing else: no new palette, no new type, no decoration.                     */

/* A PATH. flow was the most sequential drawing on the site and rendered as three
   unordered lists, so the route became eight unrelated bullets. Two changes: the
   numbering runs CONTINUOUSLY across the phase headings, because the drawing's
   connector wraps from one lane into the next and it is one route of eight steps;
   and the steps are threaded on a rail. Horizontal rules between items read as a
   table. A vertical line through them reads as a route, which is what it is. The
   rail is the same idiom the drawing uses for its lanes and the article uses for
   its headings. */
.figstack.fs-path{counter-reset:fs}
.figstack.fs-path ol{counter-reset:none;position:relative}
.figstack.fs-path ol::before{content:"";position:absolute;left:10px;top:22px;bottom:22px;
  width:2px;background:var(--line);border-radius:1px}
.figstack.fs-path li{border-top:0;padding-top:9px;padding-bottom:9px}
.figstack.fs-path li::before{top:9px}
/* The rail must not run past the last step of the last phase, or it points at nothing. */
.figstack.fs-path > ol:last-of-type::before{bottom:auto;height:calc(100% - 44px)}

/* A CONTRAST. boundary argues an opposition, what stays inside against what is out
   there and what crosses between them, and eleven identical bullets threw it away.
   The marker carries the distinction per item, so it survives the reader having
   scrolled past the heading: a filled square for what is held, a hollow one for
   what is not ours, an arrow for what crosses. */
.figstack.fs-contrast ul > li::before{transform:none;border-radius:2px}
.figstack.fs-contrast ul.fs-in > li::before{background:var(--brand-ink)}
.figstack.fs-contrast ul.fs-out > li::before{background:transparent;
  box-shadow:inset 0 0 0 1.6px var(--ink-3)}
/* The arrow points the way the flow actually goes. A fixed right arrow beside the
   word "entrant" contradicts the text it sits next to, which is worse than no arrow. */
.figstack.fs-contrast ul.fs-edge > li::before{content:"";left:4px;top:17px;width:0;height:0;
  background:none;border-radius:0;box-shadow:none;
  border-top:5px solid transparent;border-bottom:5px solid transparent}
.figstack.fs-contrast ul.fs-edge > li.fs-dir-out::before{border-left:8px solid var(--brand)}
.figstack.fs-contrast ul.fs-edge > li.fs-dir-in::before{border-right:8px solid var(--brand);left:2px}
/* A flow with no declared direction gets the neutral marker, not a guessed arrow. */
.figstack.fs-contrast ul.fs-edge > li:not([class*="fs-dir"])::before{width:8px;height:2px;
  border:0;background:var(--ink-3);top:19px;left:4px}

/* A NESTING. The barriers figure is concentric, and its own caption says so: "these
   are nested barriers and not steps". Numbering them contradicted that sentence on
   the same screen. Each ring is set further in than the one containing it, so the
   indent carries the containment, and the core is marked as what they wrap around. */
.figstack.fs-nest ul > li{border-top:0;padding-top:8px;padding-bottom:8px}
.figstack.fs-nest ul > li::before{width:8px;height:8px;transform:none;border-radius:2px;
  background:transparent;box-shadow:inset 0 0 0 1.6px var(--line-strong);top:18px}
.figstack.fs-nest li.fs-n1{padding-left:47px}
.figstack.fs-nest li.fs-n1::before{left:21px}
.figstack.fs-nest li.fs-n2{padding-left:60px}
.figstack.fs-nest li.fs-n2::before{left:34px}
.figstack.fs-nest li.fs-n3{padding-left:73px}
.figstack.fs-nest li.fs-n3::before{left:47px}
.figstack.fs-nest li.fs-core{padding-left:86px}
.figstack.fs-nest li.fs-core::before{left:60px;background:var(--brand);
  box-shadow:inset 0 0 0 1.6px var(--brand)}
.figstack.fs-nest li.fs-core .fs-l{color:var(--brand-ink)}


/* cards */
.flow .fl-box{fill:var(--surface);stroke:var(--line);stroke-width:1;filter:url(#shadow)}
.flow .fl-box-alt{fill:var(--surface-2);stroke:var(--line);stroke-width:1}
.flow .fl-core{fill:var(--surface-2);stroke:var(--brand-ink);stroke-width:1.4}

/* type: three sizes, and they stop there */
.flow .fl-t{fill:var(--ink);font:600 15px var(--sans);letter-spacing:-.006em}
.flow .fl-d{fill:var(--ink-3);font:400 12.5px var(--sans)}
.flow .fl-lane,.flow .fl-edge,.flow .fl-ring-t{fill:var(--ink-3);
  font:600 10.5px var(--mono);letter-spacing:.18em;text-transform:uppercase}
.flow .fl-edge{text-anchor:middle}

/* chips: a ground, no outline. An outlined chip inside an outlined card is two frames deep. */
.flow .fl-chip{fill:var(--surface-2);stroke:none}
.flow .fl-chip-alt{fill:var(--surface);stroke:var(--line);stroke-width:1}
.flow .fl-chip-t{fill:var(--ink-2);font:500 11px var(--mono);text-anchor:middle}

/* the numbered badge */
.flow .fl-badge{fill:var(--brand-ink)}
.flow .fl-badge-t{fill:#fff;font:600 11px var(--mono);text-anchor:middle}

/* connectors: the subject is solid and coloured, everything else is a dashed hairline */
.flow .fl-link{stroke:var(--line-strong);stroke-width:1.3;fill:none;stroke-linejoin:round}
.flow .fl-arrow{fill:var(--brand-ink)}
.flow .fl-arc{fill:none;stroke-width:1.6;stroke-linecap:round}
.flow .fl-arc-req{stroke:var(--brand-ink)}
.flow .fl-arc-opt{stroke:var(--line-strong);stroke-dasharray:6 6}
.flow .fl-dev{fill:none;stroke:var(--brand-ink);stroke-width:1.3;stroke-linecap:round}
.flow .fl-lane-rail{fill:color-mix(in srgb,var(--brand) 30%,transparent)}

/* territory we do not own, and the line between */
.flow .fl-out,.flow .fl-unseen{fill:none;stroke:var(--line-strong);stroke-width:1.1;
  stroke-dasharray:5 5}
.flow .fl-bound{stroke:var(--line-strong);stroke-width:1.1;stroke-dasharray:2 6}
.flow .fl-ring{fill:none;stroke:var(--line-strong);stroke-width:1.1}

/* counted cells */
.flow .fl-cell-on{fill:var(--brand-ink);opacity:.9}
.flow .fl-cell-off{fill:var(--surface-2);stroke:none}

/* ---- semantic depth ramp in the figures ---------------------------------------
   The palette already declared --signal, --lime and the two dark teals and used
   them a combined five times in the whole stylesheet, so every figure was drawn in
   one green against one grey: important, or not important. Two colours cannot carry
   a role. These are the two places where a ramp says something the geometry alone
   does not.

   COVERAGE is three measurements of one population, each a subset of the last, and
   every filled cell was the same green. Counting the cells was the only way to read
   the shrink. The ramp makes it legible at a glance, and it is a ramp rather than
   three unrelated hues because the rows are ordered, not categorical.             */
.flow .fl-cell-on.fl-lvl0{fill:var(--teal-800);opacity:.92}
.flow .fl-cell-on.fl-lvl1{fill:var(--brand-ink);opacity:.9}
.flow .fl-cell-on.fl-lvl2{fill:var(--signal);opacity:.95}

/* LAYERS is concentric containment and every ring was the same hairline, so depth
   read only from the geometry. Each ring inward is drawn a shade stronger, which is
   the same information the nesting already carries, said twice. The core keeps its
   own treatment. */
.flow .fl-ring.fl-lvl0{stroke:var(--line-strong)}
.flow .fl-ring.fl-lvl1{stroke:var(--brand-ink);opacity:.55}
.flow .fl-ring.fl-lvl2{stroke:var(--brand-ink);opacity:.8}
.flow .fl-ring.fl-lvl3{stroke:var(--teal-800)}

/* a label sitting over a rule knocks the rule out behind itself */
.flow .fl-t,.flow .fl-d{paint-order:stroke;stroke:var(--surface);stroke-width:4.5px;
  stroke-linejoin:round}
.fl-cap{margin-top:16px;font-size:14.5px;line-height:1.6;color:var(--ink-3);max-width:78ch}

/* The user manual, kept as its own body inside the current shell.
   site.css supplies the two column layout its markup expects; the design system loads after and
   wins on colour and type. What neither of them agreed on was rhythm: the manual's lists and
   paragraphs arrived with no spacing at all, so a page of definitions read as one block of
   sentences. Scoped to .ug-body so nothing here can reach the generated pages. */
.ug-body{padding:34px 0 10px}
.ug-body p{margin:0 0 11px;line-height:1.65}
.ug-body ul,.ug-body ol{margin:0 0 16px;padding-left:22px}
.ug-body li{margin:0 0 6px;line-height:1.6}
.ug-body ul{list-style:disc}
.ug-body ol{list-style:decimal}
.ug-body h1{margin:0 0 6px}
.ug-body h2{margin:34px 0 12px;padding-top:18px;border-top:1px solid var(--line)}
.ug-body h3{margin:22px 0 8px}
.ug-body h4{margin:16px 0 6px;font-size:15px}
.ug-body img{max-width:100%;height:auto;border:1px solid var(--line);border-radius:10px;
  margin:8px 0 6px}
.ug-body table{width:100%;border-collapse:collapse;margin:0 0 18px}
.ug-body th,.ug-body td{border-bottom:1px solid var(--line);padding:8px 10px;text-align:left;
  font-size:14.5px}
.ug-body code{background:var(--surface-2);border-radius:5px;padding:1px 5px;font-size:13.5px}
.ug-body .toc-sidebar{position:sticky;top:86px;align-self:start}

/* The vendor mark inside a coverage row. Same grey weight as the strip, sized by height and
   given a fixed box so seventeen different aspect ratios line up in one column instead of the
   text jittering left and right down the table. */
.t-rowh{display:inline-flex;align-items:center;gap:9px}
.t-logo{width:26px;height:18px;object-fit:contain;filter:grayscale(1);opacity:.6;flex:none}

/* The guide recording. First thing under the hero, in the same frame language as the
   screenshots so a page of stills and one video read as one set. */
.ht-video{margin:0;max-width:100%}
.ht-video video{width:100%;height:auto;display:block;border:1px solid var(--line);
  border-radius:12px;background:var(--surface-3);box-shadow:0 1px 3px rgba(28,32,22,.055)}
.ht-video figcaption{margin-top:11px;color:var(--ink-3);font:400 14.5px/1.6 var(--sans)}

/* The breadcrumb trail. Sits between the nav and the hero, on the page ground rather than in a
   band of its own: it is orientation, not a section, and giving it a background made it read as
   the first piece of content on every page.
   The last item is the current page and is a <span>, never an <a>. */
.crumbs{border-bottom:1px solid var(--line);background:var(--surface)}
.crumbs ol{list-style:none;margin:0;padding:11px 0;display:flex;flex-wrap:wrap;align-items:center;
  gap:0 8px;font:400 13.5px/1.5 var(--sans)}
.crumbs li{display:flex;align-items:center;gap:8px;color:var(--ink-3);min-width:0}
.crumbs li+li::before{content:"";width:5px;height:5px;border-right:1.2px solid var(--line-strong);
  border-bottom:1.2px solid var(--line-strong);transform:rotate(-45deg);flex:none}
.crumbs a{color:var(--ink-3);text-decoration:none;transition:color .14s ease-out}
.crumbs a:hover,.crumbs a:focus-visible{color:var(--brand-ink);text-decoration:underline}
.crumbs [aria-current="page"]{color:var(--ink-2);font-weight:500;
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:52ch}
@media (max-width:560px){.crumbs [aria-current="page"]{max-width:24ch}}

/* The release history. Three columns of version and date, so forty six releases read as a
   reference table rather than as a wall, and each line stays scannable for the one number the
   reader arrived with. */
.rels{display:grid;gap:34px}
.rel-h{font:600 17px/1.3 var(--sans);color:var(--ink);margin:0}
.rel-span{margin:3px 0 12px;font:400 14px/1.5 var(--sans);color:var(--ink-3)}
.rel-list{list-style:none;margin:0;padding:0;display:grid;gap:2px 26px;
  grid-template-columns:repeat(3,minmax(0,1fr))}
.rel-list li{display:flex;justify-content:space-between;gap:12px;padding:7px 0;
  border-bottom:1px solid var(--line);font:400 14.5px/1.4 var(--sans);color:var(--ink-2)}
.rel-list li b{font-weight:600;color:var(--ink);font-variant-numeric:tabular-nums}
.rel-list li span{color:var(--ink-3);font-variant-numeric:tabular-nums}
@media (max-width:820px){.rel-list{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media (max-width:520px){.rel-list{grid-template-columns:1fr}}

/* The vendor strip.
   Understated on purpose. A logo wall is the element readers have learned to skip, and the only
   thing that makes this one worth the space is the sentence under it pointing at a matrix that
   admits its gaps. So the marks rest in one grey weight at a single height and take their colour
   only under the cursor: the strip reads as a list of platforms rather than as a claim of
   endorsement, which is also the honest reading.
   Sized by height, never by width. These twelve files have twelve different aspect ratios and a
   shared width would have set them at twelve different visual weights. */
.logos{list-style:none;margin:0;padding:0;display:grid;gap:10px 18px;
  grid-template-columns:repeat(6,1fr);align-items:center}
/* A GRID, and each mark contained inside an identical cell rather than set to a common height.
   Half of these are wordmarks and half are icon marks: matched on height, ARISTA ran to 135px
   wide while Nokia sat at 40, and the row read as a ranking. An identical box lets the wide ones
   shrink on width and the compact ones grow on height, which is the closest thing to equal
   optical weight without hand tuning twelve scale factors. */
/* Two fixed rows rather than a flex column: the marks are different heights, so stacking them
   put every name at a different baseline and the second row read as ragged. */
.logos .lg-i{display:grid;grid-template-rows:38px auto;justify-items:center;align-items:center;
  gap:9px;padding:8px 10px;border-radius:10px}
/* 82% rather than 100%: a wordmark drawn to the edge of its viewBox and an icon drawn with air
   around it are not the same picture, and the tight ones were reading a size larger. */
.logos .lg-i img{max-height:34px;max-width:82%;width:auto;height:auto;object-fit:contain;
  filter:grayscale(1);opacity:.5;transition:filter .18s ease-out,opacity .18s ease-out}
.logos .lg-n{font:500 12px/1 var(--sans);letter-spacing:.02em;color:var(--ink-3);
  text-align:center;transition:color .18s ease-out}
.logos .lg-i:hover img,.logos .lg-i:focus-within img{filter:none;opacity:1}
.logos .lg-i:hover .lg-n{color:var(--ink-2)}
@media (max-width:900px){.logos{grid-template-columns:repeat(4,1fr)}}
@media (max-width:560px){.logos{grid-template-columns:repeat(3,1fr)}}

/* ------------------------------------------------------------------ touch sizing
   The navigation stacks below 1280px, so from there down every nav row is a touch target rather
   than a mouse target. Measured at 390px before this: rows 35 to 40px tall, the language switch
   35x35. That clears the WCAG 2.2 AA floor of 24px and sits under the 44px that stops a thumb
   catching two rows at once, which is the failure people actually experience.
   Matched to 1280px deliberately, not 900px: 900 was the wrong number because it is not where
   this navigation changes shape. The eyebrow labels come up with it, set in the mono face at
   11.5px, which reads at desk distance and is marginal on a phone. */
@media (max-width:1280px){
  .nav.open .nv-item,.nav.open .nv-sub a{min-height:44px;display:flex;align-items:center}
  .lang{min-height:44px;min-width:44px;display:inline-flex;align-items:center;justify-content:center}
  .nv-toggle{min-height:44px;min-width:44px}
}
@media (max-width:900px){
  .eyebrow{font-size:12.5px}
  .sp dt{font-size:12.5px}
}

/* The row label stays put while the columns scroll under it.
   Without this, a phone reader who scrolls the seventeen row coverage matrix to reach the right
   hand columns is looking at columns of dots with no vendor names: the answer is on screen and
   the question has scrolled off. Measured that way before this, by scrolling the table fully
   right and reading the render rather than the rule.
   The sticky cell needs an opaque ground of its own or the scrolling cells show through it, and
   it needs to sit above them, hence the z-index. The right hand border replaces the one the
   scrolled-away cell used to provide, so the label reads as a fixed column rather than as text
   floating over the table. Only below 1280px: above it the table fits and nothing scrolls. */
@media (max-width:1280px){
  .tablewrap thead th:first-child,
  .tablewrap tbody th[scope="row"],
  .tablewrap tbody td:first-child{
    position:sticky;left:0;z-index:2;
    background:var(--surface);
    box-shadow:1px 0 0 var(--line)}
  .tablewrap thead th:first-child{background:var(--surface-2);z-index:3}
}
.lg-note{margin:20px 0 0;max-width:70ch;color:var(--ink-3);font:400 14.5px/1.6 var(--sans)}

/* They arrive once, in reading order, when the strip is first on screen. Same idiom and the same
   guard as the diagram connectors: a second kind of motion is how a site stops feeling like one
   thing. The resting state is the finished strip, so a reader with no JavaScript sees all twelve
   and the class only ever replays what is already there. */
.logos.lg-in .lg-i{opacity:0;transform:translateY(6px);
  animation:lg-arrive .34s cubic-bezier(.32,0,.24,1) forwards;
  animation-delay:calc(var(--i,0) * .05s)}
@keyframes lg-arrive{to{opacity:1;transform:none}}

/* The connectors arrive in reading order, once, when the figure is first on screen.
   This is the site's one piece of narrative motion and it is here because here the motion IS
   the explanation: the diagram's whole claim is that these stages happen in an order, and drawn
   flat that order has to be reconstructed from which way the arrowheads point.
   What does NOT move is the point. Every card, every label, every chip and every device glyph is
   painted in the first frame and never animates: those are the facts, and a fact that fades in
   is a fact the reader has to wait for. Only the seven connectors arrive.
   The resting state is the finished diagram. Both properties are re-declared under .fl-draw
   rather than left to a backwards fill, so a connector is never briefly invisible on a browser
   that resolves fill modes differently, and a reader with no JavaScript gets the whole drawing.
   stroke-dashoffset is a paint on seven short 1.6px paths, which is cheap; pathLength="1"
   normalises every path to one unit so a 60px hop and a 900px wrap take the same time.
   Opacity sits on the wrapping group, not the path: a marker is painted as part of the path that
   references it, so the group is what carries the arrowhead in with its line. */
.flow.fl-draw .fl-conn{opacity:0;animation:fl-conn-in .2s ease-out forwards;
  animation-delay:calc(var(--i,0) * .13s)}
.flow.fl-draw .fl-link{stroke-dasharray:1;stroke-dashoffset:1;
  animation:fl-conn-draw .36s cubic-bezier(.32,0,.24,1) forwards;
  animation-delay:calc(var(--i,0) * .13s)}
@keyframes fl-conn-in{to{opacity:1}}
@keyframes fl-conn-draw{to{stroke-dashoffset:0}}

/* --------------------------------------------- the path figure: hover, and a sequence with a point
   REWRITTEN 2026-08-31. The figure animated seven 1.6px connectors for 1.3s, once, with every card
   fully painted in the first frame. The note above argues the cards are the facts and should not
   move, which is a real argument, but the result was a reader watching thin arrows appear between
   boxes already read: furtive, and explaining nothing. The cards now arrive in reading order and
   each connector follows the card it leaves, so the order the figure CLAIMS is the order it SHOWS
   rather than something reconstructed from which way the arrowheads point.

   SCOPED TO .flowpath, NEVER .flow. Five diagrams on this site carry .flow (boundary, chain,
   layers, the ring, and the pipeline figure), and b_cycle shares FLOW_JS with this one.

   FILL MODES ARE LOAD BEARING. The card animation is `backwards`, never `both`. A forwards fill
   keeps winning the cascade after the animation ends, and the hover dimming below is an ordinary
   opacity declaration, so `both` would silently make every hover state inert the moment the
   sequence had run. The connector gets its own keyframe rather than reusing fl-conn-in for the
   same reason: its base rule sets opacity:0, which a backwards fill would restore at the end.

   DIMMING IS GROUP OPACITY, which MULTIPLIES rather than overwrites, so a card's marks keep their
   relative weights while the group recedes. Same reason the pipeline figure animates transforms. */
.flowpath{--fbeat:420ms}
.flowpath .fl-hit{fill:transparent}          /* transparent, not none: none receives no pointer */
.flowpath .fl-item:focus{outline:none}
.flowpath .fl-item:focus-visible .fl-box,.flowpath .fl-item:focus-visible .fl-box-alt{
  stroke:var(--brand);stroke-width:2}

/* one card, and the links it is an end of in EITHER direction: what feeds it and what it feeds */
.flowpath.fl-focus .fl-item,.flowpath.fl-focus .fl-conn{transition:opacity .18s ease}
.flowpath.fl-focus .fl-item{opacity:.26}
.flowpath.fl-focus .fl-conn{opacity:.14}
.flowpath.fl-focus .fl-item.fl-on{opacity:1}
.flowpath.fl-focus .fl-conn.fl-lit{opacity:1}
.flowpath.fl-focus .fl-item.fl-on .fl-box,.flowpath.fl-focus .fl-item.fl-on .fl-box-alt{
  stroke:var(--brand)}
.flowpath.fl-focus .fl-conn.fl-lit .fl-link{stroke:var(--brand);stroke-width:2}

.fl-tip{position:fixed;z-index:60;max-width:320px;background:var(--surface);
  border:1px solid var(--line-strong);border-radius:8px;padding:10px 13px;
  font-size:13.5px;line-height:1.55;color:var(--ink-2);box-shadow:var(--shadow-2);
  pointer-events:none}

@keyframes flCard{from{opacity:0;transform:translateY(9px)}to{opacity:1;transform:translateY(0)}}
@keyframes flConn{from{opacity:0}to{opacity:1}}
.flowpath.fl-draw .fl-item{animation:flCard .42s cubic-bezier(.2,.7,.3,1) backwards;
  animation-delay:calc(var(--gi,0) * var(--fbeat))}
.flowpath.fl-draw .fl-conn{opacity:1;animation:flConn .2s ease-out backwards;
  animation-delay:calc(var(--i,0) * var(--fbeat) + var(--fbeat) * .55)}
.flowpath.fl-draw .fl-link{
  animation-delay:calc(var(--i,0) * var(--fbeat) + var(--fbeat) * .55)}
.fl-replay{margin:14px auto 0;display:flex}
@media (max-width:820px){.fl-tip,.fl-replay{display:none}}
@media (prefers-reduced-motion: reduce){
  .flowpath.fl-draw .fl-item{animation:none}
  .flowpath.fl-draw .fl-conn{animation:none;opacity:1}
  .fl-replay{display:none}
}

/* ------------------------------------------------------------------- the cycle, actually cycling
   The ring's whole claim is that it repeats, and until now nothing repeated: on the home page the
   animation was not even switched on, and where it was, four arcs traced once in 0.8s. A static
   ring is a picture of a loop, which asks the reader to imagine the one property the figure exists
   to carry.

   So a marker walks the ring, each stage lands as it is passed, and the centre resolves from the
   cadence to what the lap leaves behind.

   THE MARKER LOOPS, and it is meant to. This paragraph used to argue the opposite: one lap, then
   a Replay control, on the grounds that asking for another lap makes the button itself the
   argument. Mehdi asked for the continuous version explicitly, in those words, on 2026-09-01:
   the dot turns non stop with a slight pause between rounds. That is recorded here rather than
   only in the vault, because the next reader will otherwise find a figure doing what the comment
   forbids and helpfully "fix" it.

   The pause is baked into the keyframe, not timed in script: cyRide finishes its travel at 85.3%
   of an iteration lasting --clap plus --cpause, so the rest of the cycle is the dot resting at
   the top of the ring before it sets off again. One declaration, and nothing to drift.

   NUMBERS ARE GONE from the nodes, in build.py rather than here. The docstring on b_cycle argued
   that a numbered list contradicts repetition because lists end, and the drawing then numbered its
   own nodes. The marker carries the order now, which is what a ring can honestly do.

   FILL MODES, again. The node animation is `backwards`, never `both`, or its forwards fill would
   keep winning the cascade and make the hover dimming below inert once the lap had run. The centre
   swap is the one place `forwards` is correct, because there the end state IS the resting state.

   The marker is anchored at the origin and placed entirely by offset-path; it carries opacity 0 in
   the base state, so with no script the drawing is the finished ring and nothing stray sits in the
   corner. */
.cyc{--clap:3600ms;--cpause:620ms}
.cyc .cy-hit{fill:transparent}
.cyc .cy-node:focus{outline:none}
.cyc .cy-node:focus-visible .fl-box{stroke:var(--brand);stroke-width:2}
.cyc .cy-mark{fill:var(--brand);opacity:0;offset-rotate:0deg}
.cyc .cy-ctr-b{opacity:0}

/* hover and focus: one stage at a time */
.cyc.cy-focus .cy-node{opacity:.3;transition:opacity .18s ease}
.cyc.cy-focus .cy-node.cy-on{opacity:1}
.cyc.cy-focus .cy-node.cy-on .fl-box{stroke:var(--brand);stroke-width:1.8}

/* The ride occupies the first 85.3% of the cycle and the last 14.7% holds at the top of the
   ring. The pause is IN the keyframe rather than in a JS timer, so a single infinite animation
   carries it: no interval to drift, nothing to clean up, and the browser keeps the whole thing
   off the main thread. 3600ms of travel against a 620ms rest, so 3600/4220 = 85.3%. */
@keyframes cyRide{
  0%{offset-distance:0%}
  85.3%{offset-distance:100%}
  100%{offset-distance:100%}
}
@keyframes cyNode{from{opacity:0;transform:scale(.94)}to{opacity:1;transform:scale(1)}}
@keyframes cyDraw{from{stroke-dashoffset:1}to{stroke-dashoffset:0}}
@keyframes cyIn{from{opacity:0}to{opacity:1}}
@keyframes cyOut{from{opacity:1}to{opacity:0}}

.cyc.cy-run .cy-mark{opacity:1;
  animation:cyRide calc(var(--clap) + var(--cpause)) cubic-bezier(.45,0,.55,1) infinite backwards}
/* An animation that never ends would otherwise keep a compositor busy on a figure the reader has
   scrolled past. The observer sets this when the ring leaves the viewport; `paused` holds the dot
   where it is rather than resetting it, so scrolling back does not restart the lap mid-ring. */
.cyc.cy-idle .cy-mark{animation-play-state:paused}
.cyc.cy-run .cy-node{transform-box:fill-box;transform-origin:center;
  animation:cyNode .42s cubic-bezier(.2,.7,.3,1) backwards;
  animation-delay:calc(var(--gi,0) * (var(--clap) / var(--n,4)))}
.cyc.cy-run .fl-conn .fl-link{stroke-dasharray:1;
  animation:cyDraw calc(var(--clap) / var(--n,4)) linear backwards;
  animation-delay:calc(var(--i,0) * (var(--clap) / var(--n,4)))}
.cyc.cy-run .cy-ctr-a{animation:cyOut .4s ease-out forwards;
  animation-delay:calc(var(--clap) - 140ms)}
.cyc.cy-run .cy-ctr-b{animation:cyIn .5s ease-out forwards;animation-delay:var(--clap)}
.cy-replay{margin:14px auto 0;display:flex}
@media (max-width:820px){.cy-replay{display:none}}
@media (prefers-reduced-motion: reduce){
  .cyc.cy-run .cy-mark,.cyc.cy-run .cy-node,.cyc.cy-run .fl-conn .fl-link,
  .cyc.cy-run .cy-ctr-a,.cyc.cy-run .cy-ctr-b{animation:none}
  .cyc .cy-mark{opacity:0}
  .cy-replay{display:none}
}

/* --------------------------------------------------------------- the chain, deriving itself
   b_chain's argument is that fragments become an answer when they are cross referenced, and what
   carries it is the column under each box naming what that hop was BUILT OUT OF. So the sequence
   runs in that order: the source chips arrive, then the box built from them, then the handoff.
   Lighting the boxes first would animate a data model rather than an argument.

   RESTING STATE IS THE FINISHED CHAIN. Nothing here starts from invisible in the base state: the
   opacity: 0 lives on `.chain.ch-run` only, which a script adds. With no JavaScript the reader
   gets the whole drawing, which is the same guard the connectors and the logo strip use.

   FILL MODE is `backwards`, never `forwards` or `both`: a forwards fill keeps winning the cascade
   and would make the hover dimming inert once the sequence had run.

   THE OVERLAP IS DELIBERATE, AND IT HOLDS A WEAKER INVARIANT THAN THE CRAWL. Measured, hop 0:
   sources 0 to 340, box 143 to 523, handoff 265 to 525, and hop 1's sources open at 340 while
   hop 0's handoff is still travelling. Strict sequencing would cost a full beat per stage and
   run six hops for nearly six seconds, so the stages interleave for pace. What must survive the
   interleaving is the causal reading, and here that is a COMPLETION order: sources complete
   before the box they build completes, the box before its handoff, the handoff before the next
   hop's sources. Verified pairwise with a positive control, not by watching.

   The crawl next door holds the STRICTER start order, because its effects are discrete objects
   that read as arrived the moment they are visible, so a device appearing before its link lands
   inverts the claim outright. A box scaling up from .965 while its chips settle reads as forming,
   not as arriving. Different figures, different thresholds, and the threshold is a judgement to
   record rather than a constant to copy. See [[an-animations-order-can-contradict-the-figures-claim]].

   AND THE SCRIPT DROPS `.ch-run` once the sequence has played. The resting state being the finished
   chain only protects the no-script path; with the class on and a timeline that never advances, a
   backwards fill holds opacity: 0 and the reader gets a blank figure. Settling back to the resting
   state closes that, and costs nothing on a tab that ran normally. Raised by brain-49. */
.chain{--cbeat:340ms}
@keyframes chFrom{from{opacity:0;transform:translateY(7px)}to{opacity:1;transform:none}}
@keyframes chBox{from{opacity:0;transform:scale(.965)}to{opacity:1;transform:none}}
@keyframes chArc{from{opacity:0}to{opacity:1}}

.chain.ch-run .ch-from{
  animation:chFrom .34s cubic-bezier(.2,.7,.3,1) backwards;
  animation-delay:calc(var(--i,0) * var(--cbeat))}
.chain.ch-run .ch-box{
  transform-box:fill-box;transform-origin:center;
  animation:chBox .38s cubic-bezier(.2,.7,.3,1) backwards;
  animation-delay:calc(var(--i,0) * var(--cbeat) + var(--cbeat) * .42)}
.chain.ch-run .ch-arc{
  animation:chArc .26s ease-out backwards;
  animation-delay:calc(var(--i,0) * var(--cbeat) + var(--cbeat) * .78)}

.ch-replay{margin:14px auto 0;display:flex}
@media (max-width:820px){.ch-replay{display:none}}
@media (prefers-reduced-motion: reduce){
  .chain.ch-run .ch-from,.chain.ch-run .ch-box,.chain.ch-run .ch-arc{animation:none}
  .ch-replay{display:none}
}

/* ---------------------------------------------------------------- live demo
   A recording of the product beside a contents that follows it. The steps are
   the narration, because burnt-in captions would mean one file per language.
   Below the figure breakpoint the video stops being ambient: it keeps its
   controls and plays only when asked, same contract as the wide canvases. */
.livedemo{margin:0}
.ld-frame{position:relative;border:1px solid var(--rule);border-radius:10px;
  overflow:hidden;background:var(--surface);box-shadow:0 1px 2px rgba(16,32,40,.05)}
.ld-frame video{display:block;width:100%;height:auto}
.ld-steps{list-style:none;margin:18px 0 0;padding:0;display:grid;gap:2px;
  grid-template-columns:repeat(3,1fr)}
.ld-step{display:flex;align-items:baseline;gap:9px;flex-wrap:wrap;padding:9px 11px;
  border-left:2px solid transparent;border-radius:0 4px 4px 0;cursor:pointer;
  transition:background .18s ease,border-color .18s ease}
.ld-step:hover{background:var(--surface-tint)}
.ld-step[aria-current="true"]{background:var(--surface-tint);border-left-color:var(--signal)}
.ld-at{font-variant-numeric:tabular-nums;font-size:.78rem;color:var(--muted);flex:none}
.ld-lab{font-size:.9rem;font-weight:600;color:var(--ink)}
.ld-note{flex-basis:100%;font-size:.82rem;color:var(--muted);line-height:1.45}
.livedemo figcaption{margin-top:14px;font-size:.86rem;color:var(--muted);line-height:1.55}
@media (max-width:1040px){ .ld-steps{grid-template-columns:repeat(2,1fr)} }
@media (max-width:820px){ .ld-steps{grid-template-columns:1fr} }
@media (prefers-reduced-motion: reduce){ .ld-step{transition:none} }

/* ------------------------------------------------------- pipeline, in motion
   REWRITTEN 2026-08-31, because the previous pass was furtive and explained nothing.

   WHAT WAS WRONG. Seven staggered entrances, trusting the ORDER to carry the argument.
   Order is narration, not transformation. Every keyframe was an APPEARANCE (pop, grow,
   slide, rise, fade), so the figure said "here is a thing" seven times and never showed
   one thing BECOMING another, which is its entire thesis. The two exceptions were the
   funnel and the node links, because a drawn line is the one mark on screen that
   expresses a relationship rather than an arrival.

   THREE CHANGES CARRY THE MEANING.

   1. A STAGE ANNOUNCES ITSELF, THEN FILLS. The headings used to be static, so all seven
      titles stood there from the first frame while contents popped in underneath. That is
      what made the motion read as decoration over a finished drawing. Each heading now
      arrives on its own beat and its stage fills behind it, which is what turns seven
      entrances into one sequence.
   2. THE RAIL CARRIES THE ARC. The line at the foot IS the thesis, raw on the left and
      decided on the right, and it was the one element that never moved. It now draws
      across the whole run with a marker riding it, so the reader has a position in the
      argument at every moment, and the right hand label lands exactly as the answer
      cards resolve.
   3. CORRELATION IS DISCOVERED, NOT DELIVERED. Stage 06 drew the links and then dropped
      the nodes onto them, which is backwards: you cannot join things that are not there
      yet. Nodes first, then the links found between them.

   PACING. Beats of 560ms with a pause inside each, rather than one continuous 2.4s run of
   25ms staggers. The old timing was not fast because it was efficient, it was fast because
   nothing was allowed to land. About 4.3s now, and the reader can ask for it again: the
   cure for a screensaver is not a single unrepeatable burst.

   WHY EVERY ANIMATION HERE IS A TRANSFORM AND NEVER AN OPACITY. Unchanged and still load
   bearing. The elements carrying an inline opacity attribute (.pp-dev, .pp-cmd, .pp-b3,
   .pp-ix, .pp-col) hold the saturation ramp and the fade down the command list, which is a
   large part of what makes the drawing simplify left to right. A CSS opacity animation beats
   a presentation attribute, so animating opacity on those would flatten the exact gradient
   the figure exists to show. Only elements with NO inline opacity may fade. That is also why
   the rider is a separate element rather than a highlight on something already drawn.

   AND WHY NOTHING WAS ADDED TO THE LEFT HALF. The figure was approved for its density arc,
   after two heavier attempts were rejected on sight. Motion has to serve that arc, so the
   only new mark is a 3.5px rider on an existing line, below the drawing.

   Nothing is hidden in the base state except that rider, which exists only for the animation.
   With JavaScript off no .pp-run class is ever added, nothing animates, and the drawing that
   renders is exactly the one that was approved. */
.pipe{--beat:560ms}
.pipe .pp-dev,.pipe .pp-b1,.pipe .pp-b3,.pipe .pp-cmd,.pipe .pp-ix,
.pipe .pp-node,.pipe .pp-col,.pipe .pp-card,.pipe .pp-raw,.pipe .pp-field{
  transform-box:fill-box}
.pp-run .pp-dev,.pp-run .pp-ix,.pp-run .pp-node{transform-origin:center}
.pp-run .pp-b1,.pp-run .pp-b3{transform-origin:left center}
.pp-run .pp-col{transform-origin:bottom center}
.pp-run .pp-cmd{transform-box:view-box;transform-origin:left center}
/* the only mark that is not part of the static drawing */
.pipe .pp-ride{fill:var(--brand);opacity:0}

@keyframes ppPop{from{transform:scale(0)}to{transform:scale(1)}}
@keyframes ppGrowX{from{transform:scaleX(0)}to{transform:scaleX(1)}}
@keyframes ppGrowY{from{transform:scaleY(0)}to{transform:scaleY(1)}}
@keyframes ppSlide{from{transform:translateX(-14px)}to{transform:translateX(0)}}
@keyframes ppDraw{from{stroke-dashoffset:var(--len,190)}to{stroke-dashoffset:0}}
@keyframes ppDraw1{from{stroke-dashoffset:1}to{stroke-dashoffset:0}}
@keyframes ppRise{from{transform:translateY(9px)}to{transform:translateY(0)}}
@keyframes ppFade{from{opacity:0}to{opacity:1}}
@keyframes ppHead{from{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:translateY(0)}}
@keyframes ppRide{0%{opacity:0;transform:translateX(0)}
  6%{opacity:1} 92%{opacity:1}
  100%{opacity:0;transform:translateX(1192px)}}

/* a stage announces itself on its beat; its footnote closes it 260ms later */
.pp-run .pp-n,.pp-run .pp-h,.pp-run .pp-s,.pp-run .pp-f{
  animation:ppHead .36s cubic-bezier(.2,.7,.3,1) both;
  animation-delay:calc(var(--i)*var(--beat))}
.pp-run .pp-f{animation-delay:calc(var(--i)*var(--beat) + 260ms)}

/* the thesis line, drawn under the whole run, with the marker riding it */
.pp-run .pp-rail{stroke-dasharray:1;animation:ppDraw1 4.05s linear both;animation-delay:120ms}
.pp-run .pp-ride{animation:ppRide 4.05s linear both;animation-delay:120ms}
.pp-run .pp-rl{animation:ppFade .4s ease-out both;animation-delay:200ms}
.pp-run .pp-rr{animation:ppFade .5s ease-out both;animation-delay:3900ms}

/* 01 the devices arrive, top to bottom */
.pp-run .pp-dev{animation:ppPop .26s cubic-bezier(.2,.7,.3,1) both;
  animation-delay:calc(140ms + var(--i)*26ms)}
.pp-run .pp-b1{animation:ppGrowX .3s cubic-bezier(.2,.7,.3,1) both;
  animation-delay:calc(200ms + var(--i)*26ms)}
/* 02 the asking: further than before, so it reads as travel and not as jitter */
.pp-run .pp-cmd{animation:ppSlide .28s ease-out both;
  animation-delay:calc(700ms + var(--i)*22ms)}
/* 03 raw text coming back, irregular on purpose */
.pp-run .pp-raw{animation:ppFade .3s ease-out both;animation-delay:1260ms}
.pp-run .pp-b3{animation:ppGrowX .22s ease-out both;
  animation-delay:calc(1300ms + var(--i)*12ms)}
/* 04 the funnel draws itself, and the field box lands as the last line reaches it */
.pp-run .pp-fn{stroke-dasharray:190;animation:ppDraw .5s ease-in-out both;
  animation-delay:calc(1820ms + var(--i)*18ms)}
.pp-run .pp-field,.pp-run .pp-fl{animation:ppFade .34s ease-out both;animation-delay:2120ms}
/* 05 the wave crosses the grid on its diagonal, --i is row+column */
.pp-run .pp-ix{animation:ppPop .3s cubic-bezier(.2,.7,.3,1) both;
  animation-delay:calc(2380ms + var(--i)*26ms)}
/* 06 the nodes exist FIRST, then the links are found between them */
.pp-run .pp-node{animation:ppPop .32s cubic-bezier(.34,1.4,.5,1) both;
  animation-delay:calc(2940ms + var(--i)*34ms)}
.pp-run .pp-nl{stroke-dasharray:130;animation:ppDraw .38s ease-out both;
  animation-delay:calc(3120ms + var(--i)*30ms)}
/* 07 last and slowest, because this is where the reader should still be looking */
.pp-run .pp-card{animation:ppRise .5s cubic-bezier(.2,.7,.3,1) both,ppFade .5s ease-out both;
  animation-delay:calc(3500ms + var(--i)*120ms)}
.pp-run .pp-big,.pp-run .pp-sm{animation:ppFade .44s ease-out both;animation-delay:3700ms}
.pp-run .pp-col{animation:ppGrowY .46s cubic-bezier(.2,.7,.3,1) both;
  animation-delay:calc(3720ms + var(--i)*44ms)}
.pp-run .pp-ok,.pp-run .pp-warn,.pp-run .pp-stop{animation:ppPop .34s cubic-bezier(.34,1.4,.5,1) both;
  animation-delay:3960ms}

/* the replay control. Built by the script, so it cannot exist where the animation does not. */
.pp-replay{margin:14px auto 0;display:flex}
@media (max-width:820px){.pp-replay{display:none}}

@media (prefers-reduced-motion: reduce){
  .pp-run .pp-dev,.pp-run .pp-b1,.pp-run .pp-cmd,.pp-run .pp-raw,.pp-run .pp-b3,
  .pp-run .pp-fn,.pp-run .pp-field,.pp-run .pp-fl,.pp-run .pp-ix,.pp-run .pp-nl,
  .pp-run .pp-node,.pp-run .pp-card,.pp-run .pp-big,.pp-run .pp-sm,.pp-run .pp-col,
  .pp-run .pp-ok,.pp-run .pp-warn,.pp-run .pp-stop,
  .pp-run .pp-n,.pp-run .pp-h,.pp-run .pp-s,.pp-run .pp-f,
  .pp-run .pp-rail,.pp-run .pp-ride,.pp-run .pp-rl,.pp-run .pp-rr{animation:none}
  .pp-replay{display:none}
}

/* ----------------------------------------------------------------- icon marks
   Structural marks, allowed on four surfaces only. The rule for admitting one
   lives beside the registry in build.py, not here and not in a spec file, because
   a rule in a document drifts from the thing it describes and the code does not.

   Each mark sits in a rounded square rather than bare. The square echoes the cards
   and chips the site already uses; a circle would introduce a shape the page does
   not otherwise have, and several marks are WIDE, the port strip and the matrix,
   so a circle crops their argument or shrinks them to fit.

   currentColor throughout: the mark takes its row's colour. Seven marks each with
   their own accent would add a colour the page did not have. */
.ic-b{display:inline-flex;align-items:center;justify-content:center;flex:none;
  border-radius:9px;background:var(--surface-tint);border:1px solid var(--rule);
  color:var(--brand-ink)}
.ic{display:block;flex:none}

/* In a card the badge stands alone above a heading, so it carries size. */
.card .ic-b{width:44px;height:44px;margin:0 0 15px}
.card .ic{width:24px;height:24px}

/* In a row it sits beside a name and must not grow the row. */
.cat-f .ic-b,.t-rowh .ic-b{width:28px;height:28px;margin:0 10px 0 0;vertical-align:middle}
.cat-f .ic-b .ic,.t-rowh .ic-b .ic{width:17px;height:17px}
.cat-f,.t-rowh{display:flex;align-items:center}

@media (max-width:820px){ .card .ic-b{width:38px;height:38px;margin-bottom:11px}
  .card .ic{width:21px;height:21px} }

/* ------------------------------------------------- platform detail accordion
   Native <details>. It opens with JavaScript off, takes keyboard focus and is
   found by the browser's own in-page search, none of which a scripted accordion
   would have given us. */
.pd{border:1px solid var(--rule);border-radius:10px;overflow:hidden;background:var(--surface)}
.pd-i + .pd-i{border-top:1px solid var(--rule)}
.pd-i > summary{display:flex;align-items:baseline;gap:14px;padding:15px 18px;cursor:pointer;
  list-style:none;transition:background .16s ease}
.pd-i > summary::-webkit-details-marker{display:none}
.pd-i > summary::after{content:"+";margin-left:auto;font-family:var(--mono);font-size:15px;
  color:var(--muted);line-height:1}
.pd-i[open] > summary::after{content:"\2212"}
.pd-i > summary:hover{background:var(--surface-tint)}
.pd-i > summary:focus-visible{outline:2px solid var(--signal);outline-offset:-2px}
.pd-i[open] > summary{background:var(--surface-tint);border-bottom:1px solid var(--rule)}
.pd-n{font-weight:600;color:var(--ink)}
.pd-k{font-family:var(--mono);font-size:12px;color:var(--muted)}
.pd-b{padding:18px;display:grid;gap:20px;grid-template-columns:repeat(3,1fr)}
.pd-g h4{margin:0 0 8px;font-family:var(--mono);font-size:11px;letter-spacing:.14em;
  text-transform:uppercase;color:var(--brand-ink);font-weight:600}
.pd-c{display:grid;gap:5px}
.pd-c li{font-size:12.5px;line-height:1.45}
.pd-c code{font-family:var(--mono);font-size:12px;color:var(--ink);background:var(--surface-tint);
  border:1px solid var(--rule);border-radius:4px;padding:1px 6px;display:inline-block;
  word-break:break-word}
@media (max-width:1040px){ .pd-b{grid-template-columns:repeat(2,1fr)} }
@media (max-width:820px){ .pd-b{grid-template-columns:1fr;padding:15px} }
@media (prefers-reduced-motion: reduce){ .pd-i > summary{transition:none} }

/* ------------------------------------------------------------------ the crawl, reaching out
   b_crawl's argument has two halves and only one of them is the spread: a seed finds its
   neighbours hop by hop, AND one device announcing neither CDP nor LLDP is never reached. A
   static drawing states both; animating only the spread would state the marketing half twice.

   So the walk animates and the unreachable device does NOT. It is on screen from the first
   frame, because it was on the network the whole time, and the wave passes it. Its single beat
   lands after the last hop, when the reader is looking for what else is in the picture.

   THE LINKS DRAW rather than fade, because reaching is the verb the figure is about. Every path
   carries pathLength="1", so the draw is dashoffset 1 to 0 and no curve has to be measured.

   RESTING STATE IS THE FINISHED WALK: every opacity: 0 and every dashoffset below sits under
   `.crawl.cw-run`, which only the script adds, and the script removes it again once the walk has
   played. With no JavaScript, a stalled timeline, or reduced motion, the reader gets the drawing.

   FILL MODE is `backwards`, never `forwards` or `both`, for the same reason as everywhere else
   on this page: a forwards fill keeps winning the cascade and makes the hover states inert. */
.crawl{--wbeat:420ms}
@keyframes cwReach{from{stroke-dashoffset:1}to{stroke-dashoffset:0}}
@keyframes cwLand{from{opacity:0;transform:scale(.9)}to{opacity:1;transform:none}}
@keyframes cwNotice{
  0%{opacity:1}
  38%{opacity:.42}
  100%{opacity:1}}

/* THE ORDER IS THE ARGUMENT, so the numbers have to hold it: a device is discovered BECAUSE a
   neighbour reported it, so the link must finish before the device it leads to appears. Column i
   lands at i*wbeat; this link runs from i*wbeat + .34 to i*wbeat + .84 of a beat; column i+1
   lands at i*wbeat + 1 beat. The first draft drew for a full beat starting at .55, which put every
   device on screen 230ms before the line that found it, quietly reversing cause and effect. */
.crawl.cw-run .cw-links .fl-link{
  stroke-dasharray:1;
  animation:cwReach calc(var(--wbeat) * .5) cubic-bezier(.35,.6,.3,1) backwards;
  animation-delay:calc(var(--i,0) * var(--wbeat) + var(--wbeat) * .34)}

.crawl.cw-run .cw-nodes{
  transform-box:fill-box;transform-origin:center;
  animation:cwLand .34s cubic-bezier(.2,.7,.3,1) backwards;
  animation-delay:calc(var(--i,0) * var(--wbeat))}

/* One beat, and it is a DIM rather than a flash: the device is not being announced, it is being
   noticed as the thing nothing announced. */
.crawl.cw-run .cw-unseen{
  animation:cwNotice .9s ease-in-out backwards;
  animation-delay:calc(var(--i,0) * var(--wbeat))}

.cw-replay{margin:14px auto 0;display:flex}
@media (max-width:820px){.cw-replay{display:none}}
@media (prefers-reduced-motion: reduce){
  .crawl.cw-run .cw-links .fl-link,.crawl.cw-run .cw-nodes,.crawl.cw-run .cw-unseen{animation:none}
  .crawl.cw-run .cw-links .fl-link{stroke-dasharray:none}
  .cw-replay{display:none}
}

/* ------------------------------------------------------- the nested barriers, wrapping inward
   THE DIRECTION IS THE ARGUMENT, and here it runs opposite to every other figure on the site.
   Revealing these rings outside in would animate the attacker's route: the disk, then the
   running machine, then an application account, one after another. The page denies exactly
   that in its own caption, "these are nested barriers and not steps: all of them have to be
   got through", and build.py already refuses to number the rings for the same reason. An
   outside-in sequence would have put the numbering back in a form nobody proofreads.

   So it runs INSIDE OUT. The core arrives first, then each barrier closes around it, which is
   a claim the figure does make: every one of these exists to wrap the thing at the centre.

   THE ORIGIN IS THE CORE'S CENTRE, passed down from build.py as --ox/--oy in view-box units.
   `transform-box: fill-box` would use each group's own bounding box, and those boxes include
   the commentary text hanging off to the right, so every ring would grow from a different and
   visibly wrong point. The scale is small on purpose: these rectangles are nested with 26px of
   clearance, and anything larger makes a ring cross the one inside it on the way in.

   Resting state is the finished nest, the script removes .ly-run once it has played, and the
   fill is `backwards`. Same contract as the chain and the crawl. */
.layers{--lbeat:300ms}
@keyframes lyWrap{from{opacity:0;transform:scale(.985)}to{opacity:1;transform:none}}

.layers.ly-run .ly-core,.layers.ly-run .ly-ring{
  transform-box:view-box;
  transform-origin:var(--ox, 50%) var(--oy, 50%);
  animation:lyWrap .44s cubic-bezier(.2,.7,.3,1) backwards;
  animation-delay:calc(var(--i,0) * var(--lbeat))}

.ly-replay{margin:14px auto 0;display:flex}
@media (max-width:820px){.ly-replay{display:none}}
@media (prefers-reduced-motion: reduce){
  .layers.ly-run .ly-core,.layers.ly-run .ly-ring{animation:none}
  .ly-replay{display:none}
}

/* ---- sift: one body of data, divided. Motion is SELECTION, not appearance. ---------------- */
/* RESTING STATE IS THE DIVIDED FIGURE. Rows sit where they landed, dropped rows are dimmed in
   place, cards are present. A reader with no JS, reduced motion, or a stalled timeline gets the
   whole argument. `.sf-run` winds it back to the undivided start and plays it forward once. */
.sift .sf-keep{fill:var(--brand);opacity:.85;
  transform:translate(var(--dx),var(--dy)) scaleX(var(--lw));transform-origin:left center}
.sift .sf-drop{fill:var(--ink-3);opacity:.22}
/* Where a kept row used to be. Static, never animated: it is not a thing that happens,
   it is the absence the division leaves behind. */
.sift .sf-ghost{fill:none;stroke:var(--line-strong);stroke-width:.8;stroke-dasharray:3 3;opacity:.5}
.sift .sf-link{opacity:.5}
.sift .sf-card{opacity:1}

/* fill BACKWARDS, never both: `both` leaves the final frame welded on after the class is dropped,
   and the settle timeout drops it deliberately so a stalled run cannot strand the figure. */
.sift.sf-run .sf-keep{animation:sfKeep 1.5s cubic-bezier(.45,0,.15,1) backwards;
  animation-delay:calc(.5s + var(--i)*.045s)}
.sift.sf-run .sf-drop{animation:sfDrop .8s ease-out backwards;
  animation-delay:calc(.5s + var(--i)*.045s)}
.sift.sf-run .sf-link{animation:sfLink .7s ease-in-out backwards;
  animation-delay:calc(.25s + var(--j)*.12s)}
.sift.sf-run .sf-card{animation:sfCard .5s ease-out backwards;
  animation-delay:calc(1.1s + var(--j)*.14s)}

@keyframes sfKeep{
  0%{transform:translate(0,0) scaleX(1);fill:var(--ink-3);opacity:.5}
  22%{fill:var(--brand);opacity:.9}
  100%{transform:translate(var(--dx),var(--dy)) scaleX(var(--lw));opacity:.85}}
@keyframes sfDrop{0%{opacity:.5}100%{opacity:.22}}
@keyframes sfLink{0%{opacity:0}100%{opacity:.5}}
@keyframes sfCard{0%{opacity:0;transform:translateY(5px)}100%{opacity:1;transform:translateY(0)}}

/* The narrow layout has no drawing at all, so the dropped rows have to be a NAMED group there or
   the phone version quietly argues something the drawing does not. */
.figstack ul.fs-drop li .fs-l{color:var(--ink-3)}
@media (prefers-reduced-motion:reduce){
  .sift.sf-run .sf-keep,.sift.sf-run .sf-drop,
  .sift.sf-run .sf-link,.sift.sf-run .sf-card{animation:none}
}
