/* ==========================================================================
   ICF Market — the whole stylesheet.
   One file, no framework, no webfont, no external request.

   The old site loaded 90 CSS files for a 597 KB page. The constraint here is
   not aesthetic modesty, it is that a page a person can read should not need
   ninety files to describe it.

   Dark only, by decision. English, LTR only — no logical-property gymnastics
   for a direction this site will never render in.
   ========================================================================== */

/* --- tokens --------------------------------------------------------------- */

:root {
  color-scheme: dark;

  --bg:          #080b11;
  --bg-elev:     #0e141d;
  --bg-elev-2:   #141c28;
  --bg-inset:    #0b1017;

  --line:        #1b2534;
  --line-strong: #2a3a4e;

  --text:        #e7eef6;
  --text-dim:    #93a3b8;
  /* 🔴 WAS #64748b, WHICH MEASURED 3.88:1 — under the 4.5 floor. This is the
     value the PANEL sees: the panel loads site.css + admin.css and never
     theme-e5.css, so the token has two live values and both were failing. The
     public one is fixed in theme-e.css.
     Chosen to stay clearly dimmer than --text-dim (#93a3b8) so the three-step
     text hierarchy survives; measured ~5.2:1 on the lightest panel surface. */
  --text-faint:  #7f8ea6;

  --accent:      #38bdf8;
  --accent-hi:   #7dd3fc;

  /* 🔴 A SURFACE COLOUR. As text it measures 3.20:1 against --bg-elev-2 here
     and 2.57:1 in the public palette — see the note in theme-e.css. Fills,
     borders and gradient stops only. For a quiet accent that is READ, below. */
  --accent-dim:  #0e7490;

  /* .75 measured 5.06:1 against --bg-elev-2, the worst surface in this palette;
     .65 gave 4.13 and .70 gave 4.57, and a token that clears the floor by 0.07
     is one rounding away from failing. The public palette carries its own value
     in theme-e.css — same token, different colour, like --text-faint. */
  --accent-quiet: rgb(56 189 248 / .75);

  /* The two sides of a footprint cell. Used sparingly and decoratively —
     never as the only carrier of meaning. */
  --bid:         #34d399;
  --ask:         #fb7185;
  --gold:        #fbbf24;
  /* Section headings on the long admin screens, at the owner's request
     (2026-08-17) — they were the same colour as body text and hard to find in a
     page of seven cards.
     🔑 NOT `--gold`, deliberately: gold means "warning" here (`.pill--warn`,
     `.ff__hint--warn`), and painting every heading with it would make the
     warnings stop standing out — the signal would be spent on decoration. */
  --heading:     #f59e0b;

  --radius:      14px;
  --radius-sm:   9px;
  --wrap:        1180px;

  --font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
          'Helvetica Neue', Arial, sans-serif;
  --mono: ui-monospace, 'SF Mono', 'Cascadia Mono', 'Consolas', monospace;

  --shadow:    0 1px 2px rgb(0 0 0 / .4), 0 12px 32px -12px rgb(0 0 0 / .6);
  --shadow-lg: 0 2px 4px rgb(0 0 0 / .4), 0 28px 64px -20px rgb(0 0 0 / .75);
}

/* --- reset ---------------------------------------------------------------- */

*, *::before, *::after { box-sizing: border-box; }

html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
  }
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font-family: var(--font);
  font-size: 16px;
  line-height: 1.65;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

/*
 * The page scrollbar is hidden on every page (owner's instruction, 2026-08-07).
 *
 * 🔑 HIDDEN, NOT DISABLED. The wheel, touch, space bar, Page Up/Down, Home/End
 * and keyboard arrows all still scroll — this removes the indicator, not the
 * mechanism. Doing it the other way round (`overflow: hidden`) would trap the
 * bottom of every long page behind no way to reach it.
 *
 * ⚠️ The cost is real and worth knowing: with no bar, a reader cannot see how
 * long a page is or where they are in it. On the manual — 6,000 words — that is
 * the difference between "this is nearly over" and "this never ends". Say the
 * word and this block comes out.
 *
 * Scoped to the page itself. Containers that scroll INSIDE the page (wide
 * tables, code blocks) keep their bars, because there the bar is the only thing
 * telling a reader that more exists off to the right.
 */
html {
  scrollbar-width: none;      /* Firefox */
  -ms-overflow-style: none;   /* old Edge/IE */
}
html::-webkit-scrollbar,
body::-webkit-scrollbar {
  width: 0;
  height: 0;
  display: none;              /* Chrome, Safari */
}

h1, h2, h3, h4 { margin: 0; line-height: 1.18; font-weight: 650; letter-spacing: -.02em; }
p  { margin: 0; }
ul, ol { margin: 0; padding: 0; }
img, svg { max-width: 100%; height: auto; display: block; }

a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-hi); }

:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
  border-radius: 4px;
}

::selection { background: rgb(56 189 248 / .28); }

.skip-link {
  position: absolute; left: -9999px;
  padding: 12px 18px; background: var(--accent); color: #041017;
  font-weight: 600; border-radius: 0 0 var(--radius-sm) 0; z-index: 100;
}
.skip-link:focus { left: 0; top: 0; }

/* Read aloud, never shown. Used for the label on the search field and for the
   <caption> on the specification tables, both of which say to a screen reader
   what the heading above already says to everybody else. `display: none` would
   not do — that hides it from assistive tech too, which is the opposite. */
.visually-hidden {
  position: absolute; width: 1px; height: 1px;
  padding: 0; margin: -1px; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap; border: 0;
}

/* --- layout --------------------------------------------------------------- */

.wrap {
  width: 100%;
  max-width: var(--wrap);
  margin-inline: auto;
  padding-inline: 24px;
}

/* --- one look on every desktop --------------------------------------------- */

/*
 * 🔴 NOTHING ON THIS SITE SCALES WITH THE VIEWPORT, and that is the whole
 * problem this solves. Measured across 1366 to 2560: the wrap (1180), the cards
 * (1132), the h1 (56px), the body text (16px) and the section padding (112px)
 * are IDENTICAL at every width — every clamp() in the stylesheets is already
 * pinned at its maximum above about 1250px. So only the PROPORTION moves:
 *
 *   1366   86% of the screen        1920   61%        2560   46%
 *
 * The owner has a 2K and a 1080p monitor and said the 1080p "loses the beauty".
 * It was never that things got bigger — it is that a fixed column fills more of
 * a smaller screen.
 *
 * 🔑 THE OWNER FOUND THE ANSWER THEMSELVES: browser zoom at 80% on the 1080p
 * matched the 2K, and below 80% the text became unreadable. So `zoom` is
 * exactly the right instrument — it scales EVERYTHING together, which is what
 * makes it look right.
 *
 * ⚠️ AN EARLIER ATTEMPT NARROWED THE COLUMN INSTEAD (--wrap: clamp(920px, 46vw,
 * 1180px)). It hit the same 48% proportion with the text left at 16px, and the
 * owner rejected it on sight: "everything is stretched". That is the lesson —
 * the proportion of column to screen was only half of it; the proportion of
 * TEXT to column is the other half, and shrinking one without the other breaks
 * the design. That attempt is reverted; see _backup/wrap_scaling_20260813/.
 *
 * 🧪 THE HAZARD, TESTED BEFORE WRITING THIS: `zoom` changes the effective layout
 * viewport, so a zoom set inside a width media query could oscillate — the zoom
 * changes the width, which unmatches the query, which removes the zoom. It does
 * NOT: measured, `innerWidth` and `matchMedia` both keep reporting the real
 * 1920 while the zoom is applied. Media queries evaluate against the unzoomed
 * viewport, so the ladder below is stable.
 *
 * 🔑 AND IT COMPOSES WITH THE USER'S OWN ZOOM RATHER THAN FIGHTING IT. Browser
 * zoom changes the CSS viewport the query sees, so a visitor already at 80% on
 * a 1920 screen reports 2400 — above this ladder's range — and gets no extra
 * scaling. Their setting wins.
 *
 *   >= 2400   untouched. 2560 is the look everything else is aiming at, and
 *             4K at Windows' recommended 150% reports exactly 2560.
 *   2160-2399 .9   ->  49% down to 46%, text 14.4px
 *   1500-2159 .8   ->  1920 lands at 49% with 12.8px text, which is the
 *                      combination the owner verified by hand
 *   < 1500    untouched. 12.8px text on a small laptop costs more than the
 *             proportion gains, and 1366 only reaches 69% anyway.
 *
 * 🎁 It also shrinks the long-standing horizontal overflow, because the wider
 * effective viewport gives the comet trail room: at 1920 it goes 64px -> 0,
 * at 1600 224 -> 19. That is a side effect, not the reason.
 *
 * ⚠️ Firefox only implemented standards `zoom` in 126. Older versions ignore
 * this and get exactly today's layout — a clean degradation, not a break.
 */
@media (min-width: 1500px) and (max-width: 2159px) {
  html { zoom: .8; }
}

@media (min-width: 2160px) and (max-width: 2399px) {
  html { zoom: .9; }
}

.section { padding-block: clamp(56px, 8vw, 104px); }
.section + .section { padding-top: 0; }

.section--inset  { background: var(--bg-elev); padding-block: clamp(56px, 8vw, 104px); }
.section--inset + .section { padding-top: clamp(56px, 8vw, 104px); }

.section__head { max-width: 760px; margin-bottom: 40px; }

.eyebrow {
  display: inline-flex; align-items: center; gap: 8px;
  font-family: var(--mono);
  font-size: 11.5px; letter-spacing: .13em; text-transform: uppercase;
  color: var(--accent);
  margin-bottom: 18px;
}
.eyebrow::before {
  content: ''; width: 22px; height: 1px; background: currentColor; opacity: .6;
}

h1 { font-size: clamp(34px, 5.2vw, 58px); }
h2 { font-size: clamp(26px, 3.4vw, 38px); }
h3 { font-size: clamp(19px, 2vw, 23px); }
h4 { font-size: 17px; }

.lede {
  font-size: clamp(16.5px, 1.55vw, 18.5px);
  color: var(--text-dim);
  max-width: 68ch;
}

.prose { color: var(--text-dim); max-width: 72ch; }
.prose p + p { margin-top: 1.05em; }
.prose strong { color: var(--text); font-weight: 620; }
.prose ul, .prose ol { margin: 1em 0; padding-left: 1.3em; }
.prose li { margin-bottom: .45em; }
.prose ul { list-style: none; padding-left: 0; }
.prose ul > li {
  position: relative; padding-left: 22px;
}
.prose ul > li::before {
  content: ''; position: absolute; left: 2px; top: .62em;
  width: 6px; height: 6px; border-radius: 2px;
  background: var(--accent); opacity: .75;
}

/* --- buttons -------------------------------------------------------------- */

.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 9px;
  padding: 13px 22px;
  border: 1px solid transparent; border-radius: var(--radius-sm);
  font-size: 14.5px; font-weight: 600; letter-spacing: .005em;
  cursor: pointer; white-space: nowrap;
  transition: background-color .16s ease, border-color .16s ease,
              color .16s ease, transform .16s ease;
}
.btn:active { transform: translateY(1px); }

.btn--primary {
  background: var(--accent); color: #04121b; border-color: var(--accent);
}
.btn--primary:hover { background: var(--accent-hi); border-color: var(--accent-hi); color: #04121b; }

.btn--ghost {
  background: transparent; color: var(--text); border-color: var(--line-strong);
}
.btn--ghost:hover { border-color: var(--accent); color: var(--accent-hi); background: rgb(56 189 248 / .07); }

.btn--sm { padding: 9px 16px; font-size: 13.5px; }

.btn-row { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 32px; }

/* --- header --------------------------------------------------------------- */

.site-header {
  position: sticky; top: 0; z-index: 50;
  background: rgb(8 11 17 / .82);
  backdrop-filter: blur(14px);
  border-bottom: 1px solid var(--line);
}
.site-header__inner {
  display: flex; align-items: center; gap: 32px;
  height: 66px;
}

.brand { display: inline-flex; align-items: center; gap: 11px; color: var(--text); }
.brand:hover { color: var(--text); }
.brand__mark {
  width: 22px; height: 22px; border-radius: 6px; flex: none;
  background:
    linear-gradient(135deg, var(--bid) 0 50%, transparent 50% 100%),
    linear-gradient(315deg, var(--ask) 0 50%, transparent 50% 100%),
    var(--bg-elev-2);
  box-shadow: inset 0 0 0 1px rgb(255 255 255 / .1);
}
/* ⚠️ .brand__mark above is STILL IN USE — the admin panel's header and its login
   page both set it. It is no longer used on the public site, where the real
   logo replaced it, but deleting the rule would strip the mark off /admin. */

/* The supplied logo is a white cut with two small coloured wedges, so it needs
   no plate or background of its own — it is already drawn for --bg. Height is
   set and width left auto so the 2.021 aspect the artwork actually has decides
   the width, rather than a hardcoded pair that would squash it if the file is
   ever regenerated at a different size. */
.brand__logo { height: 30px; width: auto; flex: none; display: block; }

/* 🔑 THE MODIFIER EXISTS SO /admin IS NOT DRAGGED ALONG. .brand is shared: the
   admin header and its login page still use it with .brand__mark, a 22px
   square. Baseline-aligning that square and closing the gap to 3px would be
   wrong there, so the two rules below hang off .brand--logo and only the public
   site and the customer portal carry it.

   align-items: baseline is doing the real work. An image's baseline IS its
   bottom edge, so this sets the foot of the mark on the text baseline of
   "Market" — which is what the owner asked for, and it is also why "Market"
   lands at roughly half the mark's height without anyone choosing a ratio.
   It only works because the artwork was trimmed to its ink: with the original
   17px of empty rows below the letters, the mark would float. */
.brand--logo { align-items: baseline; gap: 0; }

/* "Market" tucks into the air under the F's overhanging arm.
 *
 * Measured off the artwork rather than nudged by eye: the F's top arm reaches
 * 100% of the mark's width, but in the band where "Market" actually sits — the
 * lower third, because it shares the baseline — the ink stops at 76.6%. So
 * 23.4% of the width is empty. The value below is carried as a FRACTION OF THE
 * MARK'S WIDTH, not as a pixel count, because the mark's height has already
 * moved three times and a pixel value silently stops meaning the same thing
 * each time. At the 30px height the mark is 60.4px wide, and 13.9% of that is
 * 8.4px — which starts the M about 52px from the logo's left edge, part way out
 * of the recess rather than jammed against the stem at 46.3px.
 *
 * ⚠️ IF THE HEIGHT MOVES AGAIN, recompute from 13.9% of the rendered width.
 *
 * 🧪 Verified as clearance, not as a guess: measured against the M's real cap
 * band (baseline up by its 11px cap height) and its true left side bearing, the
 * M's ink clears the F's ink by about 4.5px. An earlier check said "collides"
 * because it used the text BOX top, which reaches high enough to catch the F's
 * middle bar — the wrong band for the question.
 *
 * ⚠️ gap cannot be negative in flexbox, so this is a negative margin. And the
 * number is tied to the 34px mark height above — change that and this has to be
 * recomputed from the same 23.4%.
 *
 * 🔴 THREE CLASSES ON PURPOSE. theme-e5.css is generated and loads after this
 * file, and it already overrides the weight here to 650. A two-class selector
 * would lose to it silently, which is the failure this site has hit six times.
 * Verified by reading the computed style, not by reading this file. */
/* 400, not 350: Inter here is loaded as static faces, not a variable font —
   measured by rendering "Market" at every hundred and comparing widths, where
   100, 200 and 300 all came out identical at 290.3px and 400 was the first
   change at 311.33px. So 400 IS the smallest step up from 300 that exists;
   anything between them would round back to one of the two. */
.brand.brand--logo .brand__name {
  margin-left: -8.4px;
  font-weight: 400;
}

.brand__name { font-weight: 700; font-size: 17px; letter-spacing: -.025em; }
.brand__name-dim { color: var(--text-faint); font-weight: 500; }

/* "Market" sits beside the mark rather than under it. It moves from
   --text-faint up to --text-dim here for one reason: the faint grey was chosen
   to sit against the word ICF set in --text, and the mark that replaced it is
   pure white. Against white the old value read as a different weight of
   information rather than as the second half of a name. */
.brand .brand__name-dim { color: var(--text-dim); }

.site-nav { display: flex; gap: 26px; margin-left: auto; }
.site-nav a {
  color: var(--text-dim); font-size: 14.5px; font-weight: 500;
  padding-block: 6px;
  border-bottom: 1px solid transparent;
}
.site-nav a:hover { color: var(--text); border-bottom-color: var(--accent); }
.site-nav a[aria-current="page"] { color: var(--text); border-bottom-color: var(--accent); }

.site-header__actions { display: flex; gap: 10px; }

@media (max-width: 900px) {
  .site-nav { display: none; }
  .site-header__actions { margin-left: auto; }
}

/* --- breadcrumb ------------------------------------------------------------
   Deliberately quiet. It is orientation, not navigation: a visitor who arrived
   on a deep page from a search result needs to know where they landed, and the
   parent link is the internal link a crawler follows upward. Neither job wants
   it competing with the page's own heading.

   No bottom border and no background: the hero and every .section already
   carry their own top edge, and a second rule above them read as a stray line.
   The separators are content-free, so they are drawn with ::before rather than
   typed into the markup, where a screen reader would announce them.

   To remove the whole feature, delete the render line in layouts/main.php;
   these rules then match nothing and are harmless. */

/* Kept as thin as it can be while staying legible. The strip is 8px of padding
   plus one 19px line; there is nothing else in it to remove. */
.crumbs { padding-block: 8px 0; }

.crumbs__list {
  display: flex; flex-wrap: wrap; align-items: center;
  gap: 0 8px;
  margin: 0; padding: 0; list-style: none;
  font-size: 12px; font-family: var(--mono);
}

/* The strip only LOOKED thick because of what sits under it: theme-e5.css gives
   .hero 12vw of top padding — 154px at this width — so the trail floated alone
   in a tall empty band. Trimming the trail alone would not have fixed that.
   Pulling the following section up is what makes it read as attached to the
   header instead of as its own bar.

   🔴 Two class selectors on purpose. theme-e5.css is GENERATED by
   tools/gen_orbit2_bg.php and loads AFTER this file, so a plain `.hero` here
   would lose. Specificity wins where order cannot — and the generated file must
   never be hand-edited, because the next run of that script overwrites it. */
.crumbs + .hero    { padding-top: clamp(44px, 6.5vw, 104px); }
.crumbs + .section { padding-top: clamp(32px, 4.5vw, 56px); }

.crumbs__item { display: flex; align-items: center; gap: 8px; min-width: 0; }

.crumbs__item + .crumbs__item::before {
  content: '›';
  color: var(--text-faint);
  /* The glyph sits high in its box at this size; nudging it is cheaper than
     swapping in an SVG for one character. */
  line-height: 1;
}

.crumbs a { color: var(--text-faint); }
.crumbs a:hover { color: var(--accent-hi); }

/* Truncated, because the last crumb is the only one that can be long: article
   titles run to sixty characters and turned the trail into a three-line
   paragraph on a phone. Only the rendering is shortened — the full title stays
   in the DOM and in the BreadcrumbList, which is the copy Google reads. */
.crumbs__current {
  color: var(--text-dim);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  /* Wide enough that a real title is never clipped on a desktop — the longest
     article title is 58 characters — and narrow enough that nothing can run the
     trail off the page. */
  max-width: 72ch;
}

@media (max-width: 600px) {
  .crumbs { padding-block: 14px 0; }
  .crumbs__list { font-size: 12px; flex-wrap: nowrap; }
  .crumbs__current { max-width: none; }
}

/* --- hero ----------------------------------------------------------------- */

.hero {
  position: relative;
  padding-block: clamp(56px, 9vw, 112px) clamp(48px, 7vw, 88px);
  overflow: hidden;
  border-bottom: 1px solid var(--line);
}

/* A footprint chart, drawn in two gradients. Costs nothing to download and says
   what the product is before a single word is read. */
.hero::before {
  content: '';
  position: absolute; inset: 0;
  background-image:
    linear-gradient(to right, var(--line) 1px, transparent 1px),
    linear-gradient(to bottom, var(--line) 1px, transparent 1px);
  background-size: 46px 30px;
  mask-image: radial-gradient(120% 90% at 78% 12%, #000 0%, transparent 68%);
  -webkit-mask-image: radial-gradient(120% 90% at 78% 12%, #000 0%, transparent 68%);
  opacity: .55;
  pointer-events: none;
}
.hero::after {
  content: '';
  position: absolute; top: -28%; right: -12%;
  width: 62%; height: 128%;
  background:
    radial-gradient(48% 42% at 50% 42%, rgb(56 189 248 / .17), transparent 70%),
    radial-gradient(38% 34% at 66% 68%, rgb(52 211 153 / .10), transparent 72%);
  filter: blur(8px);
  pointer-events: none;
}

.hero > .wrap { position: relative; z-index: 1; }

.hero__grid {
  display: grid;
  grid-template-columns: minmax(0, 1.32fr) minmax(0, .68fr);
  gap: clamp(32px, 5vw, 68px);
  align-items: start;
}

.hero h1 { margin-bottom: 22px; max-width: 20ch; }
.hero h1 em {
  font-style: normal;
  background: linear-gradient(100deg, var(--accent-hi), var(--bid));
  -webkit-background-clip: text; background-clip: text; color: transparent;
}

.hero__lede { font-size: clamp(16px, 1.5vw, 17.5px); display: grid; gap: 1em; }

.hero__aside {
  background: linear-gradient(180deg, var(--bg-elev-2), var(--bg-elev));
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 26px;
  box-shadow: var(--shadow-lg);
}
.hero__aside h2 {
  font-size: 12px; font-family: var(--mono); font-weight: 600;
  letter-spacing: .13em; text-transform: uppercase;
  color: var(--text-faint); margin-bottom: 18px;
}
.hero__list { list-style: none; display: grid; gap: 2px; }
.hero__list li {
  display: flex; align-items: center; gap: 12px;
  padding: 11px 12px; border-radius: var(--radius-sm);
  font-size: 14.5px; font-weight: 550; color: var(--text);
  transition: background-color .16s ease;
}
.hero__list li:hover { background: rgb(255 255 255 / .035); }
/* A row that links somewhere keeps the row's look — the whole row is already a
   hover target, the link just makes the promise real. */
.hero__list-link { color: inherit; text-decoration: none; }
.hero__list li:hover .hero__list-link { color: var(--accent-hi); }
.hero__list li::before {
  content: ''; flex: none;
  width: 7px; height: 16px; border-radius: 2px;
  background: linear-gradient(180deg, var(--bid), var(--accent-dim));
  opacity: .85;
}

@media (max-width: 880px) {
  .hero__grid { grid-template-columns: 1fr; }
  .hero h1 { max-width: none; }
}

/* --- the generated hero backdrop ------------------------------------------ */

/* Only on a primary page, and only when web/img/hero-art/<slug>.svg exists —
   see HeroScope. Deleting that folder is the whole rollback.

   ⚠️ It sits UNDER .hero::before and ::after, not instead of them: the grid and
   the glow are what tie the band to the rest of the site, and replacing them
   would make /products look like a different template. */
/* 🔴 THREE LAYERS, AND ONLY THE ROOM IS CROPPED. The band is a fixed height at
   every width, so an instrument drawn inside the wide room image either sits far
   enough in to survive a 1280 laptop — leaving a dead margin on a wide monitor —
   or hugs the edge for the monitor and falls off the laptop. Measured: closing
   the margin at 2560 needs the profile 506px further left, which is entirely
   outside the window a 1280 screen sees.

   So the two instruments are their own files, sized by HEIGHT and pinned to the
   edges. They touch the edge at every width and never change size; the room
   behind them is still `cover`. Layer order is front-to-back. */
.hero__art {
  position: absolute;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
  z-index: 0;
  opacity: .95;
  background-image:
    var(--hero-art-left2, none), var(--hero-art-right2, none),
    var(--hero-art-mid2, none), var(--hero-art-top2, none),
    var(--hero-art-left, none), var(--hero-art-right, none),
    var(--hero-art);
  /* 🔑 ONE PER SIDE, EACH CENTRED IN ITS OWN GAP — the offsets are solved, not
     nudged, because `background-position` resolves a percentage against
     (container − image) rather than the container:

       left gap   434 .. W/2 − 306   middle W/4 + 64    25% lands W/4 − 46.25  -> +18
       right gap  W/2 + 306 .. W − 355   middle 0.75W − 24.5   75% lands 0.75W − 138.75  -> +22

     The detector sits high in its canvas and the footprint low, so they keep
     the heights the owner asked for wherever they are placed. */
  /* ⚠️ THE FILE NAMES NO LONGER MATCH THE SIDES. `-left2` holds the footprint and
     is positioned at 75%, `-right2` holds the detector at 25% — they swapped
     when the owner moved them and the names were left alone rather than break
     every cached URL. The comment is the fix; do not "correct" the percentages. */
  background-position:
    calc(75% + 22px) center, calc(25% + 18px) center,
    calc(60% + 23px) center, calc(54.06% + 358px) center,
    left center, right center,
    center center;
  background-size: auto 100%, auto 100%, auto 100%, auto 100%, auto 100%, auto 100%, cover;
  background-repeat: no-repeat;
}

/* 🔑 THE GRID'S POSITION IS SOLVED FROM TWO WIDTHS, NOT ONE. It has to clear the
   headline at 1920 AND land where the owner put it at 2560, and those fix the
   percentage and the offset between them:

     0.5406 x 2375 + 358 = 1642   ->  ink 1652 at 2560, the marked spot
     0.5406 x 1735 + 358 = 1296   ->  ink 1306 at 1920, 40px clear of the type

   It clears the footprint VERTICALLY (art 211..315 against 366..552) rather
   than horizontally, which is why the two can share a column at all. */

/* 🔴 THE FLOOR INSTRUMENTS NEED A GAP WIDER THAN THEY ARE, AND BELOW ~1900px
   THERE ISN'T ONE.

   WITH ONE PER SIDE THE **LEFT** GAP IS THE BINDING ONE — it is the narrower
   of the two (the pinned profile eats 434px of it) and it is what sets the
   threshold. Measured against the detector's actual ink, 152px, not its 185px
   canvas:

     2560   left gap 540px  ->  178px of air either side
     1920   left gap 220px  ->  34px either side, measured
     1900   left gap 210px  ->  29px, the floor
     1850   left gap 185px  ->  16px, too tight to read as spacing
     1800   left gap 160px  ->  the detector no longer fits

   ⚠️ This threshold has moved with every version of the artwork: 2149 when the
   panels were small, 2359 when they were sheared to lie parallel to the floor
   (which makes an object far wider than tall), 2099 upright, and 1899 now that
   the detector is scaled to 0.78 and one sits per side. It is DERIVED from the
   ink and the gap, never chosen — re-derive it if either changes. Rather than
   let them slide under the
   type they are switched off, and the hero keeps the composition it already
   had. `background-size: 0 0` drops a layer without disturbing the order of the
   four that remain. */
/* 🔑 The tape is NOT in this list. It is short and wide and lives below the
   headline's last line, where the band stays 700px clear even at 1920 — so it
   survives widths that leave no room beside the type. */
@media (max-width: 1899px) {
  .hero__art { background-size: 0 0, 0 0, auto 100%, 0 0, auto 100%, auto 100%, cover; }
}

/* --- the cosmic bolt ------------------------------------------------------- */

/*
 * 🔴 CSS ONLY, NO SCRIPT. The site's zero-JS property is deliberate — the whole
 * starfield and nebula are gradients for exactly this reason — so a strike every
 * fifteen seconds is one animation on a 15s loop, not a timer.
 *
 * The cycle, in seconds of the 10:
 *
 *     0.00 - 6.20   nothing
 *     6.30          strike. Bolt at full, the art jumps bright and violet
 *     6.39 - 6.48   the flicker a real strike has
 *     7.15          the art is back to normal      <- 0.85s lit, under the 1s asked for
 *     9.30          the bolt has decayed to nothing <- 3s from peak, as asked
 *
 * ⚠️ THE PERCENTAGES ARE SECONDS OVER THE DURATION, so changing 15s to 10s meant
 * recomputing every one of them — the two constants that must not move are the
 * 0.85s of light on the objects and the 3s of decay, and both are expressed as
 * fractions of a cycle that just got shorter. This is the second time; if the
 * period changes again, redo the arithmetic rather than nudging the numbers.
 */
@keyframes icf-bolt {
  0%, 62%   { opacity: 0; }
  63%       { opacity: 1; }
  63.9%     { opacity: .32; }
  64.8%     { opacity: .88; }
  93%       { opacity: 0; }
  100%      { opacity: 0; }
}

/* The violet is the home page's nebula colour (rgb 126 106 214, hue ~253) and
   cyan sits at ~189, so +65deg lands the art on it. Brightness carries the
   "lit by the strike" part; the hue carries the colour the owner asked for. */
@keyframes icf-flash {
  0%, 62%   { filter: none; }
  63%       { filter: brightness(2.4) saturate(1.35) hue-rotate(65deg); }
  64.8%     { filter: brightness(1.7) saturate(1.25) hue-rotate(65deg); }
  71.5%     { filter: none; }
  100%      { filter: none; }
}

.hero__bolt {
  position: absolute;
  inset: 0;
  z-index: 1;                 /* over the art, under .hero > .wrap which is 2 */
  pointer-events: none;
  opacity: 0;
  background: var(--hero-bolt) no-repeat center center;
  background-size: cover;     /* the same crop as the room, so they align */
  mix-blend-mode: screen;     /* a bolt adds light; it never darkens */
  animation: icf-bolt 10s linear infinite;
}
/* 🔴 `.hero--bolt`, NOT `.hero--art`. The brightness pulse is the strike lighting
   the room, so it belongs to pages that HAVE a strike. The education backdrop has
   no bolt file and was inheriting a 2.4x flash every ten seconds with nothing on
   screen to cause it — which reads as a fault, not as weather. hero.php sets this
   class only when the bolt file exists. */
.hero--bolt .hero__art { animation: icf-flash 10s linear infinite; }

/* ⚠️ A flash is exactly the kind of motion that hurts people. Both animations
   stop, and the bolt is left invisible rather than frozen mid-strike. */
@media (prefers-reduced-motion: reduce) {
  .hero__bolt { animation: none; opacity: 0; }
  .hero--art .hero__art { animation: none; filter: none; }
}

/* 🔴 NOTHING ANIMATES ON A PHONE. Measured on this desktop the strike costs
   nothing — median frame 16.7ms during the flash against 16.7ms idle, zero
   frames over 20ms — but that is one machine with a real GPU, and a phone was
   never measured and cannot be from here. On a small screen the band is short
   enough that the bolt barely reads anyway, so the whole class of risk is
   dropped rather than guessed at.
   `display: none` rather than `animation: none`: it also takes away the
   `mix-blend-mode: screen` layer, which is the part that forces the backdrop to
   be re-blended. 880px is the hero's own existing breakpoint. */
@media (max-width: 880px) {
  .hero__bolt { display: none; }
  .hero--art .hero__art { animation: none; filter: none; }
  /* The backdrop itself stays: it is the band's whole surface on a phone, and
     `cover` keeps the centre of the composition, which is where the light is. */
}

/* ⚠️ Below this width the pinned instruments would reach under the headline —
   at 1280 the profile ends at 407px and the h1 starts at 334. Nudged out until
   the band is wide enough, which is also where the dead margin appears. Both
   numbers measured in the browser, not chosen. */
@media (max-width: 1599px) {
  .hero__art {
    background-position:
      calc(75% + 22px) center, calc(25% + 18px) center,
      calc(60% + 23px) center, calc(70% - 21px) center,
      left -110px center, right -80px center,
      center center;
  }
}

/* --- /products: two detail panels lifted out of the fade ------------------ */

/*
 * 🔴 THIS IS A CONSEQUENCE OF THE BOTTOM FADE, NOT A COMPOSITION CHANGE. The
 * fade added further down this file takes .hero__art to zero by 80% of the
 * band, and two of the four detail panels are drawn at the very bottom of their
 * 200x560 canvases — so the fade erased them. Measured, in hero pixels on a
 * 517px band, with the mask's own alpha at each object's top, middle and base:
 *
 *   products-mid2    ink y 416..498    alpha 0    / 0 / 0     gone entirely
 *   products-left2   ink y 350..485    alpha 0.28 / 0 / 0     all but gone
 *   products-right2  ink y 171..286    alpha 1    / 1 / .75   unaffected
 *   products-top2    ink y 194..252    alpha 1    / 1 / .92   unaffected
 *
 * 🔑 A GENTLER FADE COULD NOT HAVE SAVED THEM. mid2's ink occupies 80% to 96%
 * of the band on its own — it IS the bottom of the picture. Any fade that
 * clears the band's lower edge erases it. So the two move up instead, and only
 * the two that were hit: right2 and top2 are left exactly where they were.
 *
 * ⚠️ THE OFFSET HAS TO BE IN PIXELS, NOT PER CENT. These layers are sized
 * `auto 100%`, so the image height EQUALS the container height — and a
 * percentage in background-position resolves against (container − image), which
 * is zero here. Every percentage gives the identical result; only a length
 * moves them. That is why `calc(50% - 150px)` reads oddly and is correct.
 *
 * ⚠️ REPEATED FOR THE NARROW CASE because background-position is a list and
 * the media query above replaces the whole list. Only the two Y values differ
 * from the rules above; the X values are copied so the nudge those widths need
 * is not lost.
 */
body[data-page="products"] .hero__art {
  background-position:
    calc(75% + 22px) calc(50% - 150px), calc(25% + 18px) center,
    calc(60% + 23px) calc(50% - 170px), calc(54.06% + 358px) center,
    left center, right center,
    center center;
}

@media (max-width: 1599px) {
  body[data-page="products"] .hero__art {
    background-position:
      calc(75% + 22px) calc(50% - 150px), calc(25% + 18px) center,
      calc(60% + 23px) calc(50% - 170px), calc(70% - 21px) center,
      left -110px center, right -80px center,
      center center;
  }
}
/* 🔴 NO `mix-blend-mode: screen` HERE, THOUGH THE FIRST VERSION HAD IT. It was
   right for the flat art, whose black ground had to disappear. The room is a
   lit interior with real shadow, and `screen` cannot darken — it lifted every
   black to grey, so the deep end of the corridor and the unlit faces of the
   boxes all washed out to the same flat slate and the depth went with them.
   The art now paints normally and brings its own darkness. */

/* The headline crosses the middle. This only has to take the top off the light
   at the far end — the corridor glow is the best thing in the picture and an
   opaque scrim over it threw the whole image away to protect type that was
   already legible. Verified against the rendered page, not assumed. */
.hero__art::after {
  content: '';
  position: absolute;
  inset: 0;
  background:
    radial-gradient(42% 54% at 50% 52%, rgb(3 4 7 / .62) 0%, rgb(3 4 7 / .26) 58%, transparent 78%),
    linear-gradient(180deg, rgb(3 4 7 / .32) 0%, transparent 22%, transparent 78%, rgb(3 4 7 / .40) 100%);
}

/* --- /education and /products: no hard edge under the band ---------------- */

/*
 * 🔑 ONE SET OF RULES FOR BOTH PAGES, NOT TWO NEAR-IDENTICAL BLOCKS. They hit
 * the same defect for the same reason and the numbers below were checked
 * against both geometries before the selectors were joined — this file's own
 * comments say more than once that copying a block into a second selector is
 * how a stylesheet grows rules that slowly disagree.
 *
 *                        /education      /products
 *   hero height             549             517
 *   copy ends at            461             409
 *   .hero::before        h 824, top -165  h 776, top -155   clip 86.7% on BOTH
 *   ::before zero at 72%    y 428           y 404      both above the copy end
 *   .hero__art zero at 80%  y 439           y 414      "
 *   .hero fade, last 44px   from 505        from 473   44px / 64px of clearance
 *
 * The two clip ratios are identical because one shared rule sizes the glow on
 * every page; that is also why one number can serve both.
 *
 * ⚠️ The TURQUOISE rules below are /education only — the owner asked for that
 * page's colour, not this one's.
 */

/*
 * 🔴 THE ART WAS NEVER BLUE — THE GLOW OVER IT WAS. Measured before changing
 * anything: hero-art/education.svg is drawn entirely in hues 183..195, which is
 * cyan through teal. What sits on top of it is the shared hero glow, and that
 * is blue and violet:
 *
 *   .hero::before   rgb(198 214 255)  hue 220     rgb(97 134 232)  hue 223
 *                   rgb(126 106 214)  hue 252  <- violet
 *   .hero::after    rgb(169 205 245)  hue 211     (this one is --accent)
 *
 * So the band read blue even though the picture in it did not, and the fix is
 * to restate those two layers in turquoise for this page only.
 *
 * ⚠️ SCOPED, NOT GLOBAL. `.hero::before` and `.hero::after` are on every hero
 * on the site, and --accent (#a9cdf5) is what the whole site is tuned against —
 * it is deliberately untouched here, exactly as it has been everywhere else.
 */
body[data-page="education"] .hero::before {
  background-image:
    radial-gradient(22% 30% at 50% 30%, rgb(168 240 250 / .22), transparent 62%),
    radial-gradient(34% 26% at 38% 36%, rgb(52 190 205 / .20), transparent 68%),
    radial-gradient(30% 24% at 63% 33%, rgb(64 168 190 / .17), transparent 70%),
    radial-gradient(46% 34% at 50% 26%, rgb(26 96 118 / .16), transparent 72%),
    radial-gradient(60% 42% at 50% 18%, rgb(12 34 44 / .50), transparent 76%);
}

/*
 * 🔴 THE BOTTOM GLOW IS OFF ENTIRELY ON THIS PAGE, NOT RECOLOURED AND NOT
 * MASKED. It was both of those first and the owner circled the band again, and
 * the reason is in the layer's own definition: `.hero::after` is
 * `radial-gradient(60% 100% at 50% 100%, …)` on a box that ends 2px below the
 * hero — so its BRIGHTEST point is exactly the bottom edge of the band. It
 * exists to put light there. Masking it only dims the thing whose entire job is
 * the tint being complained about; the honest answer is that this page does not
 * want a bottom glow at all.
 *
 * ⚠️ Scoped, so every other hero keeps its own. `display: none` rather than a
 * transparent background, so nothing composites at all.
 */
body[data-page="education"] .hero::after,
body[data-page="products"] .hero::after,
body[data-page="about-us"] .hero::after {
  display: none;
}

/*
 * 🔴 THE EDGE THE OWNER ARROWED AT. `.hero__art` is `inset: 0` inside a hero
 * that is `overflow: hidden`, so the backdrop stops dead at the hero's bottom
 * and the band ends in a straight horizontal line across the full width. The
 * scrim above only darkens it to .40 alpha, which reduces the step without
 * removing it — the picture is still 60% present at the last pixel.
 *
 * 🔑 SAME LESSON AS THE LINE UNDER THE HOME PAGE HERO, WHICH TOOK FOUR ROUNDS:
 * an edge is not fixed by darkening what is on either side of it, only by
 * taking the alpha to zero AT the cut. So the layer itself fades out over its
 * last 26%, and there is nothing left at the boundary to draw a line.
 *
 * ⚠️ THE TYPE IS SAFE, BUT NOT FOR THE REASON IT LOOKS LIKE. The fade begins
 * 407px into a 549px hero and the lede's last line ends at 461 — measured — so
 * it does overlap the text vertically. It cannot dim it: `.hero__art` is an
 * empty aria-hidden div and the copy lives in `.hero > .wrap`, which
 * `.hero--art > .wrap` puts at z-index 2 as a SIBLING. Masking a sibling has no
 * effect on the type at all. Written down because "the fade starts below the
 * text" was the first explanation and it is false — the next person moving
 * this number should not rely on it.
 */
/*
 * 🔴 EVERY FADE HERE IS EASED, NOT LINEAR, AND SPREAD OVER AS MUCH ROOM AS THE
 * LAYER ALLOWS. The first version used one 76px linear ramp on the hero and the
 * owner circled the result: the fade had "gathered" into a band. Two reasons,
 * and both are worth knowing:
 *
 *   1. TWO MASKS WERE MULTIPLYING. .hero__art faded from 407px and .hero from
 *      473px, so between 473 and 549 BOTH were falling and the combined curve
 *      was far steeper there than anywhere else. Overlapping masks do not add,
 *      they multiply — which is exactly how you manufacture a band.
 *   2. A LINEAR ALPHA RAMP HAS A CORNER AT EACH END. The eye finds the point
 *      where alpha stops being 1 far more easily than it follows a gradient.
 *      The stops below trace a smoothstep (.844 / .5 / .156 at the quarters),
 *      so there is no knee to find.
 *
 * 🔑 AND THE LONG FADES LIVE ON THE LAYERS WITH NO TEXT IN THEM. .hero__art,
 * .hero::before and .hero::after are decoration, so their fades can start
 * halfway up the band. .hero contains the copy, so its own fade cannot start
 * above the lede's last line at 461px — which is why it is now the SHORTEST of
 * the four rather than the one doing the work.
 */
/* The picture, on the hero's own box, so a percentage here IS a percentage of
   the band. Zero by 80% = y 439, in step with the glow above: the whole lower
   fifth of the hero carries no decoration, which is the band in question. */
body[data-page="education"] .hero__art,
body[data-page="products"] .hero__art,
body[data-page="about-us"] .hero__art {
  -webkit-mask-image: linear-gradient(to bottom,
    #000 0%, #000 44%,
    rgb(0 0 0 / .844) 53%, rgb(0 0 0 / .5) 62%, rgb(0 0 0 / .156) 71%, transparent 80%);
          mask-image: linear-gradient(to bottom,
    #000 0%, #000 44%,
    rgb(0 0 0 / .844) 53%, rgb(0 0 0 / .5) 62%, rgb(0 0 0 / .156) 71%, transparent 80%);
}

/*
 * 🔴 /about-us ONLY: the drawing is desaturated at render time rather than
 * redrawn. The owner asked for the band in greyscale; measured, the file is
 * plainly blue — mean RGB 6.3 / 12.9 / 19.1 with peak channel spread 116.
 *
 * A CSS filter rather than a new palette in tools/gen_about_art.php because
 * that generator is 30 KB of hand-placed colour and this is one reversible
 * line — and because "greyscale it" is exactly what the filter does, with no
 * chance of a hand-converted palette drifting from the original's tonality.
 * If the page ever wants grey ART rather than a grey RENDERING, the generator
 * is the place, and that is a bigger, reviewable change.
 *
 * ⚠️ Filter runs BEFORE mask in the rendering order, so this composes cleanly
 * with the fade above rather than fighting it.
 */
body[data-page="about-us"] .hero__art {
  filter: grayscale(1);
}

/*
 * 🔴 AND THE GLOW UNDERNEATH IT, WHICH THE FILTER ABOVE NEVER TOUCHED. The
 * owner saw "a blue light first, then the grey hero arrives" on /about-us and
 * it was not a flash of the wrong stylesheet — it was two facts meeting:
 *
 *   1. `.hero::before` is a SIBLING of `.hero__art`, not a child of it, so
 *      `filter: grayscale(1)` on the art could never reach it. Measured after
 *      that change shipped, it was still hues 223 / 224 / 251 / 227 / 230 —
 *      blue, and one of them violet.
 *   2. hero-art/about-us.svg is 201 KB, the heaviest asset on the site, and
 *      takes 155ms even from localhost. The glow is a CSS gradient with nothing
 *      to download, so it paints immediately and the grey art lands on top of
 *      it a moment later.
 *
 * So the blue was never a flash — it is permanent, and it was simply being
 * covered up once the picture arrived. Greying it removes the cause rather than
 * racing the symptom.
 *
 * 🔑 THE VALUES ARE THE LUMINANCES OF THE ORIGINALS, not greys picked by eye —
 * 0.2126R + 0.7152G + 0.0722B, which is exactly what grayscale(1) computes. So
 * the glow and the artwork are desaturated by the same rule and cannot drift
 * apart in tone. Geometry and alphas are untouched.
 *
 *   rgb(198 214 255) -> 214      rgb(97 134 232) -> 133
 *   rgb(126 106 214) -> 118      rgb(58 78 150)  -> 79
 *   rgb(20 26 56)    -> 27
 */
body[data-page="about-us"] .hero::before {
  background-image:
    radial-gradient(22% 30% at 50% 30%, rgb(214 214 214 / .22), transparent 62%),
    radial-gradient(34% 26% at 38% 36%, rgb(133 133 133 / .20), transparent 68%),
    radial-gradient(30% 24% at 63% 33%, rgb(118 118 118 / .17), transparent 70%),
    radial-gradient(46% 34% at 50% 26%, rgb(79 79 79 / .16), transparent 72%),
    radial-gradient(60% 42% at 50% 18%, rgb(27 27 27 / .50), transparent 76%);
}

/*
 * The big soft glow: 824px tall against a 549px hero, sitting 164.8px above it,
 * so a percentage of ITS height maps to the band as  y = −164.8 + p × 824.2.
 *
 * 🔴 IT NOW REACHES ZERO AT 72%, WHICH IS y = 428 — NOT AT THE CLIP. The first
 * pass faded it to nothing by 86%, which is y = 544 on a 549px hero, i.e. it
 * was still fading right down to the edge. That satisfied the arithmetic (no
 * alpha at the cut) and still left exactly what the owner objected to: colour
 * lying across the bottom of the band. Zero at 428 leaves the last 121px of the
 * hero with no glow on it at all, which is what "remove the colour from this
 * part" actually asks for.
 *
 * 🔑 The number is derived from the complaint, not chosen: the circled band is
 * the hero's last ~90px, and 428 clears it with 30px to spare.
 */
body[data-page="education"] .hero::before,
body[data-page="products"] .hero::before,
body[data-page="about-us"] .hero::before {
  -webkit-mask-image: linear-gradient(to bottom,
    #000 0%, #000 36%,
    rgb(0 0 0 / .844) 45%, rgb(0 0 0 / .5) 54%, rgb(0 0 0 / .156) 63%, transparent 72%);
          mask-image: linear-gradient(to bottom,
    #000 0%, #000 36%,
    rgb(0 0 0 / .844) 45%, rgb(0 0 0 / .5) 54%, rgb(0 0 0 / .156) 63%, transparent 72%);
}

/* (the bottom glow is switched off above) */
body[data-page="education"] .hero::after {
  -webkit-mask-image: linear-gradient(to bottom,
    #000 0%, #000 55%,
    rgb(0 0 0 / .844) 65%, rgb(0 0 0 / .5) 75%, rgb(0 0 0 / .156) 85%, transparent 94%);
          mask-image: linear-gradient(to bottom,
    #000 0%, #000 55%,
    rgb(0 0 0 / .844) 65%, rgb(0 0 0 / .5) 75%, rgb(0 0 0 / .156) 85%, transparent 94%);
}

/*
 * 🔴 AND THE SAME AGAIN ONE LEVEL UP, BECAUSE THE ART WAS NOT THE ONLY LAYER.
 * Masking .hero__art removed the picture's own edge and the owner said the line
 * was still there — correctly. Measured, three things cross the hero's bottom:
 *
 *   .hero__art     ends exactly at the edge   masked above
 *   .hero::after   hangs   2px below          clip at 99.1% of it, mask NONE
 *   .hero::before  hangs 110px below          clip at 86.7% of it, mask NONE
 *                  and it carries blur(46px) + opacity .95
 *
 * That 86.7% is not a coincidence: it is the identical figure the home page's
 * hero produced, because the geometry is the same rule on both. And the home
 * page is where this was settled the expensive way — masking the two
 * pseudo-elements is NOT sufficient. A blurred, semi-transparent layer forced
 * onto its own composited layer leaves a hairline where an ancestor clips it,
 * whatever the alpha inside that layer reads. Only taking the alpha to zero AT
 * the cut, one level up, removes it.
 *
 * ⚠️ THIS MASKS THE TYPE TOO, unlike the three rules above — the copy is inside
 * .hero. It is safe by measurement, not by luck: the lede's last line ends
 * 461px into a 549px hero, and the fade starts at 505px. 44px of clearance.
 * If the hero's copy ever grows, this number has to shrink with it.
 *
 * 🔑 IT IS NOW THE SHORTEST FADE OF THE FOUR, DELIBERATELY. Its only job is the
 * hairline the composited layer leaves at the clip; the visible softening is
 * done above, on the layers that have no text and can start halfway up the
 * band. Making this one long is what produced the gathered band the owner
 * circled.
 */
body[data-page="education"] .hero,
body[data-page="products"] .hero,
body[data-page="about-us"] .hero {
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 44px),
    rgb(0 0 0 / .844) calc(100% - 33px), rgb(0 0 0 / .5) calc(100% - 22px),
    rgb(0 0 0 / .156) calc(100% - 11px), transparent 100%);
          mask-image: linear-gradient(to bottom, #000 calc(100% - 44px),
    rgb(0 0 0 / .844) calc(100% - 33px), rgb(0 0 0 / .5) calc(100% - 22px),
    rgb(0 0 0 / .156) calc(100% - 11px), transparent 100%);
}
.hero--art > .wrap { position: relative; z-index: 2; }

/* --- the compact head, for pages that are not in the header menu ---------- */

/* 🔴 THIS IS A DEMOTION, NOT A DELETION. The h1 is inside the hero on all 33
   pages, so the block has to stay — it just stops behaving like a landing band.
   Measured on /support at 1440x900 before this existed: 312px tall, 35% of the
   viewport, 182px of it padding, to show one 56px word and a partner badge.

   What is given up is decoration only: the badge (in the view), the grid, the
   glow, and the block's own backdrop image. The h1, the lede and the buttons
   are untouched, which is the whole point — see HeroScope. */
/* 🔴 EVERY SELECTOR HERE IS DOUBLED — `.hero.hero--compact`, not
   `.hero--compact`. theme-e5.css loads AFTER this file and restyles plain
   `.hero` at single-class weight: same specificity, later in source, so it
   wins. The first version of this block was written single-class and measured
   93.6px of padding and `text-align:center` — the theme's numbers, not mine,
   with nothing in the console to say so. Two classes (0,2,0) beat it.
   Same trap as `.section.checkout` further down. */
.hero.hero--compact {
  padding-block: clamp(30px, 3.4vw, 48px) clamp(20px, 2.4vw, 32px);
  text-align: left;
}
/* The nebula and the horizon glow are the front-door treatment. theme-e5
   redefines both pseudo-elements, so these need the doubled class too. */
.hero.hero--compact::before,
.hero.hero--compact::after { display: none; }

/* Left, not centred. A centred title over left-aligned body copy was the most
   visible thing wrong with a sub-page: the h1 floated in the middle of the band
   with the first paragraph starting hard left underneath it. */
.hero.hero--compact .hero__grid {
  justify-items: start;
  gap: clamp(22px, 2.8vw, 40px);
  align-items: start;
}
.hero.hero--compact h1 {
  font-size: clamp(28px, 3.4vw, 40px);
  line-height: 1.14;
  letter-spacing: -.02em;
  margin-bottom: 12px;
  margin-inline: 0;
  max-width: 26ch;
}
.hero.hero--compact .hero__lede {
  font-size: clamp(15px, 1.35vw, 16.5px);
  margin-inline: 0;
}
.hero.hero--compact .hero__lede p { max-width: 74ch; }
.hero.hero--compact .btn-row { margin-top: 20px; justify-content: flex-start; }

/* The eyebrow becomes the page's kicker instead of a caption under a logo. */
.hero.hero--compact .hero__cred { margin-bottom: 8px; }
.hero.hero--compact .eyebrow { margin: 0; }

/* ⚠️ The aside is deliberately NOT restyled. theme-e5 turns it into the
   indicator strip — one card, hairline dividers — and that is content on the
   pages that have it (a lesson's contents, the pack's includes). Overriding its
   padding here broke that strip's internal spacing, so it is left alone. */

/* The section that follows a compact head pulls up to meet it. theme-e5 gives
   every .section 112px of top padding, which is the right rhythm under a
   landing band and far too much under a 40px page title — measured 112px of
   empty space between "SUPPORT" and the first heading.
   ⚠️ Scoped to the sibling of a compact head only, so the rhythm of every other
   section on the site is untouched. Three classes, to beat theme-e5's one. */
.hero.hero--compact + .section { padding-block-start: clamp(26px, 3.2vw, 52px); }

@media (max-width: 880px) {
  .hero.hero--compact h1 { max-width: none; }
}

/* --- content section ------------------------------------------------------ */

.content__inner { display: grid; gap: 26px; }
.content--panel .content__inner {
  background: var(--bg-elev);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: clamp(26px, 4vw, 46px);
}
.content--accent .content__inner {
  border-left: 2px solid var(--accent);
  padding-left: clamp(20px, 3vw, 34px);
}

/* A panel whose text fills it, instead of stopping at the measure cap and
   leaving the right half of the card empty. See the note further down about the
   `columns` variant that was removed: THIS is the other half of that lesson —
   `columns` never used more width, and this does, by taking the cap off.
 *
 * 🔴 TWO CLASSES, AND THAT IS THE WHOLE REASON IT WORKS. theme-e5.css sets
 * `.prose { max-width: 70ch }` and LOADS AFTER this file, so a one-class rule
 * here loses on source order no matter what it says — measured 603.75px on
 * /about-us, which is 70ch and not the 72ch this file appears to set. `.content--wide
 * .prose` is 0-2-0 against theme-e5's 0-1-0, so it wins on SPECIFICITY and the
 * load order stops mattering. Verify with getComputedStyle, never by reading
 * the rule: three separate rules lost this way on 2026-08-10.
 *
 * The extra leading is not decoration. At ~130 characters the return sweep is
 * the thing that fails — the eye loses which line comes next — and leading is
 * what buys that back. 1.6 is right for a 70ch column and too tight for this
 * one. */
.content--wide .prose {
  max-width: none;
  line-height: 1.75;
}

/* 🔴 A STACK OF CARDS NEEDS CARD RHYTHM, NOT SECTION RHYTHM — AND FORGETTING
   THAT MADE THE PAGE LONGER, NOT SHORTER. Widening the text on /about-us cut
   the number of lines, and the page still grew by 112px, because seventeen
   sections each kept `padding-bottom: 112px` on top of the card's own 46px:
   204px of nothing between one card's last line and the next card's first. That
   spacing is right when a section is a full-bleed band of the page and the gap
   IS the separator. Once the card draws its own edge, the gap is doing a job
   something else is already doing.

   Only between two cards. The last one still needs air before whatever follows,
   which is why this is `:has(+ ...)` and not a flat override.

   ⚠️ Degrades safely: a browser without :has() ignores the rule and gets the old
   roomier spacing, never a broken layout.

   The second selector is the same argument for a stack of FOLDED cards, which is
   the shape /ninjatrader/indicators became. It is additive on /about-us: a wide
   card followed by a wide card already matched the first selector, and the value
   is the same one, so nothing there moves. */
.content--wide:has(+ .content--wide),
.content--collapsible:has(+ .content--collapsible) {
  padding-bottom: 28px;
}

/* --- a wide card that folds ----------------------------------------------- */

/* No script. <details>/<summary>, the same pair .faq__item and .manual__group
   already use, which is also why the text is still in the markup when the card
   is shut and a crawler still reads it.

   🔴 THREE CLASSES ON THE PADDING RULE, ON PURPOSE. theme-e5.css restyles
   `.content--panel .content__inner` (0-2-0) and loads after this file, so a rule
   at the same weight here would lose on source order. 0-3-0 cannot. Everything
   the theme SHOULD keep owning — fill, hairline, blur — is deliberately not
   repeated here.

   🔑 KEYED ON `panel`, NOT ON `wide`, so it covers `fold` too — `wide` emits
   `content--panel content--wide`, so every wide card is also a panel and this one
   selector reaches both variants at the same 0-3-0 it needed before. */
.content--panel.content--collapsible .content__inner {
  padding: clamp(15px, 1.9vw, 21px) clamp(19px, 2.5vw, 28px);
}
.content--collapsible .content__inner { transition: border-color .18s ease; }
.content--collapsible .content__inner:hover,
.content--collapsible .content__inner[open] { border-color: var(--line-strong); }

.content__toggle {
  display: flex; align-items: flex-start; gap: 18px;
  cursor: pointer; list-style: none;
  grid-column: 1 / -1;
}
.content__toggle::-webkit-details-marker { display: none; }

/* 🔑 A FOLDED CARD IS A ROW, NOT A SECTION HEADING. These are h2 and stay h2 —
   the outline must not change with the fold — but at the section size of up to
   38px, fourteen shut cards are a wall of type taller than the text they hide,
   which is the opposite of the point. Sized into the same family as the two
   accordions already on the site (.faq__q 15.5px, .manual__name 17.5px), a
   little larger because this row is the full width of the page. */
.content--collapsible .content__toggle h2 {
  margin: 0;
  font-size: clamp(16.5px, 1.45vw, 19px);
  line-height: 1.35;
  font-weight: 600;
  letter-spacing: -.01em;
}

/* The marker is a + that turns into an ×, which is what theme-e5 gives .faq__q.
   Written out here rather than borrowed by putting .faq__q on the summary,
   because that class also carries its own padding and font size and would fight
   the card. ⚠️ If the theme's marker language ever changes, both move together. */
.content__toggle::after {
  content: '+';
  flex: none; margin-left: auto;
  font-size: 21px; line-height: 1.15; font-weight: 400;
  color: var(--text-faint);
  transition: transform .25s ease, color .25s ease;
}
.content__inner[open] > .content__toggle::after {
  transform: rotate(45deg);
  color: var(--accent);
}

/* Shut, the card is only its summary and the grid gap has nothing to space. */
.content--collapsible .content__inner:not([open]) { gap: 0; }

/* The text column keeps the gap the inner grid used to provide, so a section
   with no pictures renders exactly as it did before the media column existed. */
.content__text { display: grid; gap: 26px; align-content: start; }

.content--media .content__inner {
  grid-template-columns: minmax(0, 1.02fr) minmax(0, 1fr);
  gap: clamp(28px, 4vw, 52px);
  align-items: start;
}
/* A guard, not a layout decision. The magnifier is anchored to the outer edge
   so the arithmetic says it always lands inside the wrap — but that arithmetic
   depends on the column ratio above, and the day someone changes it the failure
   would be a horizontal scrollbar on every screen. `clip` on one axis leaves the
   other free, so the picture can still grow upward and downward. */
.content--media { overflow-x: clip; }
/* The hero's pack list scrolls here; without a margin the heading lands flush
   against the viewport edge. */
.content--media { scroll-margin-top: 24px; }
.content--media-left .content__text  { order: 2; }
.content--media-left .content__media { order: 1; }

/*
 * 🔴 THE RATIO HAS TO FOLLOW THE ROLE, NOT THE POSITION.
 *
 * The tracks are 1.02fr and 1fr — the text column is meant to be the wider one.
 * `order` moves the ITEMS but not the TRACKS, so on a media-left section the
 * picture landed in the 1.02 track and the text in the 1fr one: the ratio
 * silently inverted.
 *
 * Measured on the homepage, where two sections face each way: the same four
 * figures drew at 529x397 and 518x389 — an 11px difference in a set the owner
 * asked to be identical, and one that no amount of adjusting the pictures could
 * have fixed because it was never about the pictures.
 *
 * Swapping the track order for media-left restores the intent: text always 1.02,
 * picture always 1, whichever side each is on.
 */
.content--media-left .content__inner {
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.02fr);
}

.content__media { display: grid; gap: 22px; align-content: start; }

.content__sub { color: var(--accent-hi); font-weight: 600; font-size: 17px; }

/* --- screenshots, with a CSS-only magnifier ------------------------------- */

/* The zoom is a transform on hover and nothing else. The site ships no
   JavaScript, and a lightbox is the usual reason that promise gets broken —
   for "let me read the labels" a scale-up does the whole job. */

.shot { margin: 0; position: relative; z-index: 1; }
.shot__img {
  display: block; width: 100%; height: auto;
  border: 1px solid var(--line-strong); border-radius: var(--radius-sm);
  background: var(--bg-inset);
}
.shot__caption {
  margin-top: 10px;
  font-size: 13.4px; line-height: 1.55; color: var(--text-faint);
}
.shot__hint { display: none; }

@media (hover: hover) and (min-width: 1040px) {
  .shot--zoom .shot__img {
    transform-origin: center right;
    transition: transform .3s cubic-bezier(.2, .7, .3, 1),
                border-color .3s ease, box-shadow .3s ease;
  }
  /* Anchored to the right edge so the picture grows into the text column and
     never past the wrap — a centre origin would push it off-screen and hand
     the page a horizontal scrollbar. */
  .shot--zoom:hover { z-index: 40; }
  .shot--zoom:hover .shot__img,
  .shot--zoom:focus-within .shot__img {
    /* 🔴 1.3, NOT 2. This was scale(2) — a screenshot doubling in size the
       moment the pointer crossed it, which on the product page threw a settings
       window over most of the copy beside it and looked like a modal opening by
       accident rather than a magnifier. The owner measured it off a photograph
       of the screen and asked for about 130%.
       At 1.3 the labels in a settings screenshot are still readable, and the
       picture stays inside its own half of the column instead of taking the
       page. The full-size view is a click away and unaffected. */
    transform: scale(1.3);
    border-color: var(--accent);
    box-shadow: var(--shadow-lg);
  }
  .content--media-left .shot--zoom .shot__img { transform-origin: center left; }

  .shot__hint {
    display: inline;
    margin-left: 8px; padding-left: 8px;
    border-left: 1px solid var(--line-strong);
    font-family: var(--mono); font-size: 10.5px; letter-spacing: .1em;
    /* 🔴 WAS --accent-dim, WHICH MEASURED 2.79:1 ON THE PUBLIC GROUND — 195 of
       these on 25 pages, the single largest contrast fault on the site. It is
       an affordance ("CLICK TO ENLARGE"), so it has to be read, and it sits
       inside .shot__caption, so it must not be brighter than the caption. */
    text-transform: uppercase; color: var(--accent-quiet);
  }
}

@media (prefers-reduced-motion: reduce) {
  .shot--zoom .shot__img { transition: none; }
}

@media (max-width: 1039px) {
  .content--media .content__inner { grid-template-columns: minmax(0, 1fr); }
  .content--media-left .content__text  { order: 1; }
  .content--media-left .content__media { order: 2; }
}

/* --- hero backdrop -------------------------------------------------------- */

/* The three profiles, lifted off their charts, as a texture BEHIND the hero
   copy — not a picture above it. The image is the profiles baked on black;
   `screen` blending makes black a no-op, so the page's own background and
   nebula stay visible and only the profiles themselves show through. The copy
   sits on top in its own stacking layer.

   The pulse is the light the owner asked for: a slow breath, profiles catching
   a glow that rises from below, never fully off and never loud. */

.hero--backdrop { position: relative; }
.hero--backdrop > .wrap { position: relative; z-index: 2; }

.hero__backdrop {
  position: absolute; inset: 0; z-index: 1;
  overflow: hidden;
  pointer-events: none;
}
.hero__backdrop-img {
  position: absolute; left: 50%; bottom: -4%;
  width: max(1400px, 100%); height: auto;
  transform: translateX(-50%);
  mix-blend-mode: screen;
  opacity: .34;
  animation: hero-breathe 9s ease-in-out infinite;
}

/* The owner's spec for the breath: floor at .2, and a wider throw upward. */
@keyframes hero-breathe {
  0%, 100% { opacity: .2;  filter: brightness(.88); }
  55%      { opacity: .62; filter: brightness(1.22); }
}

@media (prefers-reduced-motion: reduce) {
  .hero__backdrop-img { animation: none; opacity: .38; }
}

/* --- click to enlarge: a CSS-only overlay --------------------------------- */

/* Charts open full-screen on click. Built on :target, so it costs no
   JavaScript — the same reason the manual below is a stack of <details>. */

.shot__open { display: block; position: relative; cursor: zoom-in; border-radius: var(--radius-sm); }
.shot__open::after {
  content: '';
  position: absolute; inset: 0;
  border-radius: var(--radius-sm);
  box-shadow: inset 0 0 0 1px transparent;
  transition: box-shadow .2s ease;
  pointer-events: none;
}
.shot__open:hover::after,
.shot__open:focus-visible::after { box-shadow: inset 0 0 0 2px var(--accent); }

.lightbox { display: none; }
.lightbox:target {
  display: grid;
  grid-template-rows: minmax(0, 1fr) auto;
  gap: 14px;
  position: fixed; inset: 0; z-index: 200;
  padding: clamp(14px, 3vmin, 42px);
  background: rgb(4 7 12 / .95);
  overscroll-behavior: contain;
}
.lightbox__backdrop { position: absolute; inset: 0; cursor: zoom-out; }
.lightbox__sheet { position: relative; display: grid; place-items: center; min-height: 0; }
.lightbox__img {
  max-width: 100%; max-height: 100%;
  width: auto; height: auto;
  border: 1px solid var(--line-strong); border-radius: var(--radius-sm);
  background: var(--bg-inset);
}
.lightbox__bar {
  position: relative;
  display: flex; gap: 20px; flex-wrap: wrap;
  align-items: baseline; justify-content: space-between;
}
.lightbox__cap { font-size: 13.6px; line-height: 1.55; color: var(--text-dim); max-width: 88ch; }
.lightbox__close {
  flex: none;
  font-family: var(--mono); font-size: 11px; letter-spacing: .12em; text-transform: uppercase;
  color: var(--text-dim); text-decoration: none;
  border: 1px solid var(--line-strong); border-radius: var(--radius-sm);
  padding: 9px 16px;
}
.lightbox__close:hover { color: var(--accent-hi); border-color: var(--accent); }

/* --- previous / next within an ordered set -------------------------------- */

/* Sits under the CTA band. `margin-left:auto` on the next link keeps it against
   the right edge on the first page of a set, where there is no previous link to
   push it there. */

/* 🔴 A `.content--columns` RULE LIVED HERE AND WAS REMOVED, 2026-08-10. It put
   `columns: 2` on the section prose, which sounds like "use the width" and is
   not: `.prose` is capped at 72ch, so all it did was split the left half of the
   page into two narrow columns and leave the right half as empty as before.
   Using the full width means two BLOCKS side by side — which is what card-grid
   already does — not a column count on one narrow block. */

/* --- the card that closes a lesson ---------------------------------------- */

/* 🔴 TWO CLASSES ON BOTH RULES, AND IT HAS TO BE. `.cta-band` and `.section` are
   both set in theme-e5.css, which is GENERATED and loads after this file — a
   single-class override here loses silently, which is the same trap the compact
   hero and the education search box each hit. `.cta-band.cta-band--lesson` and
   `.section.section--lesson-end` outrank it.

   The owner's note: too much empty space above and below the card, and the
   heading too loud for what it is. A lesson ending is a signpost, not a pitch —
   the button is the call to action and the heading only has to name the next
   lesson. */

.section.section--lesson-end { padding-block: clamp(20px, 3vw, 40px) clamp(28px, 4vw, 52px); }

.cta-band.cta-band--lesson { padding: clamp(24px, 3.2vw, 36px) clamp(26px, 3.6vw, 42px); }

/* Grey, not white. It is the quietest heading on the page on purpose. */
.cta-band.cta-band--lesson h2 {
  color: var(--text-dim);
  font-size: clamp(20px, 2.2vw, 26px);
  margin-bottom: 8px;
}
.cta-band.cta-band--lesson p { color: var(--text-faint); max-width: 62ch; }

/* --- previous / next within an ordered set (continued) -------------------- */

/* 🔴 THESE TWO ARE DELIBERATELY LOUDER THAN THE REST OF THE PAGE FURNITURE.
   Owner's instruction, 2026-08-10: on a lesson the next link is the single most
   likely thing a reader wants, and the first version was two grey boxes under a
   filled CTA button — quieter than the "ask us" call above them, which is the
   wrong way round for a twelve-part series. They now carry an accent edge, an
   accent direction label and a chevron that moves on hover.

   ⚠️ The CTA band above them keeps the solid button, so the two are still
   distinguishable at a glance: filled = the thing to do, edged = where to go
   next. Making both solid would put two primary actions side by side. */

.lesson-nav {
  display: flex;
  gap: 14px;
  margin-top: 22px;
  flex-wrap: wrap;
}
.lesson-nav__link {
  position: relative;
  display: grid;
  gap: 4px;
  flex: 1 1 240px;
  min-width: 0;
  padding: 16px 20px;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-sm);
  background:
    linear-gradient(180deg, rgb(56 189 248 / .07), rgb(56 189 248 / .02)),
    var(--bg-elev);
  text-decoration: none;
  overflow: hidden;
  transition: border-color .15s ease, background .15s ease, transform .15s ease;
}

/* The accent edge, on the side the link points. It is the cheapest way to make
   the pair read as a direction rather than as two identical boxes. */
.lesson-nav__link::before {
  content: '';
  position: absolute;
  top: 0; bottom: 0;
  width: 3px;
  background: linear-gradient(180deg, var(--accent), var(--accent-dim));
}
.lesson-nav__link--prev::before { left: 0; }
.lesson-nav__link--next::before { right: 0; }

.lesson-nav__link:hover,
.lesson-nav__link:focus-visible {
  border-color: var(--accent);
  background:
    linear-gradient(180deg, rgb(56 189 248 / .14), rgb(56 189 248 / .05)),
    var(--bg-elev);
  outline: none;
}
/* A 2px nudge toward where the link goes. Enough to feel, small enough that it
   cannot reflow anything beside it. */
.lesson-nav__link--prev:hover, .lesson-nav__link--prev:focus-visible { transform: translateX(-2px); }
.lesson-nav__link--next:hover, .lesson-nav__link--next:focus-visible { transform: translateX(2px); }

.lesson-nav__link--next { margin-left: auto; text-align: right; }

.lesson-nav__dir {
  font-family: var(--mono); font-size: 10.5px; letter-spacing: .14em; text-transform: uppercase;
  color: var(--accent-hi);
}
.lesson-nav__title { font-size: 15px; font-weight: 600; color: var(--text); line-height: 1.35; }
.lesson-nav__link:hover .lesson-nav__title,
.lesson-nav__link:focus-visible .lesson-nav__title { color: var(--accent-hi); }

/* ⚠️ Motion and the edge are separate concerns. A reader who has asked for less
   motion still wants to see which way the link points, so only the nudge goes. */
@media (prefers-reduced-motion: reduce) {
  .lesson-nav__link { transition: none; }
  .lesson-nav__link--prev:hover, .lesson-nav__link--prev:focus-visible,
  .lesson-nav__link--next:hover, .lesson-nav__link--next:focus-visible { transform: none; }
}

@media (max-width: 560px) {
  .lesson-nav__link { flex-basis: 100%; }
  .lesson-nav__link--next { margin-left: 0; }
}

/* --- a section's own grid of screenshots ---------------------------------- */

/* Sits under both columns of a content section, so it reads as belonging to
   the whole section rather than to the drawn figure beside the prose. The
   drawn figure explains the idea; these show it happening. */

.thumbs {
  margin-top: 18px;
  display: grid;
  /* auto-FILL, not auto-fit. auto-fit collapses the empty tracks, so a section
     with a single picture stretches it across the whole column while a section
     with three shows them small — the inconsistency that made the one-picture
     sections look wrong. auto-fill keeps the track width, so one picture is the
     same size as one of three. */
  grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
  gap: 14px;
}
/* One picture, one wider track. The rule above reserves three tracks in the
   546px media column and a lone thumbnail took one of them — 171px, which is
   enough for a chart and not enough for a dense table. 260px gives two tracks
   instead of three, so the picture roughly doubles without filling the column
   edge to edge, which is the stretched look auto-fit produced and that was
   rejected when this grid was built. */
.thumbs--single { grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
.thumbs__item { margin: 0; min-width: 0; }

.thumbs__open {
  display: block;
  position: relative;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-sm);
  overflow: hidden;
  /* His captures are on white and the candles in them are dark, so the card
     stays white rather than being tinted to match the page. */
  background: #fff;
  cursor: zoom-in;
  transition: transform .12s ease, border-color .12s ease;
}
.thumbs__open:hover,
.thumbs__open:focus-visible {
  transform: translateY(-2px);
  border-color: var(--accent);
  outline: none;
}
.thumbs__open img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 10;
  object-fit: contain;
}

.thumbs__item figcaption {
  margin-top: 9px;
  font-size: 12.5px;
  line-height: 1.5;
  color: var(--text-faint);
}

@media (prefers-reduced-motion: reduce) {
  .thumbs__open { transition: none; }
  .thumbs__open:hover, .thumbs__open:focus-visible { transform: none; }
}

/* --- one constellation per lesson ----------------------------------------- */
/* Drawn from real star positions by tools/gen_constellations.php and pointed at
   by --sky, which views/layouts/main.php sets from the lesson slug.

   🔴 THIS RULE IS HERE AND NOT IN theme-e5.css BECAUSE THAT FILE IS GENERATED —
   its own header says to edit tools/gen_orbit2_bg.php instead, and a hand edit
   would be lost on the next run. Nothing in theme-e5.css targets body::after,
   so loading after this file costs nothing.

   body::before is the starfield at z-index 0 and main is at z-index 1, so
   ::after slots between them: above the stars, under every word. */
body[data-sky]::after {
  content: '';
  position: fixed;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  background-image: var(--sky);
  background-repeat: no-repeat;
  /* Right of centre and high, so it sits in the margin beside the text column
     rather than behind the paragraph a reader is in. */
  background-position: 78% 22%;
  background-size: min(1180px, 92vw) auto;
  /* The figure is already drawn faint. This is the second dimmer, and it is the
     one to change if it ever competes with the copy. */
  opacity: .5;
}

/* Below the two-column break the text runs full width, so there is no margin
   left to hide in — centre it and take it down further. */
@media (max-width: 980px) {
  body[data-sky]::after {
    background-position: 50% 16%;
    background-size: 150vw auto;
    opacity: .34;
  }
}

/* --- one nebula per fundamental lesson ------------------------------------ */

/* The counterpart of the constellation above, for the other branch. Same slot —
   body::before is the starfield at z-index 0, main is at 1, so ::after sits
   between them: above the stars, under every word.

   🔴 A CLOUD NEEDS DIFFERENT NUMBERS FROM LINE ART, and reusing the
   constellation's would have been wrong on all three. It covers far more of the
   viewport, so the opacity is lower. It has no edges to place, so it is larger
   and closer to centre. And it is light rather than lines, so it screens.

   `mix-blend-mode: screen` is right here and was WRONG on the hero backdrop —
   the difference is what the layer contains. The hero art is a lit room with
   real shadow, and screen cannot darken, so it lifted every black to grey. A
   nebula is emission: it only ever adds light, which is exactly what screen
   does, and it stops the transparent parts of the SVG flattening the starfield
   behind them into a grey panel. */
body[data-nebula]::after {
  content: '';
  position: fixed;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  background-image: var(--nebula);
  background-repeat: no-repeat;
  background-position: 60% 34%;
  /* 🔴 max(), NOT min(). `min(1560px, 116vw)` made the image NARROWER than a wide
     monitor — measured on the owner's 1862px screen it ran 205..1765 and both of
     its sides were on screen as visible edges. The mask inside the SVG is the
     real fix, but the box should not be presenting a boundary in the first
     place: max() keeps the image wider than the viewport at every width, so its
     sides are always off-screen and only the faded middle is ever seen. */
  background-size: max(1900px, 132vw) auto;
  /* The clouds are already drawn faint and the layer opacities inside the SVG
     do most of the work. This is the one number to change if it ever competes
     with the copy — it is deliberately below the constellation's .5, because a
     nebula fills the space a constellation leaves empty. */
  opacity: .34;
  mix-blend-mode: screen;
}

@media (max-width: 980px) {
  body[data-nebula]::after {
    background-position: 50% 14%;
    background-size: 210vw auto;
    opacity: .26;
  }
}

/* --- the same cloud, lying along the bottom instead ------------------------ */

/* /about-us. A lesson is one column of prose with empty space beside it, which
   is where those clouds sit; this page is a stack of full-width cards with no
   such gap, so the only room left is under them.
 *
 * 🔴 AFTER THE RULES ABOVE, NOT BEFORE. `body[data-nebula-at="low"]` and
 * `body[data-nebula]` weigh exactly the same — one element, one attribute — so
 * this wins on source order alone and moving it up the file would silently turn
 * it off. A media query does not add weight either, which is why the 980px
 * variant has to be repeated here rather than inherited.
 *
 * Slightly fainter than the lesson placement: at the foot of the viewport it
 * overlaps the footer, which is already the darkest band on the page, and .34
 * there read as fog rather than as sky. */
/* 🔴 BOTH NUMBERS CAME OUT OF A PIXEL MEASUREMENT, AND THE OBVIOUS ONE WAS WRONG.
   `background-position: 50% 100%` alone looks like the answer and is not: the
   image is 1900px wide and `auto` makes it 1357 tall against a 900px viewport,
   so bottom-aligning it shows the middle of the cloud and hides the part that
   was drawn low. Sampled by row, that put 10.6 units of light across the TOP
   third and 1.9 across the bottom — the exact opposite of the brief, and it
   would have shipped looking deliberate.

   Measured over five candidates by drawing the file into a canvas and reading
   mean luminance x alpha per tenth of the viewport:

     h=auto, 100%          top 10.59   bottom  1.88     <- the obvious one
     h=auto, 100%+230px    top  6.20   bottom 13.06
     h=95%,  100%          top  1.03   bottom  5.84
     h=80%,  100%+140px    top  0.00   bottom 12.59     <- this
                           bands 0 0 0 0 .6 4.3 11.1 9.2 16.0 12.6

   Constraining the HEIGHT is what does it: the cloud is no longer tall enough to
   reach the headline, and the push carries its own faded bottom edge off-screen
   so the glow is still rising where the footer starts instead of dying just
   above it.

   🔴 ENLARGED 2026-08-11 ON THE OWNER'S ASK, AND THE PUSH HAD TO GROW WITH IT.
   Size and position are ONE setting here, not two: every extra 10% of height
   lifts the cloud's top edge 10% of the viewport further up the page, so
   scaling without pushing is how a bottom glow turns into a wash behind the
   headline. Measured across four sizes, each with its push re-solved:

     1900w  80%h +140    top 0.00   bottom 12.72   total  54.4   (was)
     2800w 110%h +250    top 0.01   bottom 17.31   total  83.5
     3200w 125%h +320    top 0.12   bottom 18.56   total  93.3   <- this
     3600w 140%h +390    top 0.41   bottom 19.33   total 101.6   too far

   3600 is where the top third stops being zero and the hero starts to haze. At
   3200 the image's top edge lands at y=95 on a 900px viewport, so the first
   tenth of the page cannot contain any of it at all — that is a geometric
   guarantee, not a measurement that might drift. */
/* 🔑 TUNABLE PER PAGE SINCE 2026-08-13, for the same reason `feature` is: a
   second page arrived (/education) wanting this placement at a different size
   and strength, and copying the block into a near-identical selector is how a
   stylesheet ends up with rules that slowly disagree. The defaults below ARE
   the values /about-us had, so it is untouched — verified by reading its
   computed style before and after, not by inspection.

   ⚠️ `scale` multiplies BOTH axes here, unlike `feature` where the height is
   `auto`. This placement sets height as a percentage of the viewport, so
   scaling only the width would stretch the cloud instead of enlarging it. */
body[data-nebula-at="low"]::after {
  background-size: calc(max(3200px, 132vw) * var(--nebula-scale, 1)) calc(125% * var(--nebula-scale, 1));
  background-position: 50% calc(100% + 320px);
  opacity: var(--nebula-op, .30);
}

@media (max-width: 980px) {
  body[data-nebula-at="low"]::after {
    background-size: calc(280vw * var(--nebula-scale, 1)) calc(85% * var(--nebula-scale, 1));
    background-position: 50% calc(100% + 140px);
    /* Scaled from the page's own value rather than repeated, so a page that
       asked for more light on the desktop gets proportionally more here. .24/.30
       is the ratio the two literals had. */
    opacity: calc(var(--nebula-op, .30) * .8);
  }
}

/* --- the same cloud, present rather than hinted at ------------------------- */

/*
 * A third placement, for a product page — /ninjatrader/indicators/…-pack, 2026-08-13.
 *
 * 🔴 IT EXISTS BECAUSE `low` MEASURED **ZERO** THERE. Not faint: zero. Sampled
 * the way the low placement itself was solved — the file drawn into a canvas,
 * mean luminance x alpha per tenth of the viewport — `low` on a 1280x720 screen
 * returned 0.0 in all ten bands. `low` sizes the image at 125% of the viewport
 * and pushes it 320px below the fold, and the Iris carries its mass at cy≈0.70
 * of its own canvas, so on a short viewport every lit pixel lands past the
 * bottom edge. The owner saw a plain starfield and said so.
 *
 * ⚠️ SO THIS IS NOT "low WITH MORE OPACITY". Raising opacity on nothing is
 * still nothing. What was wrong was the geometry.
 *
 * Measured over four candidates on 1280x720, total light and — the number that
 * decides it — the mean landing on the TEXT column, 74..620, because `screen`
 * only ever ADDS light and this page sets light type on a dark ground:
 *
 *                            total   on the text column
 *   low (what was there)        0            0.00      <- invisible
 *   site default 60%/34%       32            5.22
 *   38%/34% op .46             45            5.74
 *   30%/38% op .44             47            4.77
 *   25%/45% op .45 x1.6        57            4.57      <- this
 *
 * The winner is the brightest overall AND the gentlest on the reading column,
 * which is not a coincidence: moving the cloud left and down puts its core in
 * the margin below the prose rather than behind it, so the light that reaches
 * the page lands where there are no words.
 *
 * 🔴 AFTER the base rule and after `low`, for the same reason `low` is: all
 * three selectors weigh (0,2,0) and source order is the only thing separating
 * them. Moving this block up the file silently turns it off.
 */
/*
 * 🔑 TUNABLE PER PAGE, AND ONE RULE FOR ALL OF THEM. Three pages share this
 * placement and two of them wanted it at different strengths — /products 15%
 * brighter, /faqs larger. The alternative was a second and third near-identical
 * selector, which is how a stylesheet grows four rules that slowly disagree.
 * The layout writes `--nebula-op` / `--nebula-scale` on <body> only when a page
 * asks for one; the fallbacks below are the shared default.
 */
body[data-nebula-at="feature"]::after {
  background-size: calc(max(2200px, 160vw) * var(--nebula-scale, 1)) auto;
  /* 🔴 Y IS TUNABLE BECAUSE THE CLOUDS ARE NOT THE SAME SHAPE. Iris and M78 are
     diffuse reflection nebulae with their mass near the middle of the canvas, so
     45% lands it on screen. The Southern Ring is a planetary nebula — a torus
     with a hole in it and its light in a ring at cy≈0.71 — and at 45% that ring
     falls below the fold. Measured on /faqs: 45% gave 32, 70% gave 44 at the
     same opacity. A number that depends on the drawing belongs with the page,
     not in the shared rule. */
  /* 🔴 X IS TUNABLE TOO, AND 25% DOES NOT MEAN "A QUARTER ACROSS". For a
     background wider than its container the percentage slides the IMAGE, so 25%
     with a 2860px cloud on a 1280px viewport leaves the drawing's own centre at
     66% of the screen — measured, with 25% of the light in the right-hand eighth
     against 1% in the left. It reads as a glow stuck to one side, which is what
     the owner saw and circled. 50% is what actually centres a wider-than-viewport
     image. */
  background-position: var(--nebula-x, 25%) var(--nebula-y, 45%);
  opacity: var(--nebula-op, .45);
}

@media (max-width: 980px) {
  body[data-nebula-at="feature"]::after {
    background-size: calc(240vw * var(--nebula-scale, 1)) auto;
    background-position: 40% 30%;
    /* Scaled from the same page value rather than repeated: a page that asked
       for more light on the desktop wants proportionally more here, and a
       literal would silently ignore it. */
    opacity: calc(var(--nebula-op, .45) * .67);
  }
}

/* 🗑️ A `below` placement lived here on 2026-08-13, built for an eclipse that was
   drawn for /education and then rejected. It is gone rather than left behind: a
   placement no page uses is dead weight in a file that already has four, and
   the next person would have to work out which pages depend on it. The eclipse
   itself and the reasoning behind its geometry are frozen in
   _backup/education_eclipse_20260813/ if it is ever wanted again.

   🔑 The one part worth keeping in mind: `low` scales NON-UNIFORMLY (its
   background-size takes width from the viewport width and height from the
   viewport height), which is fine for a cloud and wrong for anything with a
   circle in it. That is what forced a separate placement then, and it is why
   the grey dust that replaced the eclipse can use `low` quite happily. */

/* --- the front door: a cloud that sinks as the page scrolls ---------------- */

/*
 * The home page, 2026-08-13. A scroll-driven cloud, and the sixth version of
 * this rule in one day. The history is the useful part:
 *
 *   drift up, 1/10    Measured at exactly 0.1003 — but a tenth of a 12-screen
 *                     page is 1.14 screens of travel, so the cloud crossed the
 *                     whole viewport and ended up behind the headline.
 *   drift up, 1/25    Less travel kept it in the lower half all the way.
 *   position 39%      Started it lower, so its falloff stopped cutting across
 *                     the middle of the last screen.
 *   static + mask     Motion dropped. Held every rule and looked worse for it.
 *   drift DOWN, 1/25  Motion back, direction reversed. 45vh.
 *   drift DOWN, 1/50  Halved on the owner's ask. 22vh.
 *   drift DOWN, 1/100 Halved again. 11vh — the speed that stuck.
 *
 * 🔑 WHY REVERSING IT WAS THE FIX RATHER THAN A PREFERENCE. Every complaint
 * about this layer was the same one — the light ends up too high. Rising motion
 * causes that by construction: wherever the cloud starts, scrolling carries it
 * toward the headline, so the placement has to be tuned to survive the journey.
 * Sinking motion cannot: the cloud is at its highest the moment the page loads,
 * and every pixel of scroll takes it further down and out.
 *
 * 🔴 NO JAVASCRIPT. The site ships none, so a scroll listener was out, and so
 * was the `perspective` + `translateZ` trick — that needs the whole document
 * inside a scrolling container and quietly changes how `position: fixed` behaves
 * on every page. A scroll-driven animation does it in two properties, and a
 * browser without support simply gets the cloud standing still.
 */
body[data-nebula-at="drift"]::after {
  /* ⚠️ REVERTED. This was briefly max(3380px, 247vw) with the height stretched a
     further 30% — bigger and taller on the owner's ask, and it did not work: the
     cloud lost its shape and the extra height only pushed light where it was not
     wanted. Back to the proportional box. The generator still carries the
     opt-in `stretch` flag that made it possible, unused. */
  background-size: calc(max(2600px, 190vw) * var(--nebula-scale, 1)) auto;

  /*
   * ⚠️ SMALLER PERCENTAGE = LOWER CLOUD. The image is taller than the viewport,
   * so background-position slides it rather than placing it.
   *
   * 🔴 IT WAS BRIEFLY 23%, AND THAT WAS A MISREAD RATHER THAN A TUNING. The
   * owner asked for the dust to come UP; 23% put its top at 513px instead of
   * 331px, which is lower, not higher — the percentage runs backwards and it was
   * moved the wrong way. Reverted on their instruction.
   *
   * 🔑 43% / 52% ARE A MEASURED CENTIMETRE, NOT A GUESS. The owner asked for two
   * centimetres up and one to the left, reading a viewport of about 2000x993.
   * A centimetre there is ~34px vertically and ~38px horizontally, and the image
   * is 3800x2714 on that screen, so:
   *
   *   up   2cm  =  68px  /  (2714 - 993)  =  +4.0%   ->  y 39 -> 43
   *   left 1cm  =  38px  /  (3800 - 2000) =  +2.1%   ->  x 50 -> 52
   *
   * Solved on a 1280x720 screen as well and both agree to the nearest percent,
   * so the numbers are not an artefact of one viewport.
   *
   * ⚠️ DO NOT "SOLVE" THIS AGAINST THE HEADLINE. Pixel reasoning done at 720px
   * does not describe what the owner sees on a much taller screen — that is
   * exactly how the 23% mistake happened. Change it only against a screenshot
   * they have sent, and convert through the arithmetic above.
   */
  background-position: var(--nebula-x, 52%) var(--nebula-y, 43%);
  opacity: var(--nebula-op, .42);

  animation: nebula-drift linear both;
  animation-timeline: scroll(root block);
}

/*
 * 🔴 POSITIVE, AND HALVED TWICE. The sign is the direction the owner asked for —
 * down the screen as the page goes down — and 11vh is the speed, after 45vh was
 * halved to 22 and then to 11 across three asks on 2026-08-13.
 *
 * Over this page's 8180px of scroll, 11vh is about 79px of travel: a rate near
 * 1/100. That is deliberately at the edge of noticeable. The cloud no longer
 * reads as something moving; it reads as something the page is sliding past,
 * which is what the last two halvings were reaching for.
 *
 * ⚠️ A minus sign here would not be a typo, it would be the old bug: the whole
 * reason the direction was reversed is that rising motion put the light behind
 * the headline. tests/home_smoke.php asserts the sign and controls for it.
 */
@keyframes nebula-drift {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(0, 11vh, 0); }
}

/* 🔴 MOTION IS OPTIONAL AND THIS ONE IS DECORATIVE. Anyone who has asked their
   system for less motion gets the cloud standing still — not a shorter drift,
   none. The static placement shipped for an hour, so it is known to look fine
   on its own. */
@media (prefers-reduced-motion: reduce) {
  body[data-nebula-at="drift"]::after { animation: none; }
}

@media (max-width: 980px) {
  body[data-nebula-at="drift"]::after {
    background-size: calc(260vw * var(--nebula-scale, 1)) auto;
    background-position: 50% 30%;
    opacity: calc(var(--nebula-op, .42) * .8);
  }
}

/*
 * 🔴 THE HERO'S OWN GLOW ENDS IN THE VERY EDGE IT EXISTS TO HIDE.
 *
 * theme-e.css draws `.hero::before` and `.hero::after` as glows filling the hero
 * box, and `::after` is `radial-gradient(60% 100% at 50% 100%, …)` — a gradient
 * whose CENTRE, and so its brightest point, sits exactly on that box's bottom
 * edge. The box stops there, so the glow is cut at full strength and leaves a
 * horizontal line across the page. Measured: the hero runs 72px to 1329px, and
 * 1329 is where the owner drew an arrow, twice.
 *
 * The comment on the original reads "which is what stops the nebula from ending
 * in a visible edge" — it was added to hide one seam and introduced another.
 *
 * ⚠️ BOTH PSEUDO-ELEMENTS. The first attempt masked `::after` alone and the line
 * survived: `::before` is the larger of the two at opacity .95 and ends on the
 * same edge. Two layers cut on one line look like one line, so fixing half of it
 * fixes nothing you can see.
 *
 * ⚠️ FIXED HERE, NOT IN theme-e.css, ON PURPOSE. theme-e5.css is GENERATED from
 * theme-e.css and loads AFTER this file, so an equal-weight rule here would lose
 * silently — the trap that has cost this project four rounds. This selector is
 * (0,2,1) against the theme's (0,1,1) and wins on weight regardless of order,
 * and it needs no regeneration of a built file.
 *
 * 🔴🔴 AND THE FIRST TWO MASKS DID NOT WORK, FOR A REASON WORTH WRITING DOWN.
 *
 * `.hero` is `overflow: hidden`. `::before` is 1820px tall against the hero's
 * 1213 — it is `blur(46px)`-ed and deliberately hangs 243px BELOW the box so its
 * glow has somewhere to fade into. The overflow then clips it dead at the hero's
 * bottom edge.
 *
 * So the mask had to reach zero before the CLIP, not before the element's own
 * end, and those are not the same place. Measured: the clip lands at 86.7% of
 * the pseudo-element's height, where a `transparent 97%` mask still had **25%
 * alpha** — a quarter-strength glow cut off in a straight line, which is exactly
 * what the owner kept seeing after two rounds of "fixing" it.
 *
 * 🔑 SO THE NUMBER IS DERIVED, NOT PICKED: fade to nothing by 82%, comfortably
 * inside the 86.7% where the clip falls. The masks are per element because the
 * two sit differently — ::after ends 2px below the hero and was already clear.
 */
body[data-nebula-at="drift"] .hero::before {
  -webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 46%, transparent 82%);
          mask-image: linear-gradient(to bottom, #000 0%, #000 46%, transparent 82%);
}
body[data-nebula-at="drift"] .hero::after {
  -webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 55%, transparent 94%);
          mask-image: linear-gradient(to bottom, #000 0%, #000 55%, transparent 94%);
}

/*
 * 🔴 200px OF NOTHING BETWEEN THE HERO AND THE FIRST SECTION, on the home page
 * only. Measured 2026-08-13: the "Exclusive indicators" box ends at 1241px and
 * the first thing in "01 — What we make" starts at 1441px. The owner circled the
 * gap and asked for the content to come up.
 *
 * It is two paddings from theme-e5.css, not one:
 *
 *   .hero    padding-block: … clamp(48px, 7vw, 88px)   ->  88px here
 *   .section padding-block: clamp(64px, 9vw, 112px)    -> 112px here
 *
 * Both halved below, so the gap closes to about 100px. Halved rather than
 * removed: the two bands are still different things and butting them together
 * reads as a mistake.
 *
 * ⚠️ SCOPED TO THE HOME PAGE, AND ONLY TO THE FIRST SECTION AFTER THE HERO.
 * `.section` is every band on all 49 pages; a global change here would compress
 * the whole site to fix one junction. `.hero + .section` is the junction itself.
 *
 * ⚠️ AND WRITTEN AT (0,2,0)/(0,2,1) BECAUSE theme-e5.css IS GENERATED AND LOADS
 * AFTER THIS FILE. An equal-weight rule would lose silently — the trap that has
 * cost this project four rounds. Same reason as .section.manual-section--inline
 * further up.
 */
body[data-home] .hero {
  padding-bottom: clamp(24px, 3.5vw, 44px);
}
body[data-home] .hero + .section {
  padding-top: clamp(32px, 4.5vw, 56px);
}

/* ⚠️ A large screened layer is the kind of thing that reads as haze rather than
   as sky on a low-contrast display. Anyone who has asked for reduced
   transparency gets the starfield alone. ⚠️ It stays LAST so it also covers the
   low variant — both selectors match the same element. */
@media (prefers-reduced-transparency: reduce) {
  body[data-nebula]::after { display: none; }
}

/* --- film strip: real charts between two sections ------------------------- */

/* A row of his own screenshots, spanning the whole wrap as a horizontal divider
   between two sections. Job split with the drawn figures beside the prose: an
   SVG teaches the idea and carries the labels, a frame here only shows that the
   thing happens on a real chart. So the photographs stay raw and unlabelled.

   Deliberately NOT a .section — theme-e5.css increments a counter on .section
   and prints it before each h2, so wrapping the strip in one would renumber
   every heading below it.

   The overlay is the .lightbox above, unchanged; only the id scheme differs,
   because a strip holds several enlargeable pictures where a section holds one. */

/* Paging inside the overlay. Prev and next are links to the neighbouring
   overlay's id, so they work with the same :target mechanism and no script. */
.lightbox__actions { display: flex; gap: 10px; align-items: center; flex: none; }
.lightbox__count {
  font-family: var(--mono); font-size: 11px; letter-spacing: .1em;
  color: var(--text-faint); min-width: 4.5em; text-align: center;
}
.lightbox__page {
  position: absolute; top: 50%; transform: translateY(-50%);
  z-index: 2;
  display: grid; place-items: center;
  width: 46px; height: 74px;
  font-size: 30px; line-height: 1; text-decoration: none;
  color: var(--text-dim);
  background: rgb(8 12 18 / .62);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-sm);
}
.lightbox__page:hover { color: var(--accent-hi); border-color: var(--accent); background: rgb(8 12 18 / .85); }
.lightbox__page--prev { left: 0; }
.lightbox__page--next { right: 0; }

@media (max-width: 640px) {
  .lightbox__page { width: 38px; height: 60px; font-size: 24px; }
  .lightbox__count { min-width: 3.6em; }
}

.filmstrip-band { margin-block: -18px 6px; }
.filmstrip { margin: 0; }

.filmstrip__row {
  display: flex;
  gap: 10px;
  padding: 10px;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-sm);
  background: var(--bg-inset);
  overflow-x: auto;
  overscroll-behavior-x: contain;
}

.filmstrip__frame {
  position: relative;
  flex: 1 1 0;
  min-width: 96px;
  /* His ratio. Height follows from the shared width, so six frames reach both
     edges of the wrap at every viewport instead of only at one. */
  aspect-ratio: 2.5 / 1;
  display: block;
  border-radius: 3px;
  overflow: hidden;
  /* White stays. His candles are dark and vanish against the page — the frame
     is a photograph on film, and the white is the photograph. */
  background: #fff;
  border: 1px solid #cbd5e1;
  cursor: zoom-in;
  transition: transform .12s ease, border-color .12s ease;
}
.filmstrip__frame:hover,
.filmstrip__frame:focus-visible {
  transform: translateY(-2px);
  border-color: var(--accent);
  outline: none;
}
.filmstrip__frame img {
  width: 100%; height: 100%;
  /* contain, not cover: a cover crop slices the delta pane off the bottom of
     the frames that have one, and those are the frames that prove the point. */
  object-fit: contain;
  display: block;
}

.filmstrip__num {
  position: absolute; left: 3px; top: 2px;
  font: 600 10px/1 var(--mono);
  color: #475569;
  background: rgb(255 255 255 / .82);
  padding: 2px 4px;
  border-radius: 3px;
}

.filmstrip__cap {
  margin: 9px 2px 0;
  font-size: 12.5px;
  line-height: 1.5;
  color: var(--text-faint);
}

@media (max-width: 700px) {
  /* Six frames across a phone would be six grey smudges. Let them keep a
     usable size and scroll the row instead. */
  .filmstrip__frame { flex: 0 0 auto; width: 152px; }
}

@media (prefers-reduced-motion: reduce) {
  .filmstrip__frame { transition: none; }
  .filmstrip__frame:hover, .filmstrip__frame:focus-visible { transform: none; }
}

.checklist { list-style: none; display: grid; gap: 12px; max-width: 72ch; }
.checklist li {
  position: relative; padding-left: 30px; color: var(--text-dim);
}
.checklist li::before {
  content: ''; position: absolute; left: 0; top: .5em;
  width: 14px; height: 8px;
  border-left: 2px solid var(--bid); border-bottom: 2px solid var(--bid);
  transform: rotate(-45deg);
}
.checklist__title { font-weight: 620; color: var(--text); margin-bottom: 4px; }

/* --- feature grid --------------------------------------------------------- */

.features {
  display: grid; gap: 18px;
  grid-template-columns: repeat(auto-fit, minmax(248px, 1fr));
}
.feature {
  background: var(--bg-elev);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 24px;
  transition: border-color .18s ease, transform .18s ease;
}
.feature:hover { border-color: var(--line-strong); transform: translateY(-2px); }
.feature h3 { font-size: 17px; margin-bottom: 9px; }
.feature p  { color: var(--text-dim); font-size: 14.8px; }

/* --- cards ---------------------------------------------------------------- */

.cards {
  display: grid; gap: 18px;
  grid-template-columns: repeat(auto-fit, minmax(258px, 1fr));
  align-items: stretch;
}
.card {
  position: relative;
  display: flex; flex-direction: column;
  background: var(--bg-elev);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 28px 24px;
  transition: border-color .18s ease, transform .18s ease, box-shadow .18s ease;
}
.card:hover { border-color: var(--line-strong); transform: translateY(-3px); box-shadow: var(--shadow); }

.card--featured { border-color: var(--accent-dim); background: var(--bg-elev-2); }
.card--featured::before {
  content: ''; position: absolute; inset: -1px -1px auto; height: 2px;
  border-radius: var(--radius) var(--radius) 0 0;
  background: linear-gradient(90deg, var(--bid), var(--accent), var(--ask));
}

/* 🔴 WAS `.card h3`, AND THE TAG WAS DOING THE WORK OF THE CLASS.
   A card title is <h3> under a titled grid and <h2> under an untitled one, so
   that the heading outline does not skip a level. Keyed on the tag, the h2
   cards silently lost this rule and fell back to `.card__title` below — 17px
   and an 8px margin against 17.5px and 6px here, on one page out of the set.
   Measured before changing it: of 37 <h3> inside .card, all 37 carry
   .card__title and none is anything else, so the selector below matches exactly
   what the old one did and nothing moves. */
.card .card__title { font-size: 17.5px; margin-bottom: 6px; }
.card__price {
  font-family: var(--mono); font-size: 13px; color: var(--accent);
  margin-bottom: 18px;
}
.card__features { list-style: none; display: grid; gap: 9px; margin-bottom: 24px; }
.card__features li {
  position: relative; padding-left: 20px;
  font-size: 14.2px; color: var(--text-dim);
}
.card__features li::before {
  content: ''; position: absolute; left: 0; top: .58em;
  width: 5px; height: 5px; border-radius: 50%;
  background: var(--accent); opacity: .8;
}
.card .btn { margin-top: auto; width: 100%; }

/* --- card grid ---------------------------------------------------------------
   Cards that are a way IN to another page: picture, heading, link. Built on
   .card for the surface, because a second card that looks almost like the first
   is worse than one that looks the same.

   .card carries its own padding — it was written for the pricing card, which
   has no picture — so this modifier moves the padding into the body and lets
   the image reach the edges. */

/* 🔴 Two classes, not one. theme-e5.css is GENERATED by tools/gen_orbit2_bg.php
   and loads AFTER this file, and it sets `padding: 28px 24px` on `.card`. A
   plain `.card--link` here has the same specificity and loses on source order,
   so the picture sat inside 28px of padding. Specificity wins where order
   cannot — and the generated file must never be hand-edited, because the next
   run of that script overwrites it. */
.card.card--link {
  padding: 0;
  overflow: hidden;
  text-decoration: none;
  color: inherit;
}

/* Fixed aspect, so ten cards line up whatever their pictures measure. A grid
   whose rows jog up and down is the thing that makes an index hard to scan. */
.card__media {
  aspect-ratio: 16 / 9;
  background: var(--bg);
  border-bottom: 1px solid var(--line);
  overflow: hidden;
}
.card__media img { width: 100%; height: 100%; object-fit: cover; display: block; }

.card__body { padding: 20px 22px 22px; display: flex; flex-direction: column; flex: 1; }

.card__eyebrow {
  font-family: var(--mono); font-size: 11.5px; letter-spacing: .1em;
  text-transform: uppercase; color: var(--text-faint);
  margin-bottom: 8px;
}

/* Not .card h3: that rule sets a pricing-card size and margin. */
.card__title { font-size: 17px; line-height: 1.35; margin-bottom: 8px; color: var(--text); }
.card.card--link:hover .card__title { color: var(--accent-hi); }

.card__text { color: var(--text-dim); font-size: 14.5px; line-height: 1.55; }

/* Pushed to the bottom so the small print lines up across a row even when the
   descriptions are different lengths. */
.card__meta {
  margin-top: auto; padding-top: 14px;
  font-family: var(--mono); font-size: 12px; color: var(--text-faint);
}

/* auto-fit already stacks these on a phone; the count is an upper bound for
   wide screens, not a fixed number of columns. */
.cards--2 { grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
.cards--3 { grid-template-columns: repeat(auto-fit, minmax(268px, 1fr)); }

@media (min-width: 900px) {
  .cards--2 { grid-template-columns: repeat(2, 1fr); }
  .cards--3 { grid-template-columns: repeat(3, 1fr); }
}

/* --- the accent sections: a lit rail, a tighter group, balanced headings --- */

/* Owner, 2026-08-12, of the four sections on the homepage: the gaps are too big,
   the plain vertical line should be something with an effect on it, and the long
   heading should break into two even lines instead of orphaning one word.

   🔴 THREE CLASSES WHERE THE RAIL IS CONCERNED. theme-e5.css is GENERATED, loads
   after this file, and owns `.content--accent .content__inner` at 0-2-0 — which is
   why the 2px accent border this file used to set was rendering as its faint 1px
   white hairline instead. `.content.content--accent` is 0-3-0 and cannot lose.
   ⚠️ Never hand-edit theme-e5.css to fix that; gen_orbit2_bg.php overwrites it. */

/* 🔑 A HEADING BREAKS WHERE IT READS, NOT WHERE THE BOX ENDS. "Spot best trading
   opportunity with the best order flow trading tools" was wrapping with "tools"
   alone on the second line. `balance` is the one-line fix and it degrades to
   ordinary wrapping on a browser that does not have it — no fallback needed and
   no fixed max-width to maintain per heading. */
.content__text h2,
.section__head h2 { text-wrap: balance; }

/* The gap between two ADJACENT accent sections only. The first one keeps its full
   space above and the last keeps its full space below, so the four read as one
   group rather than four unrelated bands — the same `:has(+ ...)` device the
   folded cards above use, for the same reason. */
.content--accent:has(+ .content--accent) {
  padding-bottom: clamp(30px, 4vw, 52px);
}

/*
 * The rail. A flat hairline replaced by a light source: bright at the top where
 * the heading is, falling away down the section.
 *
 * Painted as a BACKGROUND LAYER rather than a border, because a gradient border
 * needs border-image and border-image cannot also carry the glow below. One
 * declaration, no extra element.
 */
.content.content--accent .content__inner {
  position: relative;
  border-left: 0;
  background:
    linear-gradient(180deg,
      rgb(169 205 245 / .95) 0,
      rgb(169 205 245 / .55) 14%,
      rgb(169 205 245 / .18) 44%,
      hsl(0 0% 100% / .07) 76%,
      transparent 100%) left top / 2px 100% no-repeat;
}

/*
 * The staggered column: every other section pushed across so its text ends on the
 * right edge of the column instead of every section starting on the same left one.
 *
 * 🔑 THE CAP IS WHAT MAKES THE PUSH POSSIBLE. `margin-left: auto` moves nothing on
 * a block that is already full width, and these sections are: only `.prose` is
 * capped at the reading measure, so the heading spans the whole column and the
 * paragraphs stop two-thirds of the way across, leaving 518px of air on the right
 * (measured). Capping the inner to the same measure hands that 518px back to the
 * margin, and the heading then wraps inside the text block rather than over it.
 *
 * The `+ 34px` is the accent variant's own padding-left, which sits between the
 * rail and the text and is inside the border box.
 * ⚠️ Verify with getComputedStyle that the prose's right edge lands on the wrap's
 * content edge — `ch` is relative to the element's own font-size, and the inner's
 * is not necessarily the prose's.
 */
.content--right .content__inner {
  max-width: calc(70ch + 34px);
  margin-left: auto;
}

/*
 * The lit node the rail runs down from.
 *
 * ⚠️ ABSOLUTE, AND THAT IS NOT DECORATION. `.content__inner` is `display: grid`,
 * so a pseudo-element left in flow becomes a GRID ITEM and takes a row of its
 * own — the section's first paragraph would jump down by the row gap. Out of flow
 * it cannot.
 */
.content.content--accent .content__inner::before {
  content: '';
  position: absolute;
  left: -3px; top: 0;
  width: 8px; height: 8px;
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 0 3px rgb(169 205 245 / .13), 0 0 18px 2px rgb(169 205 245 / .42);
}

/* --- the development panel: lit from the same corner as the hero ---------- */

/*
 * Owner: "give the Next-Gen NinjaScript card some internal highlight, in the
 * colour at the top of the homepage." That colour is #a9cdf5 — measured, not
 * chosen: it is what the hero's "Next-Generation Volume Indicators for
 * NinjaTrader" line and this card's own subheading already compute to.
 *
 * Same lighting grammar as .cards .card further down: a key light off the
 * top-left and a cold bounce from the bottom-right, so the two treatments read as
 * one system rather than two ideas about glow.
 */
/*
 * 🔴 THE FILL IS RAISED, AND THE TARGET IS NOT A NUMBER I LIKED THE LOOK OF.
 *
 * theme-e5 fills every panel with `--card-fill`, which is white at 2.5%. Over
 * this page's near-black `--bg` (#030305) that lands at about #0a0a0b — close
 * enough to the page that the card had no edge to it, which is what the owner saw
 * on 2026-08-12: "make the background stronger for that card".
 *
 * ~6% white over #030305 lands near #0f0f11, which is the site's own `--bg-elev-2`
 * (#101018). So the card is being lifted to an elevation the design system already
 * defines rather than to an arbitrary tint — and the top-to-bottom fall gives it
 * the same "lit from above" reading as the rail and the product cards.
 *
 * ⚠️ Scoped to `.content--panel.content--media`, which matches exactly ONE element
 * site-wide (measured). The product pages' figure sections are
 * `content--plain content--media` and keep the fill they always had.
 */
.content--panel.content--media .content__inner {
  isolation: isolate;
  background: linear-gradient(180deg,
    hsl(0 0% 100% / .062) 0,
    hsl(0 0% 100% / .034) 46%,
    hsl(0 0% 100% / .026) 100%);
  border-color: var(--line-strong);
  box-shadow:
    inset 0 1px 0 rgb(169 205 245 / .28),    /* the top edge catching the accent */
    inset 0 -1px 0 hsl(0 0% 100% / .10),
    0 26px 54px -28px rgb(0 0 0 / .85);
}
.content--panel.content--media .content__inner::after {
  content: '';
  position: absolute; inset: 0; z-index: -1;
  pointer-events: none;
  border-radius: inherit;
  background:
    radial-gradient(95% 72% at 0% 0%, rgb(169 205 245 / .17), transparent 58%),
    radial-gradient(62% 52% at 100% 104%, rgb(169 205 245 / .10), transparent 72%);
}
/* The pseudo-element needs a positioned parent, and .content--media panels are
   the only ones getting one — a bare .content__inner stays exactly as it was. */
.content--panel.content--media .content__inner { position: relative; }

/* --- the teaser card: first lines, fading, with a Read more ---------------- */

/*
 * Owner, 2026-08-12, of the three cards at the foot of the homepage: the text was
 * using half the card, and a shut card showing only its heading gives a reader no
 * reason to open it. So: full width, the opening lines visible, fading out under a
 * Read more.
 *
 * 🔑 NO SCRIPT, AND EVERY WORD STILL IN THE MARKUP. A checkbox drives it and the
 * shut state CLIPS rather than truncates — the body is complete in the HTML either
 * way, which is the same contract the <details> cards keep and the reason folding
 * anything on this site is safe. See the note in blocks/views/content-section.php
 * for why <details> could not do this one.
 *
 * 🔴 TWO CLASSES ON ANYTHING THE THEME ALSO SETS. theme-e5.css is generated and
 * loads after this file.
 */

/*
 * The card's own fill, taken from the galaxy at the top of the page.
 *
 * 🔑 THE THREE COLOURS ARE LIFTED, NOT MATCHED BY EYE. They are the exact stops of
 * `.hero::before` in theme-e5.css — a violet `rgb(126 106 214)`, a blue-violet
 * `rgb(97 134 232)` and the deep `rgb(58 78 150)` under them. The owner asked for
 * "the light background colours at the top of the homepage, close to purple";
 * those are the ones actually painted there, so the cards belong to the same sky
 * rather than to a purple somebody picked separately.
 *
 * ⚠️ They are used at a FRACTION of the hero's opacity. Up there the galaxy is
 * blurred 46px across a 150%-tall band; here the same colours sit behind body
 * copy that has to stay readable, so what carries over is the hue, not the light.
 *
 * 🔴 `background`, not `background-color` — theme-e5 sets the shorthand on
 * `.content--panel .content__inner`, and a colour-only declaration here would be
 * wiped by it because the shorthand also resets the image.
 *
 * 🔴🔴 AND THREE CLASSES, NOT TWO. The first version of this rule was
 * `.content--teaser .content__inner` and it did nothing at all — measured: zero
 * gradient layers, the border still the theme's. Two classes is the SAME weight as
 * the theme's `.content--panel .content__inner`, and theme-e5.css loads after this
 * file, so a tie goes to the theme. The comment here even claimed specificity was
 * covered; it was not. `.content--teaser.content--panel` is 0-3-0 and cannot tie.
 */
.content--teaser.content--panel .content__inner {
  position: relative;
  background:
    radial-gradient(120% 90% at 12% 0%,  rgb(126 106 214 / .16), transparent 62%),
    radial-gradient(90% 80% at 88% 8%,   rgb(97 134 232 / .12),  transparent 66%),
    radial-gradient(140% 120% at 50% 110%, rgb(58 78 150 / .10), transparent 72%),
    linear-gradient(180deg, hsl(0 0% 100% / .045), hsl(0 0% 100% / .022));
  border-color: hsl(266 42% 70% / .20);
}

/* The heading, in the accent rather than in body white — the owner asked for the
   title to read differently, and this is the colour the hero and the development
   card already use, so it is a colour the page owns rather than a new one. */
.content--teaser .teaser__head h2 {
  color: var(--accent);
  margin-bottom: 0;
}

/*
 * 🔴 THE CARD WAS MOSTLY AIR, AND FOUR SEPARATE GAPS WERE MAKING IT.
 *
 * Owner, 2026-08-12: "the cards are still too big and take up space for nothing;
 * bring the text closer to the title". Measured, the space between the heading and
 * the first line of body was about 44px — 18px of the head's own margin plus the
 * inner grid's 26px gap — and the card carried 46px of padding at each end on top
 * of that. None of it was doing any work on a card whose whole job is to show
 * three lines.
 *
 * Three classes on the padding and the gap: theme-e5.css owns
 * `.content--panel .content__inner` at 0-2-0 and loads after this file, so 0-2-0
 * here ties and loses. That mistake was made once already on this same card today.
 */
.content--teaser .teaser__head { margin-bottom: 0; }

.content--teaser.content--panel .content__inner {
  gap: 12px;
  padding: clamp(20px, 2.4vw, 28px) clamp(22px, 2.8vw, 32px);
}
.content--teaser .content__text { gap: 14px; }

/*
 * The toggle. Off-screen but NOT `display: none` — a hidden input is not tabbable,
 * and the label on its own is not a control, so hiding it that way would make the
 * card impossible to open from the keyboard.
 */
.teaser__toggle {
  position: absolute;
  width: 1px; height: 1px;
  opacity: 0;
  pointer-events: none;
}

/*
 * Shut: about three lines, faded out at the bottom.
 *
 * ⚠️ `mask-image` rather than an overlay rectangle. An overlay has to be painted
 * in the card's own background colour, so it stops matching the moment the card's
 * fill changes — and this card's fill changed twice today. A mask fades whatever
 * is actually behind it.
 */
/*
 * Shut: three lines tall, and the fade starts at the second one.
 *
 * 🔑 COUNTED IN LINES, NOT IN PIXELS. The prose here runs at 1.75 line-height
 * (`.content--wide .prose`), so three of them is 5.25em. Two earlier versions got
 * this wrong in a way no error could show: `8.4em` silently rendered five lines,
 * and `(3+1)` rendered four with only the fourth fading.
 *
 * The owner's spec is three lines with the dissolve beginning at line two — so the
 * mask holds solid for one line (33% of three) and fades from there. The reader
 * gets one clean line, a second going, and a third almost gone: enough to know
 * what the card is about, obviously cut off, and short.
 */
.content--teaser .content__text {
  --teaser-line: 1.75em;      /* matches .content--wide .prose line-height */
  --teaser-lines: 3;
  max-height: calc(var(--teaser-lines) * var(--teaser-line));
  overflow: hidden;
  -webkit-mask-image: linear-gradient(180deg, #000 33%, transparent 100%);
          mask-image: linear-gradient(180deg, #000 33%, transparent 100%);
}

/*
 * 🔴 NO TRANSITION ON max-height, AND THE FIRST VERSION HAD ONE.
 *
 * The usual trick is `transition: max-height .3s` with the open state set to some
 * arbitrary large ceiling. Measured here: with the transition in place the card
 * stayed at 134.4px 600ms after a real click; with `transition: none` the same
 * click opened it to 442px immediately. So the animation was not slow, it was
 * stuck — and a reader clicking Read more and seeing nothing happen is the worst
 * outcome this card can produce.
 *
 * It is a known-fragile pattern for a second reason too: interpolating to a
 * ceiling nothing reaches means most of the duration is spent animating empty
 * space, so even when it works the timing is wrong. An instant expand is what a
 * Read more does everywhere else, and it cannot fail.
 */
.teaser__toggle:checked ~ .content__text {
  max-height: none;
  -webkit-mask-image: none;
          mask-image: none;
}

/* The label is the button. Its caption lives here because the markup cannot know
   which state it is in without a script. */
/* 🔴 NO UNDERLINE ON HOVER. The first version put a 1px `--accent-hi` border under
   it — and `--accent-hi` is #cfe4fb, which on this background reads as a white
   rule appearing from nowhere. The owner saw it immediately. The colour shift
   alone is the hover state; a control that is already the only accent-coloured
   thing in the card does not need a second signal. */
.teaser__more {
  display: inline-flex; align-items: center; gap: 8px;
  margin-top: 2px;
  font-family: var(--mono); font-size: 12.5px; letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--accent);
  cursor: pointer;
  transition: color .18s ease;
}
.teaser__more::before { content: 'Read more'; }
.teaser__more::after {
  content: '↓';
  font-size: 14px;
  transition: transform .25s ease;
}
.teaser__more:hover { color: var(--accent-hi); }

.teaser__toggle:checked ~ .teaser__more::before { content: 'Show less'; }
.teaser__toggle:checked ~ .teaser__more::after  { transform: rotate(180deg); }

/* Focus has to be visible on the LABEL, because the input it belongs to is the
   thing that takes focus and the input is off-screen. */
.teaser__toggle:focus-visible ~ .teaser__more {
  outline: 2px solid var(--accent);
  outline-offset: 4px;
  color: var(--accent-hi);
}

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

/* --- lighting on a card --------------------------------------------------- */

/* Owner, 2026-08-12, of the two cards on /ninjatrader: make them lit rather than
   flat. Those two carry no picture at all, so the surface IS the graphic — and
   what they had was a 1px border, a flat fill and one inset lip along the bottom.

   🔴 TWO CLASSES ON EVERY RULE HERE, AND IT IS NOT OPTIONAL. theme-e5.css is
   GENERATED by tools/gen_orbit2_bg.php, loads AFTER this file, and owns
   `.card { box-shadow: var(--card-lit), var(--shadow) }` at 0-1-0. A one-class
   rule here loses on source order however emphatic it looks — four rules have
   already died that way. `.cards .card` is 0-2-0 and cannot.
   ⚠️ Never hand-edit theme-e5.css to fix this: the next run of the generator
   overwrites it.

   Light comes from above and slightly left, which is where it comes from in the
   hero backdrop and in every product-art panel, so a card belongs to the same
   room as the artwork rather than being a box sitting in front of it. */

.cards .card {
  /* A stacking context, so the ::after below can sit at z-index -1 and paint
     BETWEEN the card's own background and its text. Without it, -1 escapes to
     the nearest positioned ancestor (main) and the card's fill hides the light.
     `isolation` does this without touching layout, which `z-index: 0` would not
     do as cleanly on an element other rules may want to stack. */
  isolation: isolate;
  box-shadow:
    inset 0 1px 0 hsl(0 0% 100% / .07),      /* the top edge catching the light */
    inset 0 -1px 0 hsl(0 0% 100% / .10),     /* theme-e5's bottom lip, kept */
    0 18px 40px -26px rgb(0 0 0 / .75);      /* the card standing off the page */
  transition: border-color .22s ease, transform .22s ease, box-shadow .28s ease;
}

/* The lit surface itself. A pseudo-element rather than a background-image on the
   card, because theme-e5 sets `background:` as a SHORTHAND on .card--featured —
   overriding background-image from here would have flattened the featured
   pricing card's gradient as a side effect of lighting everything else. */
.cards .card::after {
  content: '';
  position: absolute; inset: 0; z-index: -1;
  pointer-events: none;
  border-radius: inherit;
  background:
    /* the key light, off the top-left corner */
    radial-gradient(125% 95% at 6% -12%, hsl(0 0% 100% / .085), transparent 60%),
    /* and a cold bounce off the floor, bottom-right — the accent, very faint */
    radial-gradient(75% 65% at 94% 112%, rgb(169 205 245 / .055), transparent 72%);
}

/*
 * Hover: the light comes up and the card lifts.
 *
 * 🔑 THE HOVER STATE IS BOX-SHADOW ONLY, on purpose. box-shadow interpolates, so
 * this animates; gradients inside a background do not without @property, and
 * brightening the ::after instead would have snapped. The visible result is the
 * same because the rim and the glow are both shadows.
 */
.cards .card:hover {
  box-shadow:
    inset 0 1px 0 hsl(0 0% 100% / .13),
    inset 0 -1px 0 hsl(0 0% 100% / .14),
    0 0 0 1px rgb(169 205 245 / .17),        /* an accent rim, not a border swap */
    0 26px 58px -26px rgb(0 0 0 / .85),
    0 0 44px -14px rgb(169 205 245 / .14);   /* the glow the rim sits in */
}

/* A card that IS a link says so on hover in the line that names the destination.
   ⚠️ Paint only, and only on .card--link — .card__meta also carries "12 min read"
   on the lesson grids, where turning it into a call to action would be a lie. */
.cards .card--link:hover .card__meta { color: var(--accent); }

/* The picture inside a lit card gets the same treatment: a hairline where the
   media meets the body, so the two do not read as one flat plane. */
.cards .card--link .card__media { box-shadow: inset 0 -1px 0 hsl(0 0% 100% / .08); }

@media (prefers-reduced-motion: reduce) {
  .cards .card { transition: box-shadow .28s ease; }
  .cards .card:hover { transform: none; }
}

/* --- square-picture cards (the automatic product grid) -------------------- */

/* 🔴 THE DEFAULT 16/9 BOX IS WRONG FOR THESE PANELS, AND WRONG IN THE DIRECTION
   THAT LOOKS FINE UNTIL YOU LOOK. The product art in web/img/product-art/ is
   220x560 — a tall strip, drawn for the left edge of the wide card on /products.
   Put one through `.card__media { aspect-ratio: 16/9 }` with object-fit: cover
   and the card shows a horizontal band about a ninth of the artwork: for the
   profile panel, three bars out of sixteen. Same failure as the logo crop
   further down this file, different shape.

   A square box crops far less: at a 360px card the visible window is roughly
   source y 170-390 of 560, and the generator composes every panel around y 268.
   ⚠️ If the panels are ever redrawn square, drop the object-position below —
   it exists only to sit the crop on the composed centre. */
.cards--square .card__media { aspect-ratio: 1 / 1; }
.cards--square .card__media img { object-position: 50% 48%; }

/* The badge line is doing the job the price does on the shop card, so it gets the
   accent rather than the faint grey the lesson grids use for "Lesson 3". */
.cards--square .card__eyebrow { color: var(--accent-hi); }

/* --- FAQ ------------------------------------------------------------------ */
/* <details> — the accordion Google renders, with no JavaScript to keep in sync
   and keyboard behaviour the browser already gets right. */

.faq { display: grid; gap: 10px; max-width: 860px; }

.faq__item {
  background: var(--bg-elev);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  transition: border-color .18s ease;
}
.faq__item[open] { border-color: var(--line-strong); background: var(--bg-elev-2); }
.faq__item:hover { border-color: var(--line-strong); }

.faq__q {
  display: flex; align-items: flex-start; gap: 16px;
  padding: 17px 20px;
  cursor: pointer; list-style: none;
  font-weight: 590; font-size: 15.8px; color: var(--text);
}
.faq__q::-webkit-details-marker { display: none; }
.faq__q::after {
  content: ''; flex: none; margin-left: auto; margin-top: 7px;
  width: 9px; height: 9px;
  border-right: 2px solid var(--text-faint); border-bottom: 2px solid var(--text-faint);
  transform: rotate(45deg);
  transition: transform .2s ease, border-color .2s ease;
}
.faq__item[open] .faq__q::after {
  transform: rotate(-135deg); border-color: var(--accent);
}

.faq__a {
  margin: 0 20px 20px;
  padding-top: 16px;
  border-top: 1px solid var(--line);
  color: var(--text-dim); font-size: 15px;
}
.faq__a p + p { margin-top: .9em; }
.faq__a ol, .faq__a ul { margin: .9em 0; padding-left: 1.35em; }
.faq__a li { margin-bottom: .35em; }
.faq__a code {
  font-family: var(--mono); font-size: .88em;
  background: var(--bg-inset); border: 1px solid var(--line);
  padding: 2px 6px; border-radius: 5px; color: var(--accent-hi);
}
.faq__a strong { color: var(--text); }

/* --- user manual ---------------------------------------------------------- */

/* Two levels of native <details>. The outer panel is one indicator, the inner
   ones its topics. Same reason the FAQ uses <details>: it is an accordion that
   works with no JavaScript, keeps its content in the DOM for search engines and
   for Ctrl+F, and stays operable from the keyboard for free. */

/* 🔴 FULL COLUMN WIDTH, since 2026-08-13. This carried `max-width: 980px`,
   which sat 100px inside the .wrap on each side — measured at a 1280 viewport,
   1180px of content above it and 980px of manual below, so the box read as a
   narrower thing tucked under the section rather than as part of it. The owner
   marked both edges on a screenshot and asked for them to line up.

   ⚠️ There is no reading-measure argument for the old cap. The prose INSIDE a
   topic is capped separately by `.prose`; what 980px was limiting was the
   panel, its topic rows and its screenshots — none of which are a line of text.
   The wrap is the page's column and the manual is now in it. */
.manual { display: grid; gap: 12px; }

/* Single-indicator form: the box rides directly under its own section — no
   inset ground, no heading of its own, and a negative top margin that eats
   the section gap above it so the two read as one unit.

   Doubled class on purpose: theme-e5.css loads after this file and restyles
   `.section` padding at equal specificity, which silently beat the first
   version of this rule. (0,2,0) wins regardless of load order, and the theme
   generator never has to know this section exists.

   The margin mirrors the theme's own `.section` padding-block term, minus a
   12px breath — so the visible gap is 12px at EVERY viewport width instead of
   the difference of two unrelated clamps. If the theme's padding changes,
   this term must follow it (that coupling is the price of not editing a
   generated file). */
.section.manual-section--inline {
  padding-block: 0 clamp(56px, 8vw, 104px);
  margin-top: calc(12px - clamp(64px, 9vw, 112px));
  scroll-margin-top: 20px;
}
.manual__name-sub { color: var(--text-faint); font-weight: 500; }

.manual__intro { margin-top: 14px; }

.manual__group {
  background: var(--bg-elev);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  transition: border-color .18s ease;
}
.manual__group:hover { border-color: var(--line-strong); }
.manual__group[open] { border-color: var(--line-strong); background: var(--bg-elev-2); }

.manual__head {
  display: grid; gap: 5px;
  padding: 20px 22px;
  cursor: pointer; list-style: none;
  position: relative;
  padding-right: 52px;
}
.manual__head::-webkit-details-marker { display: none; }
.manual__head::after {
  content: ''; position: absolute; right: 24px; top: 27px;
  width: 9px; height: 9px;
  border-right: 2px solid var(--text-faint); border-bottom: 2px solid var(--text-faint);
  transform: rotate(45deg);
  transition: transform .2s ease, border-color .2s ease;
}
.manual__group[open] > .manual__head::after {
  transform: rotate(-135deg); border-color: var(--accent);
}

.manual__headline { display: flex; align-items: center; gap: 11px; flex-wrap: wrap; }
/* h3 and h4 carry the outline; they must not carry the document's heading sizes
   with them, or a panel title would outshout the section it sits under. */
.manual__name { font-weight: 620; font-size: 17.5px; color: var(--text); margin: 0; line-height: 1.3; }
.manual__topic-head h4 { font: inherit; margin: 0; }
.manual__tag {
  font-family: var(--mono); font-size: 11px; letter-spacing: .06em;
  color: var(--accent-hi);
  background: var(--bg-inset); border: 1px solid var(--line-strong);
  padding: 3px 8px; border-radius: 5px;
}
.manual__summary { color: var(--text-dim); font-size: 14.6px; max-width: 74ch; }

.manual__body {
  margin: 0 22px 24px;
  padding-top: 20px;
  border-top: 1px solid var(--line);
}
.manual__overview { font-size: 15.2px; }
.manual__overview h4 { margin: 1.4em 0 .5em; color: var(--text); }

.manual__figure { margin: 0 0 22px; }
.manual__overview + .manual__figure { margin-top: 22px; }

/*
 * 🔴 THE SCREENSHOTS ARE WHAT MADE THE OPEN MANUAL UNREADABLE, NOT THE TEXT.
 *
 * Measured 2026-08-13 with all three groups open: the page ran to 21 screens
 * and **8.2 of them were figures** — nine of them, averaging 0.87 of a viewport
 * each. The 33 topic rows the change existed to reveal cost 0.07 each.
 *
 * So the cap is on the picture, not on the manual. 46vh keeps a screenshot
 * large enough to read the chart in it while stopping any single one from
 * owning the screen, and `object-fit: contain` means a tall image shrinks
 * rather than being cropped — a cropped screenshot of a settings window loses
 * exactly the rows somebody opened the manual to see.
 *
 * ⚠️ The full-size view is unaffected: these are inside the lightbox trigger,
 * so a click still opens the original at its own size. Nothing is lost, it is
 * just no longer spent on first sight.
 */
.manual__img {
  display: block; width: 100%; height: auto;
  max-height: 46vh; object-fit: contain; object-position: left top;
  border: 1px solid var(--line-strong); border-radius: var(--radius-sm);
  background: var(--bg-inset);
}
.manual__caption {
  margin-top: 9px;
  font-size: 13.2px; color: var(--text-faint); line-height: 1.5;
}

.manual__topics { display: grid; gap: 8px; margin-top: 24px; }

.manual__topic {
  background: var(--bg-inset);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
}
.manual__topic[open] { border-color: var(--line-strong); }

.manual__topic-head {
  display: flex; align-items: flex-start; gap: 14px;
  padding: 13px 16px;
  cursor: pointer; list-style: none;
  font-weight: 570; font-size: 14.8px; color: var(--text);
}
.manual__topic-head::-webkit-details-marker { display: none; }
.manual__topic-head::before {
  content: ''; flex: none; margin-top: 6px;
  width: 7px; height: 7px;
  border-right: 2px solid var(--text-faint); border-bottom: 2px solid var(--text-faint);
  transform: rotate(-45deg);
  transition: transform .2s ease, border-color .2s ease;
}
.manual__topic[open] > .manual__topic-head::before {
  transform: rotate(45deg); border-color: var(--accent);
}

.manual__topic-body {
  margin: 0 16px 15px 37px;
  color: var(--text-dim); font-size: 14.6px;
}
.manual__topic-body p + p { margin-top: .85em; }
.manual__topic-body ul, .manual__topic-body ol { margin: .85em 0; padding-left: 1.35em; }
.manual__topic-body li { margin-bottom: .35em; }
.manual__topic-body strong { color: var(--text); }
.manual__topic-body .manual__figure { margin: 16px 0 0; }

/* The settings row names the controls exactly as they appear in the indicator
   window, so the reader can search for them rather than hunt. */
.manual__controls {
  margin-top: 14px; padding-top: 12px;
  border-top: 1px dashed var(--line-strong);
  display: flex; flex-wrap: wrap; gap: 6px; align-items: center;
}
.manual__controls-label {
  font-family: var(--mono); font-size: 10.5px; letter-spacing: .12em;
  text-transform: uppercase; color: var(--text-faint);
  margin-right: 4px;
}
.manual__controls code {
  font-family: var(--mono); font-size: 12.2px;
  background: var(--bg-elev); border: 1px solid var(--line);
  padding: 2px 7px; border-radius: 5px; color: var(--accent-hi);
}

@media (max-width: 620px) {
  .manual__head { padding: 16px 44px 16px 16px; }
  .manual__head::after { right: 18px; top: 23px; }
  .manual__body { margin: 0 16px 18px; }
  .manual__topic-body { margin-left: 16px; }
}

/* --- testimonials --------------------------------------------------------- */

.quotes {
  display: grid; gap: 18px;
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.quote {
  background: var(--bg-elev);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 26px;
}
.quote__text { color: var(--text); font-size: 15.4px; }
.quote__text::before { content: '\201C'; color: var(--accent); font-size: 2em; line-height: 0; vertical-align: -.35em; margin-right: 4px; }
.quote__by { margin-top: 18px; font-size: 13.8px; color: var(--text-faint); }
.quote__by strong { display: block; color: var(--text-dim); font-weight: 600; }

.quotes--empty {
  border: 1px dashed var(--line-strong); border-radius: var(--radius);
  padding: 34px; text-align: center; color: var(--text-faint);
  font-size: 14.5px;
}

/* --- CTA band ------------------------------------------------------------- */

.cta-band {
  position: relative; overflow: hidden;
  background: linear-gradient(140deg, var(--bg-elev-2), var(--bg-elev));
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: clamp(30px, 5vw, 52px);
  display: flex; flex-wrap: wrap; align-items: center; gap: 26px;
  justify-content: space-between;
}
.cta-band::before {
  content: ''; position: absolute; inset: 0;
  background: radial-gradient(60% 120% at 88% 0%, rgb(56 189 248 / .14), transparent 70%);
  pointer-events: none;
}
.cta-band > * { position: relative; }
.cta-band h2 { margin-bottom: 8px; }
.cta-band p  { color: var(--text-dim); max-width: 58ch; }
.cta-band .btn { flex: none; }

/* --- product card ---------------------------------------------------------- */
/* Two columns so the price and the buttons sit beside the description rather
   than at the end of it: a visitor deciding whether to buy should not have to
   read to the bottom to find what it costs. */

.product {
  display: grid;
  grid-template-columns: minmax(0, 1.5fr) minmax(280px, .8fr);
  gap: clamp(28px, 4vw, 56px);
  align-items: start;
  background: var(--card-fill, hsl(0 0% 100% / .025));
  border: 1px solid var(--line);
  border-radius: var(--radius);
  box-shadow: var(--card-lit, none), var(--shadow);
  padding: clamp(26px, 4vw, 44px);
}

.product__name { margin-bottom: 14px; }
.product__tagline { margin-bottom: 24px; }

.product__includes { list-style: none; display: grid; gap: 11px; }
.product__includes li {
  position: relative; padding-left: 26px;
  color: var(--text); font-weight: 550;
}
.product__includes li::before {
  content: ''; position: absolute; left: 2px; top: .45em;
  width: 13px; height: 7px;
  border-left: 2px solid var(--accent); border-bottom: 2px solid var(--accent);
  transform: rotate(-45deg);
}

/*
 * The extra-seat line, in the space under the includes list.
 *
 * 🔴 THE OWNER'S CONDITION WAS THAT IT MUST NOT MAKE THE CARD TALLER, so the
 * margin is the only spacing it gets and the type is the small size already used
 * by .product__note. Measured: the card is 380px and the middle column carried
 * 123px of empty space below the list, because the three columns stretch to the
 * art panel. This line costs about a third of that.
 *
 * It reads as a note rather than as a feature — it is a fact about buying a
 * second copy, not a fourth thing in the pack, and giving it the list's weight
 * would make it look like one.
 */
.product__seat {
  margin-top: 20px;
  max-width: 46ch;
  font-size: 13px; line-height: 1.55;
  color: var(--text-dim);
}
.product__seat strong { color: var(--accent-hi); font-weight: 650; }

.product__buy {
  border-left: 1px solid var(--line);
  padding-left: clamp(24px, 3vw, 40px);
}
.product__price {
  font-size: clamp(32px, 4vw, 42px);
  font-weight: 660; letter-spacing: -.02em; line-height: 1.1;
  color: var(--text);
}
.product__price-note { font-size: 13px; color: var(--text-faint); margin-top: 8px; }

/* Where a real card shows its price, a teaser shows its status. Same size and
   weight so the two cards sit level on /products — a smaller line here would
   make the unsellable one look like a broken copy of the sellable one. */
.product__soon {
  font-size: clamp(22px, 2.6vw, 28px);
  font-weight: 660; letter-spacing: -.02em; line-height: 1.15;
  color: var(--text-faint);
}
.product__actions { display: grid; gap: 10px; margin-top: 24px; }
/*
 * 🔴 `width: 100%` ALONE DID NOT HOLD IT IN. `.btn` carries `white-space: nowrap`
 * — right for a button labelled "Save", wrong here — so a long label forced a
 * min-content width larger than the column and the button hung 6px past the card
 * edge. Measured 2026-08-17 after an anchor text was lengthened for SEO.
 *
 * The label is not the fix: the next long one would do it again. Buttons in a
 * product card are allowed to run to two lines instead.
 */
.product__actions .btn { width: 100%; white-space: normal; text-align: center; }
.product__note { font-size: 12.8px; color: var(--text-faint); margin-top: 16px; }

/* --- the art panel on a product card -------------------------------------- */

/* 🔑 EVERY RULE HERE IS BEHIND `.product--art`, which the view only adds when
   the SVG is actually on disk. A card with no art keeps the two-column layout
   above, untouched. Deleting web/img/product-art/ is the whole rollback.

   The card loses its own padding so the picture can run to the edge, and the
   two text columns take that padding back individually. Without this the panel
   floats in a 44px moat and reads as a pasted-on thumbnail rather than part of
   the card. */
.product--art {
  grid-template-columns: clamp(150px, 17vw, 250px) minmax(0, 1.5fr) minmax(300px, .82fr);
  gap: 0;
  padding: 0;
  align-items: stretch;
  overflow: hidden;              /* so the art cannot escape the rounded corner */
}
.product--art .product__body { padding: clamp(26px, 4vw, 44px); }
.product--art .product__buy {
  padding: clamp(26px, 4vw, 44px);
  padding-left: clamp(24px, 3vw, 40px);
}

/* 🔴 THE CARDS ARE EQUAL HEIGHT ON PURPOSE. They are four separate <section>s,
   not one grid, so nothing can equalise them automatically — a teaser with two
   lines of text rendered 278px against the real card's 379px and read as a
   lesser, half-finished thing. The floor is set to the tallest card and the art
   fills what the text does not. Measured 2026-08-10 at 1440px.
   ⚠️ If a card ever grows past this it just gets taller and the others stop
   matching it — raise the number, do not add a max-height. */
@media (min-width: 821px) {
  .product--art { min-height: 380px; }
}

.product__art {
  position: relative;
  overflow: hidden;
  background: #04070d;
  border-right: 1px solid var(--line);
}
/* 🔴 ABSOLUTE, NOT JUST height:100%. The panel is 220x560; left in flow, the
   img resolves that aspect ratio against its own width and forced every card to
   621px tall — the cards came out equal, but equal to the picture instead of to
   the content. Taking it out of flow lets the grid row decide the height and the
   picture crop to it. */
.product__art img {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* The panel is taller than the card is deep, so the crop takes a middle band.
     gen_product_art.php keeps the subject there. */
  object-position: 50% 45%;
}
/* Fades the picture into the card instead of ending it on a hard seam. The
   stop colour is the panel's own background, so it darkens rather than bands. */
.product__art::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(90deg, rgb(4 7 13 / .55) 0%, transparent 26%, transparent 62%, rgb(4 7 13 / .9) 100%);
  pointer-events: none;
}

@media (max-width: 820px) {
  .product { grid-template-columns: 1fr; }
  .product__buy { border-left: 0; padding-left: 0; border-top: 1px solid var(--line); padding-top: 26px; }

  /* Stacked, the panel becomes a banner across the top. A 250px-wide column of
     art beside 250px of text is neither a picture nor a paragraph. */
  .product--art { grid-template-columns: 1fr; min-height: 0; }
  .product--art .product__buy { padding-top: 26px; }
  .product__art {
    height: clamp(120px, 34vw, 190px);
    border-right: 0;
    border-bottom: 1px solid var(--line);
  }
  .product__art img { object-position: 50% 42%; }
  .product__art::after {
    background: linear-gradient(180deg, rgb(4 7 13 / .3) 0%, transparent 40%, rgb(4 7 13 / .75) 100%);
  }
}

/* --- checkout + payment ---------------------------------------------------- */

/* Sized as a form to be filled in, not a page to be admired. The site's hero
   type (h1 at 58px) and 112px section padding pushed a four-step form over
   2300px on a 1920 monitor.

   ⚠️ `.section.checkout` needs the extra class: `theme-e5.css` loads after this
   file and restyles `.section` at equal weight, so a single-class rule loses. */
.section.checkout { padding-block: clamp(26px, 3.2vw, 48px); }

.checkout__head { max-width: 58ch; margin: 0 0 22px; }
.checkout .checkout__head h1 { font-size: clamp(22px, 2.4vw, 28px); letter-spacing: -.015em; }
.checkout .checkout__head .lede { font-size: 14.5px; margin-top: 6px; }

.checkout__grid {
  display: grid;
  grid-template-columns: minmax(0, 1.55fr) minmax(270px, .7fr);
  gap: clamp(16px, 2vw, 28px);
  align-items: start;
  /* Beyond this the form stops being a form and becomes a landscape. */
  max-width: 980px;
}
.checkout__main { display: grid; gap: 14px; min-width: 0; }
.checkout__summary { position: sticky; top: 20px; }

.checkout__card {
  background: var(--card-fill, hsl(0 0% 100% / .025));
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: clamp(15px, 1.8vw, 20px);
  font-size: 14.5px;
}
.checkout__card h2 {
  font-size: .95rem; margin: 0 0 12px;
  display: flex; align-items: center; gap: 8px;
}
.checkout__n {
  flex: none;
  width: 21px; height: 21px; border-radius: 50%;
  display: inline-flex; align-items: center; justify-content: center;
  background: var(--accent); color: var(--bg, #0b0f16);
  font-size: 11px; font-weight: 700;
}
.checkout__row { display: flex; gap: 11px; flex-wrap: wrap; }
.checkout__row > .ff { flex: 1 1 190px; min-width: 0; }

/* Inputs scaled with the rest — a 44px-tall field in a 20px card looks like a
   different design. */
.checkout .ff { margin-bottom: 10px; }
.checkout .ff__label { font-size: 12.4px; }
.checkout .ff__input { padding: 8px 11px; font-size: 14px; }
.checkout .ff__hint { font-size: 12px; margin-top: 5px; }
.checkout .ff__check { font-size: 13.4px; }

.checkout__errors {
  border: 1px solid #b4884a; background: rgb(180 136 74 / .12);
  border-radius: var(--radius-sm); padding: 14px 18px; margin: 0 0 22px;
}
.checkout__errors ul { margin: 8px 0 0; padding-left: 20px; }
.ff--bad .ff__input { border-color: #c0603f; }
.ff__opt { opacity: .55; font-weight: 400; text-transform: none; letter-spacing: 0; }
.ff__label em { color: var(--accent); font-style: normal; }
.ff__hint { font-size: 12.8px; color: var(--text-faint); margin: 6px 0 0; line-height: 1.55; }

.checkout__rail {
  display: flex; align-items: center; gap: 11px;
  border: 1px solid var(--line); border-radius: var(--radius-sm);
  padding: 12px 14px; margin: 0 0 9px; cursor: pointer;
}
.checkout__rail:hover { border-color: var(--line-strong); }
.checkout__rail em { font-style: normal; color: var(--text-faint); font-size: 13px; }
.checkout__rail-one { margin: 0; font-weight: 600; }

.checkout__sum-line { display: flex; justify-content: space-between; gap: 12px; padding: 5px 0; }
.checkout__sum-line--dim { color: var(--text-faint); font-size: 12.8px; }
.checkout__sum-total {
  border-top: 1px solid var(--line); margin-top: 6px; padding-top: 10px;
  font-size: 1.04rem; font-weight: 650;
}
.checkout__license { font-size: 12px; color: var(--text-faint); line-height: 1.55; margin: 11px 0; }
.checkout__terms-v { text-align: center; font-size: 11.5px; }
.checkout__rail { padding: 9px 11px; margin: 0 0 7px; font-size: 13.6px; }
.ff__check.is-bad { color: #d98466; }
.btn--block { width: 100%; }
.is-hidden { display: none !important; }

/* --- payment page ---------------------------------------------------------- */

/* Scaled down deliberately. This is a receipt, not a landing page: the site's
   hero type (h1 at 58px) and 112px section padding put a two-line total and one
   button across three screens. Everything here is sized to be read at a glance
   and acted on once.

   ⚠️ `.section.pay` and `.pay .pay__head h1` carry an extra class on purpose.
   `theme-e5.css` loads AFTER this file and restyles `.section` at equal weight,
   so a single-class rule here would be silently overridden — the same trap that
   has caught this project before. */
.section.pay { padding-block: clamp(28px, 3.5vw, 52px); }

.pay__wrap { max-width: 580px; }
.pay__head { text-align: center; margin: 0 0 20px; }
.pay .pay__head h1 { font-size: clamp(22px, 2.4vw, 28px); letter-spacing: -.015em; }
.pay .pay__head .eyebrow { margin-bottom: 6px; }

.pay__card {
  background: var(--card-fill, hsl(0 0% 100% / .025));
  border: 1px solid var(--line); border-radius: var(--radius);
  padding: clamp(18px, 2.2vw, 26px);
  font-size: 14.5px;
}
.pay__line { display: flex; justify-content: space-between; gap: 14px; padding: 6px 0; }
.pay__line--total {
  border-top: 1px solid var(--line); margin-top: 6px; padding-top: 11px;
  font-size: 1.06rem; font-weight: 660;
}
.pay__actions {
  display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
  margin: 20px 0 10px;
}
.pay__actions .btn { flex: 1 1 220px; }

/* Still the largest QR on the site — this is the page where somebody actually
   holds up a phone — but no longer taller than the button beside it. */
.pay__qr {
  flex: 0 0 auto; display: grid; justify-items: center; gap: 5px;
  background: #fff; padding: 8px 8px 6px; border-radius: var(--radius-sm);
  border: 1px solid var(--line-strong);
}
.pay__qr img { display: block; width: 96px; height: 96px; object-fit: contain; }
.pay__qr span {
  font-family: var(--mono); font-size: 8.5px; letter-spacing: .06em;
  text-transform: uppercase; color: #444;
}
.pay__method, .pay__note { font-size: 12.4px; color: var(--text-faint); line-height: 1.55; }
.pay__none {
  border: 1px solid #b4884a; background: rgb(180 136 74 / .12);
  border-radius: var(--radius-sm); padding: 12px 15px; margin: 18px 0;
}
/* The box is used both as a single <p> and, for the "payment not completed"
   state, as a <div> holding several. Default paragraph margins inside it would
   push the border away from the text and read as two boxes. */
.pay__none p { margin: 0 0 8px; line-height: 1.55; }
.pay__none p:last-child { margin: 0; }
.pay__or {
  text-align: center; margin: 14px 0 10px;
  font-size: 12.4px; color: var(--text-faint);
  text-transform: uppercase; letter-spacing: .08em;
}
.pay__confirming {
  margin: 0 0 18px; padding: 13px 16px;
  border: 1px solid #4a8a5e; background: rgb(74 138 94 / .1);
  border-radius: var(--radius-sm); line-height: 1.55; font-size: 13.6px;
}
.pay__settled {
  margin: 18px 0 0; padding: 14px 16px;
  border: 1px solid #4a8a5e; background: rgb(74 138 94 / .1);
  border-radius: var(--radius-sm);
}
.pay__settled h2 { font-size: .98rem; margin: 0 0 6px; }
.pay__settled p { margin: 0 0 6px; line-height: 1.55; }
.pay__settled p:last-child { margin: 0; }

.pay__next { border-top: 1px solid var(--line); margin-top: 20px; padding-top: 17px; }
.pay__next h2 { font-size: .92rem; margin: 0 0 9px; }
.pay__next ol { margin: 0 0 12px; padding-left: 18px; display: grid; gap: 6px; }
.pay__next li { line-height: 1.55; }
.pay__back { text-align: center; margin-top: 16px; font-size: 12.6px; color: var(--text-faint); }

@media (max-width: 880px) {
  .checkout__grid { grid-template-columns: 1fr; }
  .checkout__summary { position: static; }
}

/* --- contact form ---------------------------------------------------------- */

.contact-form { max-width: 720px; display: grid; gap: 18px; }
.contact-form__row { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
.contact-form textarea.ff__input { resize: vertical; min-height: 150px; line-height: 1.6; }
/* The select caret and the dark option list used to live here, scoped to this
   form. They are global now — see `select option` up in the form base. Keeping
   a second copy would mean the next person fixes one and not the other. */
.contact-form__actions { margin-top: 4px; }
.contact-form__note { font-size: 13px; color: var(--text-faint); max-width: 62ch; }

/* The honeypot. Not display:none — some bots skip hidden fields, and a few
   browsers skip them for autofill too. Kept in the layout at zero size and
   pushed out of view instead, so it is there to be filled but cannot be seen,
   focused or tabbed to. */
.hp {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  overflow: hidden; clip-path: inset(50%);
  white-space: nowrap;
}

.form-note {
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-sm);
  padding: 13px 16px;
  margin-bottom: 22px;
  font-size: 14.5px;
  max-width: 720px;
}
.form-note--ok  { border-color: rgb(52 211 153 / .45); background: rgb(52 211 153 / .09); color: #a7f3d0; }
.form-note--bad { border-color: rgb(251 113 133 / .45); background: rgb(251 113 133 / .1); color: #fecdd3; }

@media (max-width: 640px) {
  .contact-form__row { grid-template-columns: 1fr; }
}

/* --- risk disclosure ------------------------------------------------------- */
/* Sits between the page and the footer on every page. It has to be legible —
   it is a legal notice, not fine print to be hidden — but it must not compete
   with the page above it, so it is small, quiet, and set on the inset surface
   so it reads as a separate register rather than more page. */

.risk {
  /* One baseline grid for both columns. Every line box and every gap below is
     an exact multiple of --risk-line, so a line in the right column always
     lands level with a line in the left — the two columns start at the same y
     and can only ever drift if some step is off-grid. Ratios (1.62em, .85em)
     produce fractional heights and are what made them drift before. */
  --risk-line: 21px;

  border-top: 1px solid var(--line);
  background: var(--bg-inset);
  padding-block: 34px 44px;
  font-size: 12.8px;
  line-height: var(--risk-line);
  color: var(--text-faint);
}

/* Two columns, for height. In one column this runs past 700px — a wall of
   grey between the page and the footer. Split, it halves, and each column
   still measures about 84 characters, which is a better line than the ~165
   the full 1180px wrap would have given anyway.

   break-after on the titles is what makes it safe: without it a heading can
   land at the foot of the first column with its paragraph stranded at the top
   of the second. The -webkit- pair is for Safari, which still wants it. */
.risk__inner {
  position: relative;
  columns: 2;
  column-gap: 56px;
  /* Two rows held clear at the top for the first title, which is lifted out of
     the column flow below. Without this the columns would start under it. */
  padding-top: calc(var(--risk-line) * 2);
}

/* The first title sits ABOVE both columns rather than at the head of column
   one. In the flow it pushed column one's prose down by two rows while column
   two began at row zero, so the two bodies never lined up. Out of the flow it
   keeps its place at the top and both columns start their text on the same
   row — "for example" level with "…for every investor. An investor". */
.risk__inner > .risk__title:first-of-type {
  position: absolute;
  top: 0;
  margin: 0;
  /* No `left`. This element's containing block is .risk__inner, which is also
     .wrap and carries 24px of inline padding — so `left: 0` pinned the title to
     the PADDING box and hung it 24px outside the prose. Leaving `left` as auto
     keeps its static position: exactly where it would have sat in the flow,
     which is the head of column one. It stays flush with the body text without
     anyone having to restate .wrap's padding here. */
}

/* Every block carries its space BELOW it only, one grid row's worth. Adjacent
   margins collapse to that same row, so paragraph→title, title→paragraph and
   paragraph→paragraph are all exactly one line — and the grid holds whatever
   order the columns happen to break in. */
.risk p { margin: 0 0 var(--risk-line); }

.risk__title {
  color: var(--text-dim);
  font-weight: 600;
  font-size: 13px;
  line-height: var(--risk-line);
  margin: var(--risk-line) 0;
  break-after: avoid;
  break-inside: avoid;
  -webkit-column-break-after: avoid;
  -webkit-column-break-inside: avoid;
}
.risk__title strong { color: var(--text); font-weight: 700; letter-spacing: .01em; }
.risk__inner > :last-child { margin-bottom: 0; }

/* The footer's top margin exists to hold it off the end of a page's content.
   With the disclosure in between, that gap lands in the wrong place — 112px of
   nothing under the legal text — so it collapses when the two are adjacent. */
.risk + .site-footer { margin-top: 0; }

@media (max-width: 860px) {
  .risk__inner { columns: 1; }
}
@media (max-width: 700px) {
  .risk { font-size: 12.2px; padding-block: 26px 32px; }
}

/* --- footer --------------------------------------------------------------- */

.site-footer {
  margin-top: clamp(64px, 9vw, 112px);
  border-top: 1px solid var(--line);
  background: var(--bg-inset);
  padding-block: 56px 30px;
  font-size: 14.4px;
}
.site-footer__grid {
  display: grid; gap: 40px;
  grid-template-columns: minmax(0, 1.5fr) repeat(3, minmax(0, 1fr));
}
.site-footer__blurb { color: var(--text-dim); margin-top: 14px; max-width: 42ch; }
.site-footer__reg { color: var(--text-faint); margin-top: 12px; font-size: 13.2px; }

.site-footer__col { display: flex; flex-direction: column; gap: 10px; }
.site-footer__col-title {
  font-size: 12px; font-family: var(--mono); letter-spacing: .12em;
  text-transform: uppercase; color: var(--text-faint); font-weight: 600;
  margin-bottom: 4px;
}
.site-footer__col a { color: var(--text-dim); }
.site-footer__col a:hover { color: var(--accent-hi); }

/* NinjaTrader Ecosystem badge — quiet until hovered, like the rest of the
   footer. Explicit dimensions live on the <img> so it can never shift layout. */
.nt-badge { display: inline-block; margin-top: 18px; }
.nt-badge img { display: block; width: 180px; height: 30px; opacity: .72; transition: opacity .16s ease; }
.nt-badge:hover img { opacity: 1; }

/* Hero variant: the monochrome lockup's ECOSYSTEM half is black, so it is
   backlit from behind — a tight bright core inside a wide soft one, both
   fading to zero so the light has no edge of its own. The glow lives on
   ::before only: ::after would paint above the <img> and wash the logo out. */
.nt-badge--hero { position: relative; display: inline-block; margin-bottom: 20px; }
.nt-badge--hero::before {
  content: ''; position: absolute; inset: -38px -58px;
  border-radius: 50%;
  background:
    radial-gradient(closest-side, rgb(232 241 255 / .34), transparent 60%),
    radial-gradient(closest-side, rgb(169 205 245 / .18), transparent 82%);
  filter: blur(6px);
  opacity: .72;
  transition: opacity .18s ease;
}
.nt-badge--hero img {
  position: relative;
  width: 168px; height: 28px;
  opacity: .88;
  transition: opacity .18s ease, transform .18s ease;
}
.nt-badge--hero:hover::before { opacity: 1; }
.nt-badge--hero:hover img { opacity: 1; transform: translateY(-1px); }

.site-footer__bar {
  margin-top: 44px; padding-top: 24px;
  border-top: 1px solid var(--line);
  display: flex; flex-wrap: wrap; gap: 16px; justify-content: space-between;
  color: var(--text-faint); font-size: 13px;
}
.site-footer__disclaimer { max-width: 62ch; }

@media (max-width: 780px) {
  .site-footer__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .site-footer__brand { grid-column: 1 / -1; }
}

/* --- account & auth ------------------------------------------------------- */

.auth__wrap { display: grid; place-items: center; }
.auth__card {
  width: 100%; max-width: 440px;
  background: var(--bg-elev); border: 1px solid var(--line);
  border-radius: var(--radius); padding: clamp(28px, 5vw, 40px);
  box-shadow: var(--shadow-lg);
}
.auth__card--message { text-align: center; }
.auth__card h1 { font-size: clamp(24px, 3vw, 28px); }
.auth__sub { color: var(--text-dim); margin-top: 10px; font-size: 14.8px; }
.auth__form { display: grid; gap: 18px; margin-top: 26px; }
.auth__form .btn { width: 100%; }
.auth__row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.auth__links {
  margin-top: 22px; padding-top: 20px; border-top: 1px solid var(--line);
  font-size: 14px; color: var(--text-faint); text-align: center;
}
.auth__links span { margin-inline: 6px; opacity: .5; }
.auth__error {
  margin-top: 20px; padding: 12px 15px;
  border: 1px solid rgb(251 113 133 / .45); background: rgb(251 113 133 / .1);
  border-radius: var(--radius-sm); color: #fecdd3; font-size: 14.2px;
}
.auth__card--message .btn { margin-top: 24px; }

@media (max-width: 480px) { .auth__row { grid-template-columns: 1fr; } }

/* Public-side form fields. Same tokens as the panel so the two never drift into
   two different-looking inputs. */
.ff { display: grid; gap: 7px; }
.ff__label { font-size: 13.2px; color: var(--text-dim); font-weight: 570; }
.ff__input {
  width: 100%; padding: 11px 13px;
  background: var(--bg-inset); color: var(--text);
  border: 1px solid var(--line-strong); border-radius: var(--radius-sm);
  font-family: inherit; font-size: 15px;
}

/* --- reveal button on a password box --------------------------------------
 * The wrapper is created by js/pw-reveal.js, so nothing here applies unless the
 * button actually exists — no reserved space, no empty box, on a page whose
 * script did not load. Same class names as the panel so the two do not drift
 * into two different-looking controls. */
.pw-reveal { position: relative; display: block; }
.pw-reveal input { padding-right: 42px; }
.pw-reveal__btn {
  position: absolute; top: 50%; right: 7px; transform: translateY(-50%);
  width: 30px; height: 28px; padding: 0; line-height: 1;
  background: transparent; border: 0; border-radius: var(--radius-sm);
  color: var(--text-faint); cursor: pointer; font-size: 15px;
  /* 🔴 `opacity: .6` WAS HERE AND PUT THIS CONTROL AT 2.75:1 on both the sign-in
     and the sign-up form. --text-faint is tuned to 5.82:1; multiplying it by .6
     undid that tuning silently, the third instance of the same fault found on
     2026-08-16. And no value rescues it — .8 still measures only 4.07:1.
     The resting state is now the token itself, 5.83:1, and the button announces
     hover and `is-on` with COLOUR (--accent, 12.07:1) instead of opacity, which
     is the more legible signal anyway. The control is unchanged, only readable. */
}
.pw-reveal__btn:hover  { opacity: 1; color: var(--accent); }
.pw-reveal__btn.is-on  { opacity: 1; color: var(--accent); }
.pw-reveal__btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

/* ---------------------------------------------------------------------------
 * 🔴 EVERY <select> ON THE SITE. Do not scope this to one form.
 *
 * The open dropdown list is drawn by the OPERATING SYSTEM, not by the page, and
 * it only half-inherits: it takes the near-white `color` off the select but
 * keeps its own white background — so every option except the hovered one is
 * white on white and the list looks like a blank rectangle.
 *
 * This was found and fixed once on the contact form, and the fix was written as
 * `.contact-form select.ff__input option`. Every select added afterwards — the
 * checkout's three, the panel's ten — was born broken again, because the cure
 * was attached to the place the symptom was first seen rather than to the thing
 * that causes it. It is an element-level defect, so it gets an element-level
 * rule.
 *
 * Setting BOTH colours is the point: a browser that honours option styling gets
 * a matched pair, and one that ignores it falls back to the system's own pair
 * rather than a half-styled mix. `color-scheme: dark` on :root helps modern
 * browsers but does NOT cover this on Windows, which is where it was reported.
 * ------------------------------------------------------------------------- */
select option,
select optgroup {
  background-color: #0f1012;
  color: #f4f5f7;
}

/* The caret, drawn in CSS because appearance:none removes the native one.
   Global for the same reason as above: a select that looks different depending
   on which form it landed in is a second defect waiting to be reported. */
select.ff__input {
  cursor: pointer;
  appearance: none;
  padding-right: 40px;
  background-image:
    linear-gradient(45deg, transparent 50%, var(--text-dim) 50%),
    linear-gradient(135deg, var(--text-dim) 50%, transparent 50%);
  background-size: 6px 6px, 6px 6px;
  background-position: right 20px center, right 14px center;
  background-repeat: no-repeat;
}
.ff__input:focus { outline: 2px solid var(--accent); outline-offset: 1px; border-color: transparent; }
.ff__hint { font-size: 12.6px; color: var(--text-faint); }
.ff__error { font-size: 12.8px; color: var(--ask); }
.ff__check {
  display: flex; align-items: flex-start; gap: 10px;
  font-size: 14.2px; color: var(--text-dim); line-height: 1.5;
}
.ff__check input[type=checkbox] { width: 16px; height: 16px; margin-top: 2px; flex: none; accent-color: var(--accent); }

.account__head {
  display: flex; align-items: flex-start; justify-content: space-between;
  gap: 20px; margin-bottom: 44px;
}
.account__head form { margin: 0; }
.account__section {
  font-size: 19px; margin: 40px 0 18px;
  padding-bottom: 12px; border-bottom: 1px solid var(--line);
}
.account__table {
  width: 100%; border-collapse: collapse; font-size: 14.5px;
  background: var(--bg-elev); border: 1px solid var(--line);
  border-radius: var(--radius); overflow: hidden;
}
.account__table th {
  text-align: left; padding: 13px 18px; border-bottom: 1px solid var(--line);
  font-size: 11.5px; font-family: var(--mono); letter-spacing: .1em;
  text-transform: uppercase; color: var(--text-faint); font-weight: 600;
}
.account__table td { padding: 14px 18px; border-bottom: 1px solid var(--line); }
.account__table tr:last-child td { border-bottom: 0; }
.account__table code { font-family: var(--mono); font-size: 13px; color: var(--accent-hi); }
.account__empty {
  border: 1px dashed var(--line-strong); border-radius: var(--radius);
  padding: 30px; color: var(--text-faint); font-size: 14.5px;
}

/* --- my software ----------------------------------------------------------
 *
 * One row per licence the customer owns. A list rather than a second table:
 * the orders above are a ledger and read column by column, but each of these
 * is one object with one status, and the eye should land on the badge.
 *
 * 🔴 THE STATE COLOURS ARE A SEQUENCE, NOT A PALETTE. They run cool to warm
 * along the path the customer actually travels — generated, emailed, the 48h
 * window, downloaded, activated — so the badge says "how far along" before the
 * word is read. Only the last one is green: nothing before activation is done.
 * ⚠️ Amber on `window_48h` is the one that carries urgency; do not level these
 * out into five tints of the accent, which is what makes a status list stop
 * being scannable. */
.sw { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }

.sw__item {
  display: flex; align-items: center; justify-content: space-between; gap: 20px;
  background: var(--bg-elev); border: 1px solid var(--line);
  border-radius: var(--radius); padding: 17px 20px;
  transition: border-color .18s ease;
  /* ⚠️ `wrap` added 2026-08-20 with the licence panel. The row had always been
     a single line, and the panel joining it as one more flex child squeezed the
     product name into four. Wrapping changes nothing for the children that
     already fitted — they still share line one — it only lets a child that asks
     for the full width take a line of its own. */
  flex-wrap: wrap;
}
.sw__item:hover { border-color: var(--line-strong); }

.sw__main { min-width: 0; }
.sw__name { font-weight: 600; font-size: 15.5px; color: var(--text); margin: 0; }
.sw__note { margin: 4px 0 0; font-size: 13.5px; color: var(--text-faint); }

.sw__side { flex: none; text-align: right; }
.sw__seats {
  margin: 6px 0 0; font-size: 12px; font-family: var(--mono);
  color: var(--text-faint); letter-spacing: .04em;
}

.sw__badge {
  display: inline-block; padding: 4px 12px; border-radius: 100px;
  font-size: 12px; font-weight: 620; white-space: nowrap;
  border: 1px solid currentColor;
}
/* The ramp. Each is the border, the text and a wash of the same hue. */
.sw__badge--generated    { color: #7aa7d9; background: rgb(122 167 217 / .10); }
/* `ready` sits between "generated" and "emailed": the licence server has built
   the package and handed back a live link. Green rather than amber because it
   is not a deadline, it is the good news — and a different green from
   `activated`, which is a further step and must stay distinguishable. */
.sw__badge--ready        { color: #7fd39a; background: rgb(127 211 154 / .12); }
.sw__badge--link_emailed { color: #d9b46a; background: rgb(217 180 106 / .10); }
.sw__badge--window_48h   { color: #e0913f; background: rgb(224 145  63 / .12); }
.sw__badge--downloaded   { color: #5fc3bd; background: rgb( 95 195 189 / .10); }
.sw__badge--activated    { color: #5fbf7e; background: rgb( 95 191 126 / .12); }

/*
 * The download itself.
 *
 * On its own row under the name and badge, beside the service buttons rather
 * than inside them: it is the only control here that is not a REQUEST — the
 * other three ask the licence server for something and wait, this one is a file
 * that already exists.
 */
.sw__download {
  display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
  margin-top: 12px;
}
/* The expiry is text, not a tooltip. A link that dies without warning is a
   support message; on a touchscreen a `title` never appears at all. */
.sw__expires {
  font-size: 12px; color: var(--text-faint); letter-spacing: .01em;
}

/* The windowed state is the only one with a deadline, so the whole row carries
   it rather than just the pill — a customer scanning the list should see the
   one that expires without reading any of them.

   🔴 RENAMED 2026-08-20 WITH THE STATE ITSELF, and it was broken in between.
   The class is generated as `sw__item--<state>`, the state constant went from
   `window_48h` to `window` when the hard-coded 48 came out, and this selector
   silently stopped matching anything. Nothing failed; the border just quietly
   went away. A modifier built from a value in PHP has to be renamed on both
   sides at once — Guard 28 now checks that they still agree. */
.sw__item--window { border-color: rgb(224 145 63 / .38); }

/* ── licence status, under each product ────────────────────────────────────
   What the licence server reports: seats, the free reset, re-requests left.
   Quiet by design — it sits between the download button and the service
   buttons, and must not compete with either. */
.sw__lic {
  /* Its own line inside the wrapping row — see the note on .sw__item. */
  flex: 0 0 100%;
  margin: 4px 0 0;
  padding: 10px 12px;
  border: 1px solid var(--line);
  border-radius: 8px;
  display: grid;
  gap: 6px 18px;
  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
.sw__lic-row { display: flex; align-items: baseline; gap: 8px; margin: 0; }
.sw__lic-row dt {
  font-size: 12px; color: var(--text-dim);
  letter-spacing: .02em; white-space: nowrap;
}
.sw__lic-row dd { margin: 0; font-size: 13px; color: var(--text); font-weight: 600; }
/* ⚠️ `--text-dim`, not `--text-faint` with opacity. A faint token behind an
   opacity is how the contrast sweep found 209 failures — the tuned colour is
   discarded and no value of opacity brings it back. */
.sw__lic-sub { font-weight: 400; color: var(--text-dim); font-size: 12px; }

/* The three service buttons, on their own row under the name and badge so a
   long licence name never squeezes them. */
.sw__actions {
  flex-basis: 100%; order: 3;
  display: flex; flex-wrap: wrap; gap: 8px 10px;
  margin-top: 4px; padding-top: 14px; border-top: 1px solid var(--line);
}
.sw__form { margin: 0; }
.sw__price { font-family: var(--mono); font-size: 12px; opacity: .85; }

/* 🔴 AN UNAVAILABLE ACTION IS TEXT, NOT A DEAD BUTTON. A greyed-out control
   that cannot say why is the single most common reason somebody writes to
   support about an account page. The reason is rendered as a sentence rather
   than a title attribute, because a title never appears on a touchscreen — and
   it is not a <button> at all, so nothing invites a click that will not work. */
.sw__off {
  display: inline-flex; flex-direction: column; gap: 1px;
  padding: 6px 12px; border-radius: 8px;
  border: 1px dashed var(--line); color: var(--text-faint);
  font-size: 13px;
}
/*
 * 🔴 THE `opacity` HERE PUT THIS TEXT UNDER THE FLOOR, AND IT IS A RULE THIS
 * PROJECT HAS ALREADY WRITTEN DOWN ONCE.
 *
 * The parent is `--text-faint`, which is the faintest tuned colour there is.
 * Multiplying it by .72 measured 3.59:1 against a 4.5 floor — and no value of
 * opacity saves a colour that was already at the bottom of its range; that is
 * why the recorded rule is "opacity cancels a tuned colour", not "pick a bigger
 * opacity".
 *
 * ⚠️ IT SURVIVED THE 2026-08-16 CONTRAST SWEEP because the sweep never signed
 * in: every portal page answered the crawler with a redirect to the sign-in
 * form, so 54 pages were measured and not one of them was this. Found on
 * 2026-08-20 only because a probe happened to run inside a logged-in session.
 * The sentence it made illegible is the one that tells a customer WHY a button
 * is disabled — the exact text that stops them writing to support.
 */
.sw__off em { font-style: normal; font-size: 11.5px; }

.sw__provisional {
  margin: 12px 0 0; font-size: 12.5px; color: var(--text-faint);
  border-left: 2px solid var(--line-strong); padding-left: 12px;
}

/* --- my requests ----------------------------------------------------------- */

.req { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
.req__item {
  display: flex; align-items: flex-start; justify-content: space-between; gap: 18px;
  background: var(--bg-elev); border: 1px solid var(--line);
  border-radius: var(--radius-sm); padding: 14px 18px;
}
.req__what { margin: 0; font-weight: 600; font-size: 14.5px; color: var(--text); }
.req__when {
  margin: 3px 0 0; font-size: 12px; font-family: var(--mono);
  color: var(--text-faint); letter-spacing: .03em;
}
.req__msg { margin: 7px 0 0; font-size: 13px; color: var(--text-dim); max-width: 62ch; }

.req__side { flex: none; text-align: right; }
.req__amount { margin: 6px 0 0; font-family: var(--mono); font-size: 13px; color: var(--text-dim); }
.req__pay { margin: 10px 0 0; }

/* --- a ticket thread ------------------------------------------------------- */

/* The two sides are told apart by WHICH EDGE carries the accent, not by sitting
   on opposite sides of the page. A chat-bubble layout wastes half the width on
   a support thread, where the messages are long and often pasted. */
.thread { list-style: none; margin: 0 0 30px; padding: 0; display: grid; gap: 12px; }

.thread__msg {
  background: var(--bg-elev); border: 1px solid var(--line);
  border-left: 3px solid var(--line-strong);
  border-radius: var(--radius-sm); padding: 16px 20px;
}
.thread__msg--staff { border-left-color: var(--accent); }

/* 🔴 AN INTERNAL NOTE HAS TO LOOK WRONG TO SEND. It is in the same thread as
   the customer's own words, and the one mistake that matters here is an
   operator typing a note into the reply box by habit. Hatched and warm, so it
   never reads as part of the conversation. */
.thread__msg--internal {
  border-left-color: #e0913f;
  background:
    repeating-linear-gradient(135deg,
      transparent 0 9px, rgb(224 145 63 / .05) 9px 18px),
    var(--bg-elev);
}
.thread__tag {
  font-style: normal; font-size: 11px; font-weight: 620;
  color: #e0913f; text-transform: uppercase; letter-spacing: .05em;
}

.thread__who {
  margin: 0 0 8px; font-size: 12.5px; font-weight: 620; color: var(--text);
  display: flex; flex-wrap: wrap; align-items: baseline; gap: 10px;
}
.thread__who span {
  font-weight: 400; font-family: var(--mono); font-size: 11.5px;
  color: var(--text-faint);
}
.thread__body { font-size: 14.5px; color: var(--text-dim); line-height: 1.65; max-width: 78ch; }

/* --- the ticket forms ------------------------------------------------------ */

.tform { display: grid; gap: 16px; max-width: 70ch; }
.tform__row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.tform__field, .tform__row label { display: grid; gap: 6px; }
.tform span { font-size: 13px; font-weight: 600; color: var(--text); }
.tform span em { font-style: normal; font-weight: 400; color: var(--text-faint); }
.tform input[type=text], .tform select, .tform textarea {
  width: 100%; padding: 11px 14px;
  background: var(--bg); border: 1px solid var(--line);
  border-radius: var(--radius-sm); color: var(--text);
  font: inherit; font-size: 14.5px;
}
.tform textarea { resize: vertical; min-height: 120px; line-height: 1.6; }
.tform input:focus, .tform select:focus, .tform textarea:focus {
  outline: none; border-color: var(--accent);
}
.tform__check {
  display: flex; align-items: flex-start; gap: 10px;
  font-size: 13px; color: var(--text-dim);
}
.tform__check input { margin-top: 3px; }
.tform button { justify-self: start; }

@media (max-width: 620px) {
  .tform__row { grid-template-columns: 1fr; }
}

.req__state {
  display: inline-block; padding: 3px 11px; border-radius: 100px;
  font-size: 11.5px; font-weight: 620; white-space: nowrap;
  border: 1px solid currentColor;
}
/* Three groups, not ten colours: waiting on us, waiting on you, and finished.
   A customer scanning the list needs to know which ones need them to act. */
.req__state--requested,
.req__state--paid,
.req__state--manual_review    { color: #7aa7d9; background: rgb(122 167 217 / .10); }
.req__state--payment_required { color: #e0913f; background: rgb(224 145  63 / .12); }
.req__state--completed        { color: #5fbf7e; background: rgb( 95 191 126 / .12); }
.req__state--rate_limited,
.req__state--not_allowed,
.req__state--denied,
.req__state--cancelled,
.req__state--failed           { color: var(--text-faint); background: transparent; }

/* --- a flash line on the account page -------------------------------------- */

.note {
  margin: 0 0 22px; padding: 13px 18px; border-radius: var(--radius-sm);
  font-size: 14px; border: 1px solid var(--line);
}
.note--ok   { color: #5fbf7e; border-color: rgb( 95 191 126 / .40); background: rgb( 95 191 126 / .08); }
.note--warn { color: #e0913f; border-color: rgb(224 145  63 / .40); background: rgb(224 145  63 / .08); }

@media (max-width: 620px) {
  .sw__item { flex-direction: column; align-items: flex-start; gap: 12px; }
  .sw__side { text-align: left; }
  .req__item { flex-direction: column; }
  .req__side { text-align: left; }
}
.pill {
  display: inline-block; padding: 3px 11px; border-radius: 100px;
  font-size: 12px; font-weight: 600; text-transform: capitalize;
  border: 1px solid var(--line-strong); color: var(--text-dim);
}

/* --- blog ----------------------------------------------------------------- */

.blog-head { padding-bottom: 40px; }
.blog-head h1 { margin-bottom: 18px; max-width: 18ch; }

.posts {
  display: grid; gap: 18px;
  grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
}
.post-card {
  display: flex; flex-direction: column;
  background: var(--bg-elev); border: 1px solid var(--line);
  border-radius: var(--radius); padding: 28px;
  transition: border-color .18s ease, transform .18s ease;
}
.post-card:hover { border-color: var(--line-strong); transform: translateY(-2px); }
.post-card--illustrated { padding: 0; overflow: hidden; }
.post-card--illustrated > *:not(.post-card__figure) { margin-inline: 28px; }
.post-card--illustrated > *:last-child { margin-bottom: 28px; }

.post-card__figure {
  display: block; margin: 0 0 22px; background: var(--bg-inset);
  border-bottom: 1px solid var(--line);
}
.post-card__figure img { width: 100%; height: auto; display: block; }

.post-card__meta {
  font-family: var(--mono); font-size: 11.5px; letter-spacing: .07em;
  text-transform: uppercase; color: var(--text-faint); margin-bottom: 14px;
}
/* Same fault as .article__meta below, and a reminder that identical markup can
   sit under two different rules: the card on /blog uses THIS class, so fixing
   the article page alone left the index at 2.26:1 and the re-measurement is the
   only reason that was noticed. Separators keep the dimming; the byline does
   not, because it is text somebody is meant to read. */
.post-card__meta .article__sep { margin-inline: 7px; opacity: .5; }
.post-card__title { font-size: 20px; line-height: 1.3; margin-bottom: 12px; }
.post-card__title a { color: var(--text); }
.post-card__title a:hover { color: var(--accent-hi); }
.post-card__excerpt { color: var(--text-dim); font-size: 14.8px; margin-bottom: 20px; }
.post-card__more { margin-top: auto; font-size: 14px; font-weight: 600; }

/* --- article -------------------------------------------------------------- */

.article__wrap { max-width: 760px; margin-inline: auto; }

.article__crumbs { font-size: 13px; color: var(--text-faint); margin-bottom: 22px; }
.article__crumbs a { color: var(--text-dim); }
.article__crumbs span { margin-inline: 8px; opacity: .5; }

.article h1 { font-size: clamp(30px, 4.4vw, 44px); margin-bottom: 18px; }

.article__meta {
  font-family: var(--mono); font-size: 12px; letter-spacing: .07em;
  text-transform: uppercase; color: var(--text-faint);
}
/*
 * 🔴 THIS WAS `.article__meta span`, AND `span` WAS STANDING IN FOR "SEPARATOR".
 * The row also holds <span rel="author">By ICF Market</span>, so the rule dimmed
 * the byline too: --text-faint is tuned to 5.82:1, and opacity .5 on top of it
 * measured 2.17:1. Measured 2026-08-16 on all three blog pages.
 *
 * ⚠️ AND NO OPACITY VALUE FIXES IT — .70 gives 3.29:1 and even .85 only 4.42:1,
 * still under the floor. The dimming had to come off the text, not be softened.
 * The separators keep it because a `·` between two facts carries no meaning and
 * WCAG 1.4.3 exempts decoration; they now say so in their own class name rather
 * than being identified by their tag.
 */
.article__meta .article__sep { margin-inline: 7px; opacity: .5; }

.article__standfirst {
  font-size: clamp(17px, 1.7vw, 19.5px); line-height: 1.6;
  color: var(--text); margin: 26px 0 0;
  padding-left: 20px; border-left: 2px solid var(--accent);
}

.article__figure {
  margin: 34px 0 0;
  background: var(--bg-inset);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  overflow: hidden;
}
.article__figure img { width: 100%; height: auto; display: block; }

/* Long-form measure and rhythm. An article is read, not scanned. */
.article__body { margin-top: 40px; font-size: 17px; line-height: 1.78; max-width: none; }
.article__body h2 {
  font-size: clamp(22px, 2.6vw, 27px); margin: 2em 0 .7em;
  padding-top: 1.2em; border-top: 1px solid var(--line);
  color: var(--text);
}
.article__body h3 { font-size: 19px; margin: 1.7em 0 .5em; color: var(--text); }
.article__body p + p { margin-top: 1.15em; }
.article__body ul, .article__body ol { margin: 1.3em 0; }
.article__body ol { padding-left: 1.4em; }
.article__body li { margin-bottom: .6em; }
.article__body blockquote {
  margin: 1.8em 0; padding: 20px 24px;
  background: var(--bg-elev); border-left: 2px solid var(--accent);
  border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
  color: var(--text); font-size: 16.5px;
}
.article__body blockquote p + p { margin-top: .8em; }
.article__body code {
  font-family: var(--mono); font-size: .87em;
  background: var(--bg-inset); border: 1px solid var(--line);
  padding: 2px 6px; border-radius: 5px; color: var(--accent-hi);
}
.article__body table {
  width: 100%; border-collapse: collapse; margin: 1.8em 0; font-size: 15px;
  display: block; overflow-x: auto;
}
.article__body th, .article__body td {
  padding: 11px 14px; border-bottom: 1px solid var(--line); text-align: left;
}
.article__body th {
  font-size: 12px; font-family: var(--mono); letter-spacing: .08em;
  text-transform: uppercase; color: var(--text-faint);
}
.article__body a { text-decoration: underline; text-underline-offset: 3px; }

/* --- specification table -------------------------------------------------- *

   The spec-table block. Every class here is new, so nothing in theme-e5.css —
   which loads after this file — has a rule that can outrank it. That is luck
   rather than design: if a later theme ever styles `table`, these need the
   two-class treatment the hero needed.

   The grid is `width: max-content` on purpose. A percentage width makes the
   browser distribute the space by wrapping cells, and an eleven-column
   specification table wrapped to fit is unreadable in a way that looks like a
   content problem. Content width plus a scrolling box keeps every row one line
   high, which is what makes the grid scannable at all.                        */

.spec-table__intro { margin-bottom: 22px; }

.spec-table__hint {
  font-size: 12.5px; color: var(--text-faint); font-family: var(--mono);
  letter-spacing: .04em; margin-bottom: 10px;
}

.spec-table__scroll {
  overflow-x: auto;
  border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--bg-elev);
  /* Focusable, because a box that scrolls has to be reachable without a mouse.
     The ring is on :focus-visible so a mouse click does not light it up. */
  scrollbar-color: var(--line-strong) transparent;
}
.spec-table__scroll:focus-visible {
  outline: 2px solid var(--accent); outline-offset: 2px;
}

.spec-table__grid {
  width: max-content; min-width: 100%;
  border-collapse: collapse;
  font-size: 14.5px;
}

.spec-table__grid thead th {
  padding: 13px 16px; border-bottom: 1px solid var(--line-strong);
  font-size: 11px; font-family: var(--mono); letter-spacing: .09em;
  text-transform: uppercase; color: var(--text-faint); font-weight: 600;
  white-space: nowrap; vertical-align: bottom;
}

.spec-table__grid tbody th,
.spec-table__grid tbody td {
  padding: 12px 16px; border-bottom: 1px solid var(--line); white-space: nowrap;
}
.spec-table__grid tbody tr:last-child th,
.spec-table__grid tbody tr:last-child td { border-bottom: 0; }

/* Figures in the mono face with tabular figures, so a column of numbers lines
   up digit over digit. Proportional numerals in a spec table defeat the point
   of putting them in a column. */
.spec-table__grid td {
  font-family: var(--mono); font-variant-numeric: tabular-nums;
  font-size: 13.5px; color: var(--text);
}

.spec-table__grid .is-right { text-align: right; }
.spec-table__grid .is-left  { text-align: left; }

/* The row label stays put while the rest scrolls. On an eleven-column table the
   contract symbol is off-screen by the time you reach the stop distance, and a
   figure you cannot attribute to an instrument is worse than no figure. */
.spec-table__grid tbody th[scope="row"],
.spec-table__grid thead th:first-child {
  position: sticky; left: 0; z-index: 1;
  background: var(--bg-elev);
  border-right: 1px solid var(--line);
  font-weight: 600; color: var(--text);
}
.spec-table__grid tbody th[scope="row"] { font-size: 14px; }

/* The summary row — the reversal probability, which is not one more ladder. */
.spec-table__grid--sum tbody tr:last-child th,
.spec-table__grid--sum tbody tr:last-child td {
  border-top: 1px solid var(--line-strong);
  background: var(--bg-inset); color: var(--accent-hi);
}

.spec-table__notes {
  list-style: none; margin: 16px 0 0; padding: 0;
  display: grid; gap: 9px;
  font-size: 13.5px; color: var(--text-dim); max-width: 78ch;
}
.spec-table__notes li { padding-left: 16px; position: relative; line-height: 1.6; }
.spec-table__notes li::before {
  content: ''; position: absolute; left: 0; top: .62em;
  width: 5px; height: 5px; border-radius: 50%; background: var(--line-strong);
}

@media (max-width: 640px) {
  .spec-table__grid thead th { padding: 11px 12px; }
  .spec-table__grid tbody th,
  .spec-table__grid tbody td { padding: 10px 12px; }
}

/* --- education search ----------------------------------------------------- *

   The search box directly under the hero on /education, and the results that
   drop out of it. NOTHING IS VISIBLE UNTIL SOMETHING IS TYPED — the owner
   removed the always-on list of lessons on 2026-08-10 because a page that lists
   everything under a search box has not been searched, it has been scrolled.

   🔴 THE [hidden] RULE BELOW IS LOAD-BEARING, NOT TIDINESS. `[hidden]` lives in
   the browser's own stylesheet, and ANY author rule setting `display` beats it
   regardless of specificity — so `.edu-search__form { display: flex }` alone
   would show the form to a reader with no JavaScript, which is exactly the dead
   control the hidden attribute exists to prevent. Two classes, so it also
   outranks the rules underneath it.                                            */

.edu-search [hidden] { display: none; }

/* 🔴 TWO CLASSES, AND IT HAS TO BE. `theme-e5.css` sets
   `.section { padding-block: clamp(64px,9vw,112px) }` and loads AFTER this file,
   so a single-class `.edu-search` rule loses silently — no error, no warning,
   just 112px of nothing above and below a search field. Measured: 276px tall for
   a 52px control. `.section.checkout` and `.section.pay` above solve it the same
   way. Always read the computed style; never trust that the rule was written. */
.section.edu-search { padding-block: clamp(6px, 1vw, 14px) clamp(28px, 4vw, 48px); }
.edu-search__intro { margin-bottom: 20px; max-width: 70ch; }

/* The positioning context for the results. Centred under the centred hero. */
.edu-search__box { position: relative; max-width: 620px; margin: 0 auto; }

.edu-search__form { display: flex; gap: 10px; }
.edu-search__field { flex: 1 1 auto; display: block; min-width: 0; }
.edu-search__field input {
  width: 100%; padding: 13px 16px; font: inherit; font-size: 15px;
  background: var(--bg-inset); color: var(--text);
  border: 1px solid var(--line-strong); border-radius: var(--radius-sm);
}
.edu-search__field input::placeholder { color: var(--text-faint); }
.edu-search__field input:focus {
  outline: none; border-color: var(--accent);
  box-shadow: 0 0 0 3px rgb(56 189 248 / .18);
}
.edu-search__go { flex: 0 0 auto; }

/* Overlays what is beneath rather than pushing it down. A panel that reflows the
   page on every keystroke makes the cards below jump while you are still typing,
   and the reader's eye follows the movement instead of the results. */
.edu-search__panel {
  position: absolute; z-index: 30; left: 0; right: 0; top: calc(100% + 8px);
  max-height: min(62vh, 560px); overflow-y: auto;
  padding: 6px 18px 14px;
  background: var(--bg-elev);
  border: 1px solid var(--line-strong); border-radius: var(--radius);
  box-shadow: 0 22px 48px rgb(0 0 0 / .55);
}

.edu-search__status {
  position: sticky; top: 0; z-index: 1;
  margin: 0; padding: 10px 0 8px;
  background: var(--bg-elev);
  font-family: var(--mono); font-size: 11.5px; letter-spacing: .05em;
  color: var(--text-faint);
}

.edu-search__list { list-style: none; margin: 0; padding: 0; }
.edu-row { border-top: 1px solid var(--line); padding: 13px 0; }
.edu-row[hidden] { display: none; }
.edu-row:first-child { border-top: 0; }

.edu-row__link {
  display: block; text-decoration: none;
  margin: -6px -10px 0; padding: 6px 10px; border-radius: var(--radius-sm);
}
.edu-row__link:hover { background: rgb(56 189 248 / .07); }
.edu-row__meta { display: flex; flex-wrap: wrap; gap: 9px; align-items: baseline; margin-bottom: 3px; }
.edu-row__kicker,
.edu-row__group {
  font-family: var(--mono); font-size: 10px;
  letter-spacing: .11em; text-transform: uppercase;
}
.edu-row__kicker { color: var(--accent-hi); }
.edu-row__group  { color: var(--text-faint); }
.edu-row__title { display: block; font-size: 15.5px; font-weight: 600; color: var(--text); line-height: 1.35; }
.edu-row__link:hover .edu-row__title,
.edu-row__link:focus-visible .edu-row__title { color: var(--accent-hi); }

/* Two lines at most. A result is a signpost — the page it points at is where
   the reading happens, and a five-line excerpt pushes the next result off. */
.edu-row__summary {
  margin: 5px 0 0; font-size: 13px; line-height: 1.55; color: var(--text-dim);
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
/* A snippet is a cut out of the page's own text rather than its summary, so it
   is set in the mono face — the reader should be able to see at a glance that
   this line is quoted from further in rather than written for the card. */
.edu-row__summary.is-snippet { font-family: var(--mono); font-size: 12px; }
.edu-row__summary mark {
  background: rgb(56 189 248 / .2); color: var(--accent-hi);
  padding: 1px 3px; border-radius: 3px;
}

.edu-search__empty { padding: 6px 0 8px; color: var(--text-dim); font-size: 14px; line-height: 1.6; }
.edu-search__empty a { color: var(--accent-hi); }

@media (max-width: 560px) {
  .edu-search__form { flex-wrap: wrap; }
  .edu-search__go { width: 100%; }
}

/* --- error page ----------------------------------------------------------- */

.error-page { padding-block: clamp(72px, 12vw, 140px); text-align: center; }
.error-page__code {
  font-family: var(--mono); font-size: 13px; letter-spacing: .16em;
  color: var(--accent); text-transform: uppercase; margin-bottom: 16px;
}
.error-page h1 { margin-bottom: 16px; }
.error-page p { color: var(--text-dim); max-width: 52ch; margin-inline: auto; }
.error-page .btn-row { justify-content: center; }

/* ==========================================================================
   THE CUSTOMER PORTAL SHELL — /account
   ==========================================================================

   🔴 A SEPARATE NAMESPACE ON PURPOSE, AND THE REASON IS LOAD ORDER.
   theme-e5.css is generated from theme-e.css and is linked AFTER this file, so
   anything both files name is decided by theme-e5, not by whichever rule looks
   more specific in here. That has silently reverted four earlier changes.

   theme-e5.css names `.account__table`, `.account__empty` and `.pill`. It knows
   nothing about `acct-*`. So the shell is built entirely out of new class names
   rather than by extending the old `.account__*` ones, and cannot be overridden
   by a file it has never heard of.

   The one place the two must meet is the starfield: theme-e5 paints it on
   `body::before`. `body.acct-body::before` carries one more class, so it wins on
   specificity regardless of which file loads last — which is the point, because
   relying on order here is exactly the trap above.
   ⚠️ Verified by reading the COMPUTED style in the browser, not by reading this
   file. A rule that looks right and loses is the failure mode.
   ========================================================================== */

.acct-body { background: var(--bg); }

/* The portal is a flat panel, not the public site's sky. Both pseudo-elements
   go: ::before is the starfield, ::after is a lesson constellation or nebula
   that a page under /account would never set — but it costs nothing to be
   certain, and "no decoration reaches this area" is easier to keep true than
   "no decoration except the ones we listed". */
.acct-body::before,
.acct-body::after { display: none !important; content: none !important; }

/* --- top bar -------------------------------------------------------------- */

.acct-top {
  position: sticky; top: 0; z-index: 50;
  background: var(--bg-elev);
  border-bottom: 1px solid var(--line);
}
.acct-top__inner {
  max-width: 1240px; margin-inline: auto;
  padding: 0 clamp(16px, 3vw, 28px);
  height: 64px; display: flex; align-items: center; gap: 20px;
}
.acct-top__brand { margin-right: auto; }

.acct-top__user { display: flex; align-items: center; gap: 12px; }
.acct-top__name {
  font-size: 14px; color: var(--text); font-weight: 500;
  white-space: nowrap; max-width: 22ch; overflow: hidden; text-overflow: ellipsis;
}
.acct-top__out { margin: 0; }

.acct-avatar {
  width: 34px; height: 34px; flex: none;
  display: grid; place-items: center;
  border-radius: 50%;
  background: linear-gradient(150deg, var(--accent-dim), var(--bg-elev-2));
  border: 1px solid var(--line-strong);
  color: var(--text); font-size: 14px; font-weight: 600; line-height: 1;
}

/* --- shell: sidebar + column ---------------------------------------------- */

.acct-shell {
  max-width: 1240px; margin-inline: auto;
  padding: 0 clamp(16px, 3vw, 28px);
  display: grid; grid-template-columns: 232px minmax(0, 1fr);
  gap: clamp(20px, 3vw, 40px);
  align-items: start;
}

.acct-side { position: sticky; top: 88px; padding-block: 28px; }

.acct-nav { display: flex; flex-direction: column; gap: 2px; }
.acct-nav__item {
  display: flex; align-items: center; gap: 11px;
  padding: 9px 12px; border-radius: 9px;
  border: 1px solid transparent;
  color: var(--text-dim); font-size: 14px; line-height: 1.3;
  text-decoration: none;
  transition: background .16s var(--ease-out), color .16s var(--ease-out);
}
.acct-nav__item:hover { background: var(--bg-elev); color: var(--text); }

/* The framed active item from the reference portal. The frame — not just a
   colour — is what makes it readable at a glance, and it is paired with
   aria-current in the markup so the state is not colour-only. */
.acct-nav__item.is-current {
  background: var(--bg-elev-2);
  border-color: var(--line-strong);
  color: var(--text);
}
.acct-nav__icon { width: 18px; height: 18px; flex: none; opacity: .85; }
.acct-nav__item.is-current .acct-nav__icon { opacity: 1; color: var(--accent); }
.acct-nav__label { flex: 1 1 auto; }

.acct-nav__badge {
  flex: none; min-width: 20px; padding: 1px 6px;
  border-radius: 999px; background: var(--accent-dim);
  color: var(--text); font-size: 11px; font-weight: 600;
  text-align: center; line-height: 18px;
}
/* Not a badge — a label. Muted so it reads as "later", never as a count. */
.acct-nav__soon {
  flex: none; font-size: 10px; letter-spacing: .08em; text-transform: uppercase;
  color: var(--text-faint);
}

.acct-main { padding-block: 28px 56px; min-width: 0; }

/* --- page headings inside the shell --------------------------------------- */

.acct-head { margin-bottom: 24px; }
.acct-head--split { display: flex; align-items: flex-start; gap: 16px; justify-content: space-between; }
.acct-head__eyebrow {
  font-family: var(--mono); font-size: 12px; letter-spacing: .12em;
  text-transform: uppercase; color: var(--accent); margin-bottom: 6px;
}
.acct-head__title { font-size: clamp(24px, 3vw, 30px); margin: 0 0 6px; }
.acct-head__lede { color: var(--text-dim); font-size: 15px; margin: 0; max-width: 62ch; }
.acct-h2 { font-size: 17px; margin: 32px 0 14px; }

/* --- welcome card --------------------------------------------------------- */

.acct-welcome {
  background: var(--bg-elev);
  border: 1px solid var(--line);
  border-radius: 14px;
  padding: clamp(18px, 2.4vw, 26px);
  margin-bottom: 20px;
}
.acct-welcome__eyebrow {
  font-family: var(--mono); font-size: 12px; letter-spacing: .12em;
  text-transform: uppercase; color: var(--accent); margin: 0 0 6px;
}
.acct-welcome__name { font-size: clamp(22px, 3vw, 28px); margin: 0 0 8px; }
.acct-welcome__meta { color: var(--text-dim); font-size: 14px; margin: 0; }
.acct-welcome__sep { margin-inline: 8px; color: var(--text-faint); }

/* --- the four tiles ------------------------------------------------------- */

.acct-tiles {
  display: grid; grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 12px; margin-bottom: 28px;
}
.acct-tile {
  display: flex; flex-direction: column; gap: 4px;
  background: var(--bg-elev); border: 1px solid var(--line);
  border-radius: 12px; padding: 16px 18px;
  text-decoration: none; color: inherit;
  transition: border-color .16s var(--ease-out), background .16s var(--ease-out);
}
.acct-tile:hover { border-color: var(--line-strong); background: var(--bg-elev-2); }
.acct-tile__label {
  font-family: var(--mono); font-size: 11px; letter-spacing: .1em;
  text-transform: uppercase; color: var(--text-dim);
}
.acct-tile__value { font-size: 30px; line-height: 1.1; font-weight: 600; color: var(--text); }
.acct-tile__note { font-size: 12px; color: var(--text-faint); }
/* The em-dash tile. Dimmed so it does not read as a number that happens to be
   unusual — it is a different kind of answer. */
.acct-tile--unknown .acct-tile__value { color: var(--text-faint); font-weight: 400; }

/* --- feature cards -------------------------------------------------------- */

.acct-cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.acct-card {
  display: flex; gap: 14px; align-items: flex-start;
  background: var(--bg-elev); border: 1px solid var(--line);
  border-radius: 12px; padding: 18px 20px;
  text-decoration: none; color: inherit;
  transition: border-color .16s var(--ease-out), background .16s var(--ease-out);
}
a.acct-card:hover { border-color: var(--line-strong); background: var(--bg-elev-2); }
.acct-card--soon { opacity: .72; }
.acct-card__icon { flex: none; width: 26px; height: 26px; color: var(--accent); }
.acct-card__icon svg { width: 100%; height: 100%; }
.acct-card__body { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.acct-card__title { font-size: 16px; font-weight: 600; color: var(--text); }
.acct-card__text { font-size: 13.5px; line-height: 1.6; color: var(--text-dim); }
.acct-card__badge {
  margin-left: 8px; padding: 2px 7px; border-radius: 999px;
  border: 1px solid var(--line-strong);
  font-family: var(--mono); font-size: 10px; letter-spacing: .08em;
  text-transform: uppercase; color: var(--text-faint); vertical-align: middle;
}

/* --- empty and not-connected states --------------------------------------- */

.acct-empty, .acct-pending {
  background: var(--bg-elev); border: 1px solid var(--line);
  border-radius: 12px; padding: clamp(20px, 3vw, 30px);
  color: var(--text-dim); font-size: 14.5px; line-height: 1.7;
}
.acct-empty__title, .acct-pending__what {
  color: var(--text); font-size: 16px; font-weight: 600; margin: 0 0 8px;
}
.acct-empty p + p, .acct-pending p + p { margin-top: 12px; }
/* Dashed, so "laid out but not wired" is visible before the words are read. */
.acct-pending { border-style: dashed; }
.acct-pending__tag {
  font-family: var(--mono); font-size: 11px; letter-spacing: .1em;
  text-transform: uppercase; color: var(--text-faint); margin: 0 0 10px;
}
.acct-pending__why { margin: 0; max-width: 64ch; }
.acct-fineprint { margin-top: 16px; font-size: 13px; color: var(--text-faint); }

/* --- notes (flash messages) ----------------------------------------------- */

.acct-note {
  border: 1px solid var(--line); border-radius: 10px;
  padding: 12px 16px; margin-bottom: 20px; font-size: 14px;
}
.acct-note--ok   { border-color: rgb(74 222 128 / .38); color: #bbf7d0; background: rgb(74 222 128 / .07); }
.acct-note--warn { border-color: rgb(251 191 36 / .38); color: #fde68a; background: rgb(251 191 36 / .07); }

/* --- buttons in the shell -------------------------------------------------- */
/* Deliberately not .btn: that one is themed by theme-e5.css, and a portal
   button inheriting a marketing button's sizing was how the old page ended up
   with a "Sign out" the same weight as "Buy now". */

.acct-btn {
  display: inline-block; padding: 8px 14px; border-radius: 9px;
  border: 1px solid var(--line-strong); background: transparent;
  color: var(--text); font-size: 13.5px; font-family: inherit;
  cursor: pointer; text-decoration: none; line-height: 1.3;
  transition: background .16s var(--ease-out), border-color .16s var(--ease-out);
}
.acct-btn:hover { background: var(--bg-elev-2); border-color: var(--accent-dim); }
.acct-btn--primary { background: var(--accent-dim); border-color: var(--accent-dim); }
.acct-btn--primary:hover { background: var(--accent); color: #06121f; }

.acct-tablewrap { overflow-x: auto; }

/* Visually hidden, still read aloud — the tile's em dash needs a word. */
.acct-sr {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* --- footer: removed 2026-08-12 -------------------------------------------
   The portal had a one-line "Designed and secured by ICF Market" footer with a
   rule above it. The owner struck out BOTH — the rule and the wording — so the
   rules are gone rather than left orphaned for a future reader to reattach.
   The shell now ends where its content ends. */

/* --- narrow screens -------------------------------------------------------- */

@media (max-width: 900px) {
  /* The sidebar becomes a scrolling row above the content. It stays a real
     <nav> with the same items — a hamburger would hide the one thing this
     redesign exists to add. */
  .acct-shell { grid-template-columns: minmax(0, 1fr); gap: 0; }
  .acct-side {
    position: static; padding-block: 14px 0;
    border-bottom: 1px solid var(--line);
    margin-inline: calc(clamp(16px, 3vw, 28px) * -1);
    padding-inline: clamp(16px, 3vw, 28px);
  }
  .acct-nav {
    flex-direction: row; gap: 6px;
    overflow-x: auto; scrollbar-width: none; padding-bottom: 12px;
  }
  .acct-nav::-webkit-scrollbar { display: none; }
  .acct-nav__item { flex: none; }
  .acct-main { padding-block: 22px 44px; }
  .acct-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .acct-cards { grid-template-columns: minmax(0, 1fr); }
}

@media (max-width: 560px) {
  .acct-top__name { display: none; }
  .acct-top__inner { gap: 12px; }
  .acct-head--split { flex-direction: column; }
}

/* --- security: facts and the activity list --------------------------------- */

.acct-sec-facts {
  display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 12px; margin-bottom: 8px;
}
.acct-sec-fact {
  background: var(--bg-elev); border: 1px solid var(--line);
  border-radius: 12px; padding: 14px 16px;
}
.acct-sec-fact__label {
  font-family: var(--mono); font-size: 11px; letter-spacing: .1em;
  text-transform: uppercase; color: var(--text-dim); margin: 0 0 6px;
}
.acct-sec-fact__value { font-size: 15px; color: var(--text); margin: 0; word-break: break-word; }
.acct-sec-fact__note { font-size: 12.5px; color: var(--text-faint); margin: 4px 0 0; }
.acct-sec-fact__note a { color: var(--accent-hi); }

.acct-events { list-style: none; margin: 0; padding: 0; }
.acct-event {
  display: flex; align-items: baseline; gap: 12px;
  padding: 12px 0; border-bottom: 1px solid var(--line);
}
.acct-event:last-child { border-bottom: 0; }

/* The dot is the only colour on the row. A failed attempt has to be findable
   while scanning without reading every line — but it is amber, not red: a
   mistyped password is the commonest cause and red would read as a breach. */
.acct-event__dot {
  flex: none; width: 7px; height: 7px; border-radius: 50%;
  background: var(--accent-dim); transform: translateY(-1px);
}
.acct-event--failed .acct-event__dot { background: #fbbf24; }

.acct-event__body { flex: 1 1 auto; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.acct-event__what { font-size: 14.5px; color: var(--text); }
.acct-event--failed .acct-event__what { color: #fde68a; }
.acct-event__where { font-size: 12.5px; color: var(--text-faint); }
.acct-event__when {
  flex: none; font-family: var(--mono); font-size: 12px;
  color: var(--text-dim); white-space: nowrap;
}

@media (max-width: 900px) {
  .acct-sec-facts { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 560px) {
  /* The timestamp drops under the event rather than squeezing it to two words. */
  .acct-event { flex-wrap: wrap; }
  .acct-event__when { width: 100%; padding-left: 19px; }
}

/* --- welcome card: the way back to the public site ------------------------- */
/* The card becomes a two-column row so the button sits opposite the name, where
   the eye already is after reading it. It wraps under on a narrow screen rather
   than squeezing the name. */

.acct-welcome { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.acct-welcome__text { flex: 1 1 260px; min-width: 0; }
.acct-welcome__out {
  flex: none; display: inline-flex; align-items: center; gap: 8px;
  white-space: nowrap;
}
.acct-welcome__out-icon { width: 16px; height: 16px; flex: none; }

/* --- password form -------------------------------------------------------- */

.acct-form-card {
  background: var(--bg-elev); border: 1px solid var(--line);
  border-radius: 12px; padding: clamp(18px, 2.4vw, 24px);
  max-width: 46rem;
}
.acct-form-card__note {
  margin: 0 0 18px; color: var(--text-dim); font-size: 14px; line-height: 1.7;
}
.acct-form-card__alt {
  margin: 16px 0 0; padding-top: 14px; border-top: 1px solid var(--line);
  font-size: 13px; color: var(--text-faint);
}
.acct-form-card__alt a { color: var(--accent-hi); }

/* Amber, matching the failed-attempt dot: this is a correction to make, not a
   failure that has already cost anything. */
.acct-field-error {
  display: block; margin-top: 6px;
  font-style: normal; font-size: 13px; color: #fde68a;
}

@media (max-width: 560px) {
  /* Full width and a real tap target. 36px is fine for a mouse and awkward for
     a thumb; 44px is the smallest reliably hittable size, and this is the
     wayfinding control the whole button exists to make obvious — missing it is
     exactly the frustration it was added to remove. The Log out button in the
     top bar gets the same treatment for the same reason. */
  .acct-welcome__out { width: 100%; justify-content: center; }
  /* ⚠️ min-height, not padding. The first attempt added 11px of padding and
     landed on 42px — close enough to look right and still under the bar. State
     the requirement and let the box satisfy it, rather than deriving it from a
     font-size and line-height that can both move. */
  .acct-welcome__out, .acct-top__out .acct-btn { min-height: 44px; }
}

/* The way-back button carries a light accent tint rather than the plain ghost
   border, so it reads as an offer rather than a control you have to find.
   ⚠️ Two classes on purpose (.acct-btn.acct-welcome__out): .acct-btn--ghost is
   a single class, so a one-class rule here would depend on source order — the
   trap that has silently reverted four earlier changes on this site. */
.acct-btn.acct-welcome__out {
  background: rgb(169 205 245 / .09);
  border-color: var(--accent-dim);
  color: var(--accent-hi);
}
.acct-btn.acct-welcome__out:hover {
  background: rgb(169 205 245 / .18);
  border-color: var(--accent);
  color: #fff;
}

/* --- sign in with Google --------------------------------------------------- */

.oauth { margin-top: 22px; }

/* The divider. A line with the word sitting in a gap, drawn with two flexed
   rules rather than a background trick, so it survives any card width. */
.oauth__or {
  display: flex; align-items: center; gap: 12px;
  margin: 0 0 16px; color: var(--text-faint); font-size: 12px;
  text-transform: uppercase; letter-spacing: .1em;
}
.oauth__or::before, .oauth__or::after {
  content: ''; flex: 1 1 auto; height: 1px; background: var(--line);
}

.oauth__btn {
  display: flex; align-items: center; justify-content: center; gap: 10px;
  width: 100%; min-height: 44px; padding: 10px 16px;
  border: 1px solid var(--line-strong); border-radius: 10px;
  background: #fff; color: #1f1f1f;
  font-size: 14px; font-weight: 500; text-decoration: none;
  transition: background .16s var(--ease-out), border-color .16s var(--ease-out);
}
/* ⚠️ White, against the site's dark theme, and deliberately so: this is the
   button Google's branding terms describe and the one people recognise without
   reading. A dark restyle to "match the theme" makes it look like every other
   secondary control on the page, which is the opposite of the point. */
.oauth__btn:hover { background: #f1f3f4; border-color: #dadce0; }
.oauth__mark { width: 18px; height: 18px; flex: none; }

.oauth__who {
  margin: 0 0 18px; padding: 12px 14px;
  border: 1px solid var(--line); border-radius: 10px;
  background: var(--bg-elev); color: var(--text-dim); font-size: 14px;
}
.oauth__who strong { color: var(--text); font-weight: 600; }

/* A Google-only account has no password to change; the Security page says so
   here instead of showing a form that cannot work. */
.acct-nopassword {
  display: flex; align-items: flex-start; gap: 12px;
  background: var(--bg-elev); border: 1px solid var(--line);
  border-radius: 12px; padding: 18px 20px; max-width: 46rem;
  color: var(--text-dim); font-size: 14px; line-height: 1.7;
}
.acct-nopassword__mark { width: 18px; height: 18px; flex: none; margin-top: 3px; }

/* ==========================================================================
   FORM FIELDS: MAKE THE BOX VISIBLE          (owner, 2026-08-12)
   ==========================================================================
   The complaint was that on the Support form "the lines are very thin, the
   space is not clear". Measured rather than guessed: every field was a 9%-white
   border (var(--line)) around a background of var(--bg) — the SAME colour as
   the page behind it. So the field had neither an edge nor a surface; there was
   nothing to see but the label above it.

   Two changes, and the second matters more than the first: a stronger border,
   and a slightly lifted background so the control reads as an area you can type
   into rather than a gap in the page.

   🔴 .ff__input HAS TO OUT-SPECIFY theme-e5.css. site.css already asked for
   --line-strong there; theme-e5.css is generated, loads AFTER site.css, and
   sets it back to --line. So site.css has been losing that argument silently —
   the documented trap on this project, now four-for-four. Hence the element
   qualifier: `input.ff__input` (0-1-1) beats `.ff__input` (0-1-0) whatever the
   load order does.
   ========================================================================== */

.tform input[type=text], .tform select, .tform textarea {
  /* ⚠️ NOT var(--line-strong) (18%). The owner asked for deeper lines, and 18%
     was already the "strong" token — measured at 1.09 contrast between the
     field surface and the page, which is a difference you can only find if you
     already know it is there. 30% is the edge you can see without looking. */
  background: #14141f;
  border-color: hsl(0 0% 100% / .30);
}
.tform input[type=text]:hover, .tform select:hover, .tform textarea:hover {
  border-color: hsl(0 0% 100% / .44);
}
/* The focus state was a 1px border swap, which is easy to miss on a dark
   screen. A ring reads at a glance and does not shift the layout. */
.tform input:focus, .tform select:focus, .tform textarea:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px rgb(169 205 245 / .16);
}

input.ff__input, select.ff__input, textarea.ff__input {
  background: #14141f;
  border: 1px solid hsl(0 0% 100% / .30);
}
input.ff__input:hover, select.ff__input:hover, textarea.ff__input:hover {
  border-color: hsl(0 0% 100% / .44);
}
input.ff__input:focus, select.ff__input:focus, textarea.ff__input:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px rgb(169 205 245 / .16);
}

/* --------------------------------------------------------------------------
   🔴 THE GOOGLE BUTTON'S TEXT ON HOVER
   --------------------------------------------------------------------------
   Reported as "the writing becomes unreadable when you go over Google", and it
   was exactly that. The button is an <a>, and the site has a blanket
   `a:hover { color: var(--accent-hi) }` — a very pale blue. .oauth__btn set the
   dark text colour but had no :hover of its own, so `a:hover` (0-1-1) beat it
   (0-1-0) the moment the pointer arrived and painted near-white text onto a
   white button.

   ⚠️ Both states are pinned now, and both selectors carry the element so they
   cannot lose the same argument again. This is the second bug on this page from
   a generic rule reaching a control that only looks like a link.
   -------------------------------------------------------------------------- */

a.oauth__btn, a.oauth__btn:link, a.oauth__btn:visited { color: #1f1f1f; }
a.oauth__btn:hover, a.oauth__btn:focus, a.oauth__btn:active {
  color: #1f1f1f;
  background: #f1f3f4;
  border-color: #dadce0;
}
a.oauth__btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* ==========================================================================
   PORTAL SURFACES: MAKE THE CARDS READ AS CARDS   (owner, 2026-08-12)
   ==========================================================================
   "The page is black and they are not visible at all." Measured before
   touching anything: every card was rgb(10,10,16) on a rgb(3,3,5) page — a
   contrast ratio of 1.04 — inside a 9%-white border worth 1.19 against the
   same page. So a card had neither a surface nor an edge. It was not a subtle
   card; it was an invisible one, and the owner was describing it accurately.

   🔑 ONE TOKEN, NOT NINE EDITS. Every panel in the portal now takes its
   surface and edge from --acct-surface / --acct-edge, defined once on the
   shell. The old code set var(--bg-elev) and var(--line) in nine separate
   places, which is why "make the cards clearer" would otherwise have been nine
   chances to miss one.

   ⚠️ Scoped under .acct-body so nothing here reaches the public site. .sw__item,
   .req__item and .account__table are shared with pages outside the portal, and
   those pages sit on a different background where the old values are right.
   ========================================================================== */

.acct-body {
  /* ⚠️ These two numbers were raised twice after MEASURING, not after looking.
     The first attempt (#16161f, 22%) moved the surface from 1.04 to 1.15 and
     the edge from 1.19 to 1.82 — better, and still below the 3.0 the form
     fields on the same page had just been set to. Two panels on one screen
     disagreeing about how visible an edge should be is worse than either
     choice, so the cards were brought up to match. */
  --acct-surface: #1e1e2a;
  --acct-edge:    hsl(0 0% 100% / .34);
  /* Barely visible on its own, but it softens the edge into the page instead
     of stopping dead — which is what stops a lifted panel looking pasted on. */
  --acct-lift:    0 1px 2px rgb(0 0 0 / .5), 0 8px 24px -12px rgb(0 0 0 / .8);

  /* 🔴 RAISED BECAUSE LIFTING THE CARDS COST THE SMALL PRINT ITS CONTRAST.
     --text-faint is white at 42% alpha. Against the old near-black card that
     was marginal; against the new lighter surface it measured 3.77:1, under
     the 4.5:1 AA needs for normal text — and it is exactly the text that most
     needs to be legible: "Licences you own", "Not connected yet", "No password
     set". Making the panels clearer while making the words on them dimmer
     would have been a net loss dressed up as an improvement.

     ⚠️ Overridden only inside the portal. --text-faint is site-wide and the
     public pages sit on their own backgrounds where 42% is correct. */
  --text-faint: rgb(244 245 247 / .56);
}

.acct-body .acct-welcome,
.acct-body .acct-tile,
.acct-body .acct-card,
.acct-body .acct-empty,
.acct-body .acct-pending,
.acct-body .acct-form-card,
.acct-body .acct-sec-fact,
.acct-body .acct-nopassword,
.acct-body .sw__item,
.acct-body .req__item,
.acct-body .thread__msg {
  background: var(--acct-surface);
  border-color: var(--acct-edge);
  box-shadow: var(--acct-lift);
}

/* The dashed "not connected yet" panel keeps its dashes — that is what marks
   it as unfinished — but gets the same surface as everything else. */
.acct-body .acct-pending { border-style: dashed; }

/* Hover has to stay ABOVE the new resting state or the cards would appear to
   darken when the pointer arrives. */
.acct-body .acct-tile:hover,
.acct-body a.acct-card:hover {
  background: #1d1d29;
  border-color: hsl(0 0% 100% / .34);
}

/* The rows in My Software and My Requests are cards too, and they were the
   worst of the lot: several are borderless lists on the public site. */
.acct-body .sw__item,
.acct-body .req__item { border-width: 1px; border-style: solid; }

/* Orders is a table rather than a card, so it gets the surface as a block and
   keeps its internal rules light — a full-strength line under every row turns
   a table into a grid and is harder to read, not easier. */
.acct-body .acct-tablewrap {
  background: var(--acct-surface);
  border: 1px solid var(--acct-edge);
  border-radius: 12px;
  box-shadow: var(--acct-lift);
}
.acct-body .account__table { border: 0; }
.acct-body .account__table th,
.acct-body .account__table td { border-color: hsl(0 0% 100% / .12); }

/* The top bar and the active sidebar item are surfaces too. Left dimmer than a
   card on purpose: they are the frame, and a frame that competes with the
   content is the reason the reference portal's chrome is quiet. */
.acct-body .acct-top { background: #101018; border-bottom-color: var(--acct-edge); }
.acct-body .acct-nav__item.is-current {
  background: var(--acct-surface);
  border-color: var(--acct-edge);
}
.acct-body .acct-nav__item:hover { background: #101018; }

/* --- card grid: logo variant ----------------------------------------------
   🔴 THE DEFAULT .card__media CROPS (object-fit: cover), WHICH DESTROYS A LOGO.
   Measured on the partner page before this existed: the 600x101 Ecosystem
   wordmark was being scaled to 885px wide inside a 266px box, so roughly two
   thirds of the words were outside the frame.

   `contain` shows the whole mark whatever its shape, which is what lets a
   200x200 banner, a 274x274 round badge and a 600x101 wordmark sit in one row
   without any of them being re-cut by hand.

   ⚠️ Scoped to .cards--logo. The lesson and article grids genuinely want the
   crop — a thumbnail that does not fill its box leaves a ragged grid, and
   losing the edge of a chart costs nothing. */

/* 🔑 THE BOX IS CUT TO THE PRIMARY ARTWORK, AND EVERY CARD IN THE ROW MATCHES IT.
   428x254 is the NinjaTrader banner, the only one of the three that is a
   designed picture rather than a mark on a transparent field. Giving the box
   its exact shape means that picture fits with nothing cropped and nothing left
   over, and the other two are then centred inside a box of identical size —
   which is what keeps all three cards the same height.

   ⚠️ A generic 16:10 was tried first. It was wrong for a subtler reason than it
   looked: the Kinetick artwork is 1.2:1, so a wider box made it HEIGHT-bound and
   it grew to fill the full 229px, reading as an oversized picture next to two
   restrained ones. Matching the banner's shape lets it sit at its natural
   weight. One number, changed here, moves every card in the row. */
.cards--logo { --logo-ratio: 428 / 254; }

.cards--logo .card__media {
  aspect-ratio: var(--logo-ratio);
  /* 🔴 WITHOUT THIS THE ASPECT RATIO IS ONLY A SUGGESTION.
     .card__media is a flex item (.card is a column flex container), and a flex
     item's automatic minimum size is its CONTENT size. The Kinetick artwork is
     250px tall, taller than the box the ratio asks for, so that minimum won and
     its box alone grew to 251px while the other two stayed at 217 — three cards
     that were supposed to be identical, differing by the height of one picture.
     Measured, not guessed: gridTemplateRows resolved to 250px on that card and
     217px on the others. */
  min-height: 0;
  display: grid;
  grid-template-rows: minmax(0, 1fr);
  place-items: center;
  /* Enough to keep a mark off the card border, not enough to reinstate the
     framed-patch look. */
  padding: 14px 16px;
  background: var(--bg-elev-2);
  /* The anchor for the corner badge below. */
  position: relative;
}

/* 🔴 contain, never cover. Equal-height boxes plus assorted logo shapes means
   something has to give, and it must not be the artwork: a cropped logo is a
   damaged trademark, while empty space beside one is just empty space. */
.cards--logo .card__media img {
  width: auto; height: auto;
  max-width: 100%; max-height: 100%;
  object-fit: contain;
  display: block;
}

/* The small mark, in the bottom-right corner of the picture area.
   ⚠️ position:absolute takes it out of the grid centring, so it does NOT get
   the place-items treatment its parent applies to the main picture. */
/* 🔴 img.card__badge, NOT .card__badge. The badge IS a .card__media img, and
   that rule above carries one more element than a bare class does — so a plain
   `.card__badge { height: 58px }` loses to its `height: auto` and the sticker
   rendered at 228px, nearly filling the picture area. Measured, not guessed:
   the browser reported 228x228 where 58 was asked for. Adding the element
   qualifier puts this rule ahead of it. */
/* ⚠️ The media box normally clips (overflow: hidden on the base .card__media),
   which is what lets the badge sit ACROSS its bottom edge rather than inside
   it. The main picture is size-constrained and cannot escape, so nothing else
   changes. The card itself still clips, so the badge cannot leave the card. */
.cards--logo .card__media { overflow: visible; }

.cards--logo .card__media img.card__badge {
  position: absolute;
  /* Straddling the join between picture and text, in the corner — owner's
     arrow, 2026-08-12. Sitting fully inside the picture, it landed squarely on
     the artwork's own "FREE EOD DATA" button and covered the words. */
  right: 12px; bottom: -26px;
  width: auto; height: 54px; max-width: 34%;
  /* Above the card body, which is a later sibling and would otherwise paint
     over it. */
  z-index: 2;
  /* A round sticker sitting directly on artwork needs an edge of its own or it
     reads as part of the picture underneath. */
  filter: drop-shadow(0 2px 6px rgb(0 0 0 / .55));
}

/* --- footer: the three partner marks -------------------------------------- */
/* Owner's brief, 2026-08-12: same apparent size, smaller overall, and tucked
   under the footer rather than sitting in a band of its own.

   🔴 ONE HEIGHT FOR ALL THREE DOES NOT LOOK LIKE ONE SIZE, and that was the
   complaint. Each vendor packs its file differently:

     NinjaTrader  a single word filling the whole file  -> looked far too big
     Ecosystem    two stacked lines, so the big line is about half the file
     Kinetick     a tiny "recommended market data service" line, the word
                  KINETICK, and a tagline under it — the word itself is well
                  under half the file, so it looked far too small

   So the heights below differ ON PURPOSE, chosen so the WORD in each mark reads
   at roughly the same size. Setting them all to one number is the bug, not the
   fix. The rendered widths land within ~30px of each other, which is the
   cross-check that they now carry equal weight.

   ⚠️ An automatic version of this was attempted — measuring ink density per row
   to find each file's dominant band — and abandoned after it read Kinetick's
   10px tagline as the main word and found no ink at all in the NinjaTrader PNG.
   Three tuned numbers that are right beat a clever measurement that is wrong. */

.site-footer__marks {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
  flex-wrap: wrap;
  /* Part of the footer, not a band under it: no rule above, and only enough
     space to separate it from the menus.
     4mm on 2026-08-14 — the owner's number for the gap between the social
     icons above and the NinjaTrader badges here, matching the 4mm set above
     them so the row sits evenly between the two. */
  margin-top: 4mm;
}

.site-footer__mark { display: block; flex: 0 0 auto; }

.site-footer__mark img {
  display: block;
  width: auto;
  opacity: .8;
  transition: opacity .16s var(--ease-out);
}
.site-footer__mark:hover img { opacity: 1; }

/* The three tuned heights. See the note above for why they are not equal. */
.site-footer__mark--eco      img { height: 30px; }   /* two-line lockup   */
.site-footer__mark--data     img { height: 38px; }   /* word + two taglines */
.site-footer__mark--platform img { height: 20px; }   /* bare wordmark     */

/* Saturated orange on a muted footer shouts louder than the other two even at
   the smallest size. */
.site-footer__mark--platform img { opacity: .66; }
.site-footer__mark--platform:hover img { opacity: 1; }

@media (max-width: 700px) {
  /* Centred and stacked; space-between on a narrow screen pushes two marks to
     the edges and leaves a hole in the middle. */
  /* 4mm here too, so the narrow layout does not end up with a LARGER gap than
     the wide one — which is what leaving the old 22px beside a 15.11px desktop
     value would have produced. */
  .site-footer__marks { justify-content: center; gap: 22px; margin-top: 4mm; }
  .site-footer__mark--eco      img { height: 24px; }
  .site-footer__mark--data     img { height: 30px; }
  .site-footer__mark--platform img { height: 16px; }
}

/* A picture that brings its own background fills the box to the edge. The
   padding on .cards--logo .card__media is there to keep a TRANSPARENT mark off
   the card border; on artwork with its own backdrop it just paints a frame back
   on, which is the "patch" the owner objected to. */
.cards--logo .card__media--bleed { padding: 0; }

/* The small print under a card grid — in practice an affiliate disclosure.
   ⚠️ Not var(--text-faint). A disclosure that has to be hunted for does not
   discharge the obligation it exists for, so this sits at the same weight as
   ordinary body copy, just smaller. */
.cards__note {
  margin: 22px auto 0;
  max-width: 72ch;
  font-size: 13.5px;
  line-height: 1.65;
  color: var(--text-dim);
  text-align: center;
}


/* ==========================================================================
   🔴 THE LINE UNDER THE HERO — fixed at the CLIP EDGE, not at a suspect.
   ==========================================================================
   Four rounds went into this. Rounds 1-3 chased the two hero pseudo-elements
   and tuned their masks; the owner still saw the line every time. Round 4
   measured instead of guessed, and the numbers said everything was already
   clean:

     .hero::before  clip lands at 86.67% of its height, mask hits 0 at 82%
     .hero::after   clip lands at 99.63% of its height, mask hits 0 at 94%

   Verified in the browser's OWN computed style at 800px, 1742px and 1920px
   wide — and the two ratios do not move with the viewport, because the
   pseudo-elements are sized in % of the hero, so the geometry is self-similar.
   Every other candidate was eliminated by measurement too: `.hero`'s
   `border-bottom` is killed by theme-e5.css (computed `none`/`0px`), and both
   `body::before` and `body::after` are `position: fixed`, so they scroll with
   the viewport and cannot pin a line to a fixed point in the DOCUMENT.

   🔑 So the alpha was never the problem. A CONTROL settled it: with
   `overflow: visible` on the hero the line disappeared. The clip edge itself
   is what paints — `.hero::before` carries `filter: blur(46px)` and
   `opacity: .95`, which force it onto its own composited layer, and clipping
   that layer on a fractional device-pixel boundary leaves a hairline no
   amount of mask tuning inside the layer can remove.

   ⚠️ `overflow: visible` is NOT the fix — measured: it lets the 2090px-wide
   ::before out of a 1742px viewport and adds 174px of horizontal overflow.
   The clip is load-bearing and stays.

   So the fix is applied to the CLIP EDGE instead: fade the hero's own last
   40px to nothing, so the pixels the clip cuts are already transparent and
   there is no edge left to alias. Mechanism-level rather than suspect-level —
   it also covers any layer added inside the hero later.

   ⚠️ 40px is safe because it lives entirely inside the hero's bottom padding:
   `body[data-home] .hero` sets `padding-bottom: clamp(24px, 3.5vw, 44px)` =
   44px here, and the last content box ends at doc y=1256 against a hero
   bottom of 1300. Content is never touched. If that padding is ever reduced
   below 40px, shorten this to match.

   ⚠️ Written at (0,2,1) because theme-e5.css IS GENERATED AND LOADS AFTER
   this file — an equal-weight rule would lose silently, the trap that has
   cost this project six rounds.

   🔙 Rollback: _backup\hero_line_20260813\  (site.css sha256 695E2EEBF8476CE9)

   🔁 WIDENED 2026-08-14, because scoping it to one page was the bug's second
   life. /blog gained a hero block that evening and the line came straight back
   — same mechanism, same look, on a page the fix could not reach. It was fixed
   for the home page rather than for heroes, and "the next page to get a hero"
   was never going to come back here and notice.

   `body[data-home]` and `body[data-page]` between them cover every page: home
   carries the first, everything else the second (measured). Both are (0,2,1),
   so the specificity that keeps generated theme-e5.css from winning is intact —
   a plain `.hero` at (0,1,0) would have lost silently, which is the trap this
   file has paid for six times.

   🔴 AND 40px IS NOT SAFE ON EVERY PAGE — measured, not assumed. Widening the
   selector without checking would have faded the bottom of real content on
   three pages, including a BUTTON:

     /faqs                                      31px to the "Contact support" button
     /ninjatrader/indicators                    32px to the last list item
     /education/volume-and-order-flow-analysis  31px to the closing paragraph
     (home 45 · /products 108 · /education 88 · /blog 88 · /about-us 108)

   ⚠️ The first measurement said EIGHT pages were unsafe and was wrong: it took
   the lowest edge of any descendant, which on several pages is a decorative
   layer filling the hero. Counting only visible ink — text leaves and images,
   skipping aria-hidden absolutes — gave the three above.

   So the length is a variable, not a constant. 24px is the default because it
   clears the tightest page by 7px, and the ramp length was never what fixed
   the artefact: what matters is that alpha reaches zero AT the clip edge, so
   the pixels being cut are already transparent. The home page keeps 40px
   because that is the value the owner looked at and accepted, and there is no
   reason to re-open a page that is right.
   ========================================================================== */
body[data-home] .hero,
body[data-page] .hero {
  --hero-fade: 24px;
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - var(--hero-fade)), transparent 100%);
          mask-image: linear-gradient(to bottom, #000 calc(100% - var(--hero-fade)), transparent 100%);
}
body[data-home] .hero { --hero-fade: 40px; }

/* ═══════════════════════════════════════════════════════════════════════════
   INVOICE — a document on a dark site
   Added 2026-08-14.

   The sheet is white with dark text whatever the theme is doing. An invoice is
   a document before it is a web page: it gets printed, forwarded, and filed
   next to other invoices, and a dark one wastes ink and reads as a screenshot.

   🔑 There is no print BUTTON, on purpose — the site ships zero JavaScript and
   a button would have been the first script on it, to duplicate Ctrl+P. The
   print rules below are the actual feature.
   ══════════════════════════════════════════════════════════════════════════ */
.inv-sheet {
  background: #fff; color: #111;
  padding: 44px 46px; border-radius: 10px;
  max-width: 820px; margin: 0 auto;
}

/* The customer's list of invoices, in the portal. */
.inv-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
.inv-list__item {
  display: flex; flex-wrap: wrap; gap: 14px; align-items: center;
  justify-content: space-between;
  background: var(--bg-elev); border: 1px solid var(--line);
  border-radius: var(--radius); padding: 15px 18px;
}
.inv-list__no { font-family: var(--mono); font-size: 14.5px; font-weight: 600; }
.inv-list__meta { margin: 5px 0 0; font-size: 12.5px; color: var(--text-faint); }
/* An invoice that exists but never arrived is something the reader can act on,
   so it is named rather than left to look the same as one that did. */
.inv-list__unsent { color: var(--gold); }
.inv-list__right { display: flex; align-items: center; gap: 14px; }
.inv-list__total { font-variant-numeric: tabular-nums; font-weight: 600; }
.inv-bar {
  display: flex; flex-wrap: wrap; gap: 14px; align-items: center;
  justify-content: space-between; max-width: 820px; margin: 0 auto 20px;
}
.inv-bar__hint { margin: 0; font-size: 13px; color: var(--text-faint); }
.inv-bar__hint kbd {
  font-family: var(--mono); font-size: 11.5px; padding: 1px 5px;
  border: 1px solid var(--line-strong); border-radius: 4px;
}
.inv-note { max-width: 820px; margin: 16px auto 0; font-size: 13px; color: var(--text-faint); }
.inv__title { font-weight: 700; }

@media print {
  /* Everything that is not the document goes. A printed invoice with a site
     header on it is a screenshot; without one it is a document. */
  .site-header, .site-footer, .no-print, .risk { display: none !important; }
  body { background: #fff !important; }
  body::before, body::after { display: none !important; }
  .section { padding: 0 !important; }
  .inv-sheet { padding: 0; border-radius: 0; max-width: none; }
  @page { margin: 18mm; }
}

/* ═══════════════════════════════════════════════════════════════════════════
   /blog — A DELIBERATE DIVIDER WHERE THE ACCIDENTAL ONE WAS
   Added 2026-08-14.

   The owner saw a line under the blog hero and asked for it to be a highlight.
   Both halves matter and they are different jobs:

     1. the hairline above is an ARTEFACT — a composited layer clipped on a
        fractional device pixel. It cannot be positioned, coloured or relied on,
        and it renders differently per browser. Killed by the mask rule above.
     2. a divider between the headline and the article grid is a DESIGN, so it
        is drawn on purpose: brightest in the middle, gone at both ends, so it
        reads as light catching an edge rather than as a rule ending abruptly.

   Leaving the artefact and "styling" it would have been neither — a line that
   moves when the window is resized cannot be aligned to anything.

   On the section rather than the hero: the hero is masked to transparent in
   its last 40px, so anything drawn inside it there would be faded out by the
   very rule that removes the artefact. ⚠️ If this ever stops appearing, check
   that first — it is the non-obvious interaction between the two rules.
   ══════════════════════════════════════════════════════════════════════════ */
body[data-page="blog"] .posts-band {
  position: relative;
  /* The cards sat hard against the headline once the head became a hero block.
     Enough air that the divider reads as separating two things, not as an
     underline on the paragraph above it. */
  padding-top: clamp(46px, 6vw, 92px);
}
body[data-page="blog"] .posts-band::before {
  content: '';
  position: absolute; top: 0; left: 50%; transform: translateX(-50%);
  width: min(1180px, 92vw); height: 1px;
  background: linear-gradient(to right,
      transparent 0%,
      rgb(125 211 252 / .10) 18%,
      rgb(125 211 252 / .42) 50%,
      rgb(125 211 252 / .10) 82%,
      transparent 100%);
  pointer-events: none;
}

/*
 * The list's label, moved out of the hero on 2026-08-17.
 *
 * 🔑 It has to override the theme, not just the base. Orbit 5 restates
 * `.hero .eyebrow` with `margin: 0 0 22px 80.04px` to hold its dash under the
 * badge's ECOSYSTEM — that rule is scoped to `.hero`, so it does not reach here,
 * but the BASE `.eyebrow` still carries a bottom margin tuned for a hero. Down
 * here it only needs to clear the card grid.
 */
body[data-page="blog"] .posts-band__label {
  margin: 0 0 18px;
}

/* ═══════════════════════════════════════════════════════════════════════════
   LEGAL DOCUMENTS — the `legal` variant of content-section
   Added 2026-08-14 for /terms-and-conditions, /privacy-policy,
   /refund_returns-policy and /software-license-terms.

   THE PROBLEM, MEASURED BEFORE ANY OF THIS WAS WRITTEN
   Each numbered clause was its own full marketing section: 46px of padding
   above and 112px below, with an h2 at 38px/600 — the size the homepage uses
   to introduce a product. Ten clauses of Terms therefore ran 4.1 screens, and
   the heading of "1. Acceptance of Terms" was typographically shouting.

   A legal document is scanned, not read start to finish. It wants a tight
   vertical rhythm, a heading that reads as a clause number, and a hairline so
   the eye can find where one clause ends.

   🔴 THREE CLASSES, AND THAT IS NOT DECORATION. theme-e5.css is GENERATED by
   tools/gen_orbit2_bg.php and is loaded AFTER this file, and it carries
   `.section { padding-block: clamp(64px, 9vw, 112px) }`. A single-class rule
   here — `.content--legal { … }` — has identical specificity and therefore
   LOSES on source order, silently. It has cost this project six rounds before.
   `.section.content.content--legal` is (0,3,0) against (0,1,0) and wins
   wherever it is loaded from. Every value below was confirmed by reading the
   COMPUTED style in the browser, not by reasoning about the cascade.
   ═════════════════════════════════════════════════════════════════════════ */

.section.content.content--legal { padding-block: 0; }

/* The clause's own box. The hairline sits on the TOP of each clause so the
   gap between two clauses is one line, not two stacked paddings. */
.section.content.content--legal > .wrap {
  padding-block: 24px;
  border-block-start: 1px solid var(--line);
}

/* The first clause after the page title owns the document's opening space and
   must not draw a rule under the hero. */
.hero + .section.content.content--legal > .wrap,
.section.content.content--legal:first-of-type > .wrap {
  border-block-start: 0;
  padding-block-start: 28px;
}

/* The last clause keeps real space beneath it, or the footer crowds the text. */
.section.content.content--legal:last-of-type > .wrap { padding-block-end: 56px; }

/* Both grids drop from 26px to something a dense document can live with. */
.section.content.content--legal .content__inner { gap: 14px; }
.section.content.content--legal .content__text  { gap: 14px; }

/* The clause heading. 17px against the 16px body: enough to lead the eye,
   nowhere near the 38px it was. */
.section.content.content--legal .content__text h2 {
  font-size: 1.0625rem;
  line-height: 1.45;
  font-weight: 600;
  letter-spacing: .005em;
  text-wrap: balance;
}

/* Tighter paragraph rhythm inside a clause, and lists that sit closer to the
   sentence that introduces them. */
.section.content.content--legal .prose p { margin-block: 0 .55em; }
.section.content.content--legal .prose p:last-child { margin-block-end: 0; }
.section.content.content--legal .checklist { gap: 8px; }

/* An anchored clause, arrived at from a table of contents or a cross-link from
   another legal page, gets a moment of colour so the reader lands on it. */
.section.content.content--legal:target > .wrap {
  border-inline-start: 2px solid var(--accent);
  padding-inline-start: 18px;
}

/* Room for the sticky header when a #anchor is jumped to. */
.section.content.content--legal { scroll-margin-block-start: 90px; }


/* ═══════════════════════════════════════════════════════════════════════════
   /education — the hub's short sections
   Added 2026-08-14.

   THE THREE COMPLAINTS, AND WHAT EACH ONE MEASURED AS

   1. "the text is left on a single line"  — the last line of two paragraphs
      held only "to it." and "it.". Typographic widows, produced by the default
      text-wrap: wrap, which fills every line to the maximum and lets the
      remainder fall where it may.
   2. "the sections are far too far apart" — each section carried 112px of
      bottom padding while being only ~400 characters and ~420px tall. The gap
      was more than a quarter of the section.
   3. "the font is unprofessional and it feels different from the other pages"
      — and that instinct was right for a reason nobody had named. EVERY other
      content-heavy page wraps its sections in a panel: /about-us is 4 panels,
      /ninjatrader/indicators is 9. /education is the only one whose sections
      are bare `content--plain`. A 38px heading sits fine at the top of a card;
      with no card around it and 112px of air beneath, it floats.

   🔑 THE HEADING IS RESIZED RATHER THAN THE SECTIONS BEING TURNED INTO CARDS.
   A card would have matched the other pages, but the owner asked for no rules
   or dividers between the sections, and a panel is a bordered box — the same
   visual weight arriving by another route. So the fix keeps the open layout and
   fixes the proportion instead: 38px was sized for a section of many
   paragraphs, and these hold two.

   🔴 SCOPED TO body[data-page="education"], WHICH IS WHY IT WINS. That is
   (0,1,1) before the three classes are counted, so the whole selector easily
   outranks theme-e5.css's `.section { padding-block: … }` — which is generated,
   loads after this file, and silently beats anything of equal weight. Every
   number below was read back from the computed style, not assumed.
   ═════════════════════════════════════════════════════════════════════════ */

body[data-page="education"] .section.content.content--plain {
  padding-block: 0 56px;
}

/* The heading, in proportion to a section of roughly four hundred characters. */
body[data-page="education"] .section.content.content--plain .content__text h2 {
  font-size: clamp(1.375rem, 2.2vw, 1.625rem);
  line-height: 1.25;
  letter-spacing: -.01em;
}

/* 🔑 text-wrap: balance, AND `pretty` WAS TRIED FIRST AND MEASURED AS WORSE.
   The reasoning for `pretty` was the textbook one: it fixes the last few lines
   without disturbing the rest, while `balance` evens out every line and is
   meant for short headings. Measured on this page's ten paragraphs:

       pretty    0 one-word endings, 3 two-word  ("to it." "draws it." "the course.")
       balance   0 one-word endings, 0 two-word

   The textbook is right about long body copy and wrong about THIS page: every
   paragraph here is two to four lines, which is exactly where `balance` works
   and exactly where `pretty` has too little room to move anything. The control
   was /ninjatrader/custom, a page nobody has complained about — it has 0 of 6,
   and `balance` is what brings this page to the same place.

   ⚠️ SCOPED TO /education FOR THAT REASON. Do not lift this to the whole site:
   on a page of six-line paragraphs `balance` is the wrong trade, and Chrome
   stops applying it past a handful of lines anyway. A browser without support
   ignores the line and renders what it renders today. */
body[data-page="education"] .section.content .prose p,
body[data-page="education"] .section.content .content__text p {
  text-wrap: balance;
}

/* The gap between the two paragraphs inside one section, and the space before
   the button, both come down with the outer padding so the section reads as
   one block rather than three stacked pieces. */
body[data-page="education"] .section.content.content--plain .content__inner { gap: 16px; }
body[data-page="education"] .section.content.content--plain .content__text  { gap: 16px; }


/* ═══════════════════════════════════════════════════════════════════════════
   Footer — the three social icons
   Added 2026-08-14, between the company-registration line and the partner
   badges, which is where the owner marked them.

   🔑 GREYSCALE COMES FROM currentColor, NOT FROM filter: grayscale().
   The icons are drawn with fill="currentColor", so the colour is set here once
   and the hover state is one more line. A `filter: grayscale(1)` would have
   worked on coloured artwork, but it also desaturates anything else in the
   element and cannot be lifted for a single state without re-rendering.

   ⚠️ The resting colour is the same --ink-dim the registration line above uses,
   so the block reads as one quiet group rather than three logos competing with
   the NinjaTrader badges below — which are the ones that should carry weight
   in this footer.
   ═════════════════════════════════════════════════════════════════════════ */

/*
 * SPACING IN mm, AND THAT IS THE OWNER'S UNIT — 4mm above and 4mm below,
 * asked for on 2026-08-14. CSS `mm` is honoured directly, so the number in the
 * stylesheet is the number that was asked for rather than a px translation
 * somebody has to reverse later.
 *
 * ⚠️ CSS mm IS NOMINAL, NOT PHYSICAL. It is defined against 96dpi, so 4mm is
 * exactly 15.11px on every display — measured, not assumed. On a screen whose
 * real pixel density differs it will not be 4mm under a ruler. That is a CSS
 * property, not a bug here, and px would behave identically.
 */
.site-footer__social {
  display: flex;
  gap: 14px;
  align-items: center;
  margin-block: 4mm 0;
  padding: 0;
  list-style: none;
}

.site-footer__social li { margin: 0; }

/*
 * 🔑 NO RING, AND THE BOX STAYS ANYWAY. The circle was removed at the owner's
 * request; the 34px box is NOT part of it and is kept deliberately. It is the
 * hit area, and at 20px the icon alone would be under the 24x24 minimum WCAG
 * 2.2 sets for a pointer target. Invisible now, still there for the finger.
 *
 * Negative inline margin on the first icon so the row's optical left edge lines
 * up with the text above it: the box is wider than the glyph, and without this
 * the icons appear indented against the registration line.
 */
.site-footer__social a {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 34px;
  height: 34px;
  color: var(--ink-dim, hsl(0 0% 100% / .52));
  transition: color .18s ease;
}

.site-footer__social li:first-child a { margin-inline-start: -7px; }

.site-footer__social svg { display: block; }

/* Hover lifts the icon to full ink and nothing else moves — the set stays
   monochrome, and with the ring gone there is nothing left to tint. */
.site-footer__social a:hover,
.site-footer__social a:focus-visible { color: var(--ink, #fff); }

/* A visible focus ring, because the link is an icon with no text to underline. */
.site-footer__social a:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce) {
  .site-footer__social a { transition: none; }
}
