/* hub_web components — the web sibling of hub_flutter's widgets.
 *
 * Authored by hand (unlike hub-tokens.css, which is generated). The rule that makes this a
 * design system rather than a stylesheet: EVERY value here is a `--hub-*` token. No raw hex,
 * no ad-hoc px for spacing or type. If a value you need has no token, the fix is a token —
 * not a literal.
 *
 * Type follows ADR 0076: five roles, one weight each — title / headline / body / footnote /
 * caption. Emphasis is the `headline` role, never a heavier weight on `body`. There is no
 * `font-weight: 600` anywhere below, deliberately.
 *
 * Requires hub-tokens.css first:
 *   <link rel="stylesheet" href="/hub/hub-tokens.css">
 *   <link rel="stylesheet" href="/hub/hub-components.css">
 *
 * Accessibility is built in rather than added later (ADR 0057): every interactive target is
 * ≥44px, focus is always visible, and each component reflows at 320px and survives 200% text
 * because it sizes in `rem`/`ch` and wraps rather than shrinking. The catalog page has a
 * narrow/large-text panel to check that by eye.
 */

/* ── the brand face ─────────────────────────────────────────────────────────────
 *
 * Noto Sans, self-hosted. hub-tokens.css has always declared
 * `--hub-font-family-sans: "Noto Sans", system-ui, sans-serif`, but nothing ever loaded it —
 * so every page of this site has been rendering in whatever `system-ui` happens to be on the
 * visitor's machine. The token said one thing and the pixels said another.
 *
 * SELF-HOSTED, not linked from fonts.gstatic.com. Hub's positioning says "no ads, no
 * tracking"; a font <link> to Google hands every visitor's IP and user-agent to a third party
 * on every page load, which German courts have found to breach the GDPR. The analytics are
 * self-hosted for the same reason. It is also faster — no second DNS lookup, TLS handshake and
 * connection to set up for one file.
 *
 * ONE file, because it is the VARIABLE font: `wght` 100–900 in 35KB covers 500 for
 * title/body/footnote/caption and 700 for headline, and anything added later. The static cuts
 * already in the repo (packages/asset-templates/fonts, for asset-studio) could not have done
 * this — they are 400/600/700/900 with no Medium 500, the weight four of the six roles use.
 *
 * `swap`, not `optional` or `block`: the text is readable in a fallback face from the first
 * paint and re-renders when the font lands. On a public-safety site the words must never wait
 * for the typography. Paired with a preload in the document head, the swap is usually
 * invisible; on a slow connection it is a reflow, which is the correct thing to trade.
 *
 * Provenance, licence and the Latin-only limit: packages/hub_web/fonts/README.md.
 */
@font-face {
  font-family: "Noto Sans";
  src: url("/hub/fonts/NotoSans-latin-var.afc7a910.woff2") format("woff2-variations");
  /* The range the FILE covers, not the range the site uses — declaring it honestly lets the
     browser interpolate any weight instead of synthesising a fake one. */
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
  /* DERIVED FROM THE FONT'S OWN cmap, not from Google's stylesheet.
     The range must describe what the FILE contains. The previous value was copied from
     fonts.googleapis.com, which describes the range that SERVICE covers — 387 codepoints
     against the 232 actually in this file. Overstating it is worse than understating: the
     browser believes the face covers the character, so it does not fall back, and renders
     tofu. That was live for 59 printable characters including U+2010 HYPHEN, U+2011
     NON-BREAKING HYPHEN, U+2020 DAGGER, U+2030 PER MILLE and both U+2191/U+2193 arrows.
     Regenerate with tools/hub-web/font-range.py after changing the font file. */
  unicode-range:
    U+0000, U+000D, U+0020-007E, U+00A0-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+2002, U+2009, U+200B,
    U+2013-2014, U+2018-201A, U+201C-201E, U+2022, U+2026, U+2032-2033, U+2039-203A, U+2044,
    U+20AC, U+2122, U+2212, U+FEFF, U+FFFD;
}

/* ── primitives ─────────────────────────────────────────────────────────────── */

.hub-page {
  margin: 0;
  background: var(--hub-surface-base);
  color: var(--hub-content-primary);
  font-family: var(--hub-font-family-sans);
  font-size: var(--hub-body-regular-size);
  font-weight: var(--hub-body-regular-weight);
  line-height: 1.55;
  /* WCAG 1.4.10 reflow + 1.4.4 resize-text, and the ONE declaration that fixes both.
   *
   * At 200% text on a 320dp viewport the heading roles are genuinely large — .hub-title computes
   * to 56px — while .hub-container leaves a 208px content box. Any word wider than that box has
   * no break opportunity under the initial `overflow-wrap: normal`, so it paints straight out of
   * the page. The type scale is NOT the bug: at 200% the text is supposed to be that size. The
   * missing break opportunity is.
   *
   * Two distinct failures, both from the same missing property, measured on www.ph.app:
   *
   *   1. TEXT OVERFLOWING ITS OWN BOX. "government" in the CTA strip measures 330px in a 208px
   *      box and pushed /accessibility-compliance to 379px of horizontal scroll.
   *   2. A BOX SIZED TO AN UNBREAKABLE WORD. `.hub-split` is `grid-template-columns: 1fr`, and
   *      `1fr` means `minmax(auto, 1fr)` — an AUTO minimum, i.e. min-content. "implementation"
   *      at 56px is ~420px, so the track became 420px inside a 272px container and
   *      /public-health scrolled to 444px.
   *
   * `anywhere` and NOT `break-word`, which was the first fix here and only solved (1).
   * The two differ in exactly one respect and it is the one that matters: break-word breaks a
   * word when laying out a line but is explicitly ignored when computing min-content, so a grid
   * or flex track still sizes itself to the longest unbreakable word. Measured: break-word alone
   * left /public-health at 444px. Narrowing the track instead (`minmax(0, 1fr)`) only got to
   * 369px, because the same class of overflow recurs in nested lists. `anywhere` took every
   * measured page to exactly the viewport width.
   *
   * The cost of `anywhere` is that it also shrinks intrinsic min-content, so content-sized boxes
   * can get narrower. Measured rather than assumed, before and after, on the same pages: at
   * 1280px/100% the layout is byte-identical — same button widths, same card widths, same split
   * columns — and at 320px/100% nothing moves either. It only acts where the alternative was
   * overflowing the viewport, and there it also pulled a 416px button back to 272px.
   *
   * Why an element-level audit missed all of this: an overflowing text run does not change its
   * element's box. The h2's rect stayed a well-behaved 208px while the glyphs sat outside it, so
   * every "does any element exceed the viewport" scan came back clean while the page still
   * scrolled sideways. Measure TEXT NODES with a Range, not elements.
   *
   * Set here rather than on the six type roles because overflow-wrap inherits — one declaration
   * covers headings, prose, footer links and authored blog bodies alike, including whatever the
   * corpus grows next. */
  overflow-wrap: anywhere;
}

/* Content measure caps at ~72ch so long-form prose stays readable, and the gutter is a
 * spacing token so it scales with the system rather than drifting per page. */
.hub-container {
  width: 100%;
  max-width: 1140px;
  margin-inline: auto;
  padding-inline: var(--hub-spacing-xl);
  box-sizing: border-box;
}

.hub-section {
  padding-block: calc(var(--hub-spacing-xxl) * 2);
}
/* A full-bleed hero owns its own vertical space, so the section must not add any: the section
   padding is page background, and above a dark hero it reads as a stray light band across the
   top of the site. Keyed on CONTAINING a hero rather than on being first, because "first" is
   true today by accident of the route table and would silently stop being true. */
.hub-section:has(> .hub-hero) { padding-block: 0; }
.hub-section--tight { padding-block: var(--hub-spacing-xxl); }
.hub-section--flush-top { padding-block-start: 0; }
.hub-section--flush-bottom { padding-block-end: 0; }
.hub-section--alt { background: var(--hub-surface-elevated); }

/* ── the web type scale ──────────────────────────────────────────────────────
 *
 * SIZES are web-specific and defined here; WEIGHTS still come from the generated tokens, so
 * "one weight per role" (ADR 0076) is unchanged. Only the sizes are re-pitched.
 *
 * Why this exists, because the obvious shortcut is a trap. hub-tokens.css is generated from
 * the Figma source and emits exactly one scale, labelled `typography (mobile)`: title 24 /
 * headline 19 / body 19. On a phone that is right. On a marketing page at desktop width it
 * gives an h1 only 1.26x body, and a headline the SAME SIZE as the paragraph under it —
 * separated by weight alone. That is the flatness, and it is why the site read as undesigned.
 *
 * The trap: the Figma source does carry per-role "Desktop" variants, and reaching for them makes
 * it worse — they are macOS-app-chrome sizes (Title 17, Headline 13, Body 13, Caption 10),
 * measured in packages/design_tokens/tokens/textStyles.json. They describe PHapp in a desktop
 * WINDOW, not a marketing page. The generator is right to emit only the mobile scale; the web
 * surface simply needs its own, which is what packages/hub_web/README.md already anticipated
 * ("mobile scale by default; per-platform variants can be added").
 *
 * Fluid rather than breakpointed: each size interpolates with the viewport, so there is no
 * step where the page suddenly re-pitches. Every clamp keeps a `rem` term in its preferred
 * value — that is what makes it survive 200% text zoom (WCAG 1.4.4); a pure-vw formula would
 * ignore the user's setting entirely.
 */
:root {
  /*                          320px          →  1440px  */
  --hub-web-display-size:  clamp(2.5rem,    1.55rem + 4.20vw, 4rem);      /* 40 → 64 */
  --hub-web-title-size:    clamp(1.75rem,   1.40rem + 1.55vw, 2.5rem);    /* 28 → 40 */
  --hub-web-headline-size: clamp(1.125rem,  1.06rem + 0.30vw, 1.3125rem); /* 18 → 21 */
  --hub-web-body-size:     clamp(1.0625rem, 1.03rem + 0.16vw, 1.1875rem); /* 17 → 19 */
  --hub-web-footnote-size: 0.9375rem;                                     /* 15 */
  --hub-web-caption-size:  0.875rem;                                      /* 14 */

  /* Leading tightens as type grows — a 64px headline set at body leading looks unset. */
  --hub-web-display-leading:  1.05;
  --hub-web-title-leading:    1.15;
  --hub-web-headline-leading: 1.3;
  --hub-web-body-leading:     1.6;

  /* Optical tracking on large sizes only. Systematic, defined once — not the per-use
   * `letterSpacing:` ADR 0076 bans. Small text gets none: negative tracking hurts legibility. */
  --hub-web-display-tracking: -0.022em;
  --hub-web-title-tracking:   -0.012em;

  /* Line length. Prose past ~68 characters loses the return sweep. */
  --hub-web-measure: 68ch;

  /* Position of an element within a revealed group; hub-motion.js sets it per element to
   * stagger a row of cards. Declared here so it is a real token with a real default rather
   * than a name that only exists inside a JS string. */
  --hub-reveal-index: 0;
}

/* The type roles. Nothing else sets font-size or font-weight.
 *
 * `display` is a SIXTH role, and only on web. It exists because a hero is the one place a
 * marketing page has to carry presence that no app screen needs — and adding it is what let
 * `title` stop doing two jobs badly. It borrows the title weight rather than introducing one. */
.hub-display  { font-size: var(--hub-web-display-size);  font-weight: var(--hub-title-regular-weight);    line-height: var(--hub-web-display-leading);  letter-spacing: var(--hub-web-display-tracking); margin: 0; }
.hub-title    { font-size: var(--hub-web-title-size);    font-weight: var(--hub-title-regular-weight);    line-height: var(--hub-web-title-leading);    letter-spacing: var(--hub-web-title-tracking);   margin: 0; }
.hub-headline { font-size: var(--hub-web-headline-size); font-weight: var(--hub-headline-regular-weight); line-height: var(--hub-web-headline-leading); margin: 0; }

/* Hyphenate the HEADING roles, and only those.
 *
 * The `overflow-wrap: anywhere` on .hub-page is what stops a heading escaping the viewport at
 * 200% text, but on its own it breaks wherever the character happens to land: measured on
 * hub.inc/about at 320dp, the display role is 80px in a 272px box and "verified" (289px),
 * "information" (456px) and "coordinated" (462px) genuinely cannot fit on a line, so the break
 * is mandatory — it rendered as "verifie / d" and "inform / ation", which reads as a typo rather
 * than as a continuation. With hyphenation the same line becomes "veri- / fied in- / forma- /
 * tion", where the hyphen says what happened.
 *
 * NOT on .hub-page, for two reasons. It would be a site-wide typographic change to body copy as
 * a side effect of an accessibility fix, which is not this rule's decision to make. And body
 * copy is where URLs appear — a soft hyphen inserted into a displayed URL is not a typographic
 * nicety, it is a URL that reads as though it contains a character it does not. Headings do not
 * contain URLs, so scoping to them buys the readability without that risk.
 *
 * `hyphens` needs the document language to pick break points; the layout sets lang="en" on
 * <html>. -webkit- prefix for Safari, which still requires it. */
.hub-display,
.hub-title,
.hub-headline {
  -webkit-hyphens: auto;
  hyphens: auto;
}
.hub-body     { font-size: var(--hub-web-body-size);     font-weight: var(--hub-body-regular-weight);     line-height: var(--hub-web-body-leading);     margin: 0; }
.hub-footnote { font-size: var(--hub-web-footnote-size); font-weight: var(--hub-footnote-regular-weight); line-height: var(--hub-web-body-leading);     margin: 0; }
.hub-caption  { font-size: var(--hub-web-caption-size);  font-weight: var(--hub-caption-regular-weight);  line-height: var(--hub-web-body-leading);     margin: 0; }

.hub-muted { color: var(--hub-content-secondary); }

/* Links were styled only inside .hub-prose, so every other link on the site fell back to the
   browser default #0000EE. That already measured 3.4:1 on a light card; on the dark theme's
   elevated surface it is 1.11:1. Styling them is therefore not polish, it is the thing that
   makes a dark site legible.
   Underline always, never colour alone — the affordance has to survive for anyone who cannot
   separate the hue (ADR 0057 name/role/value). */
/* :not(.hub-btn) is load-bearing. `.hub-page a` is specificity (0,1,1) and `.hub-btn--primary`
   is (0,1,0), so without the exclusion this rule WINS over the button's own colour and paints
   the label in the link colour — on a button whose background IS the link colour. The label
   does not go low-contrast, it disappears entirely. Caught on the hero CTA. */
/* Scoped to MAIN. "Underline always, never colour alone" is a rule about links inside running
   text, where nothing else marks them; it is not a rule about navigation, where position and
   the surrounding landmark already say what the thing is (WCAG 1.4.1 is about information
   conveyed by colour, and a nav is not conveying information by colour). Applied globally it
   also outranked the chrome's own rules — specificity (0,2,1) against (0,1,0) — and painted
   the header nav and the wordmark link-blue and underlined, which is the same specificity trap
   that erased the hero button's label one commit earlier. */
.hub-page main a:not(.hub-btn) {
  color: var(--hub-content-link);
  text-decoration: underline;
  text-underline-offset: 0.15em;
}
/* On an ELEVATED surface the link token does not clear AA in dark (#47A3FF on #393E5B is
   3.95:1), so links there take the content colour and lean entirely on the underline:
   9.99:1 dark, 12.75:1 light. Buttons opt out — they are their own contrast pair. */
/* Every component that paints --hub-surface-elevated, enumerated rather than guessed:
   grep the file for surface-elevated and this list is what comes back. Missing one is not a
   subtle regression — it is a link at 3.95:1, which is how the trust panel was found. */
.hub-page main .hub-section--alt a:not(.hub-btn),
.hub-page main .hub-card a:not(.hub-btn),
.hub-page main .hub-trust a:not(.hub-btn),
.hub-page main .hub-faq__item a:not(.hub-btn),
.hub-page main .hub-quote a:not(.hub-btn) { color: var(--hub-content-primary); }
.hub-page .hub-skip-link { text-decoration: none; }

/* Focus is never removed, only restyled — the ring is a token so it matches the app. */
.hub-focusable:focus-visible,
.hub-btn:focus-visible,
.hub-card:focus-visible,
a:focus-visible {
  outline: 2px solid var(--hub-focus-ring-color);
  outline-offset: 2px;
  border-radius: var(--hub-spacing-xs);
}

/* ── button ─────────────────────────────────────────────────────────────────── */

/* min-height 44px is the WCAG 2.1 AA target size, expressed once here so no caller has to
 * remember it. Buttons wrap rather than truncate: a clipped label at 200% text is the exact
 * defect that shipped in the Flutter HubButton. */
.hub-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--hub-spacing-sm);
  min-height: 44px;
  padding: var(--hub-spacing-md) var(--hub-spacing-xl);
  border-radius: var(--hub-spacing-sm);
  border: 1px solid transparent;
  font-family: inherit;
  font-size: var(--hub-web-body-size);
  font-weight: var(--hub-body-regular-weight);
  line-height: 1.2;
  text-decoration: none;
  cursor: pointer;
  text-align: center;
  white-space: normal;
  overflow-wrap: anywhere;
}

.hub-btn--primary {
  background: var(--hub-interactive-primary-surface);
  color: var(--hub-interactive-primary-content);
}
.hub-btn--primary:hover { background: var(--hub-interactive-primary-surface-hover); }

.hub-btn--secondary {
  background: var(--hub-interactive-secondary-surface);
  color: var(--hub-interactive-secondary-content);
  border-color: var(--hub-interactive-secondary-border);
}

.hub-btn[aria-disabled="true"] {
  background: var(--hub-interactive-primary-surface-disabled);
  color: var(--hub-interactive-primary-content-disabled);
  pointer-events: none;
}

.hub-btn-group {
  display: flex;
  flex-wrap: wrap;
  gap: var(--hub-spacing-md);
  margin-block-start: var(--hub-spacing-xl);
}

/* ── hero ───────────────────────────────────────────────────────────────────── */

.hub-hero {
  position: relative;
  padding-block: calc(var(--hub-spacing-xxl) * 3);
  background: var(--hub-surface-base);
  overflow: hidden;
}
.hub-hero__grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--hub-spacing-xxl);
  align-items: center;
}
@media (min-width: 900px) {
  .hub-hero__grid { grid-template-columns: 1fr 1fr; }
  .hub-hero--full .hub-hero__grid { grid-template-columns: 1fr; }
}
/* Display type wants a SHORTER measure than body, not the same one: at 64px a 68ch line is
 * over a metre of reading on a wide monitor and the eye loses the row. ~22ch gives a headline
 * two or three deliberate lines instead of one long ribbon. Both caps are max-widths, so a
 * narrow viewport is unaffected and 320px reflow still holds. */
.hub-hero__title { margin-block-end: var(--hub-spacing-lg); max-width: 22ch; }
.hub-hero__sub   { color: var(--hub-content-secondary); margin-block-end: var(--hub-spacing-lg); max-width: var(--hub-web-measure); }
/* The hero's authored prose sits beside the sub and needs the same cap. */
.hub-hero__grid > div > .hub-prose { max-width: var(--hub-web-measure); }
.hub-hero__media img,
.hub-hero__media video { width: 100%; height: auto; border-radius: var(--hub-spacing-md); display: block; }

/* Badge carries meaning as TEXT, never colour alone (ADR 0057 name/role/value). */
.hub-badge {
  display: inline-block;
  padding: var(--hub-spacing-xs) var(--hub-spacing-md);
  border-radius: var(--hub-spacing-xl);
  background: var(--hub-accent-surface);
  color: var(--hub-accent-content);
  font-size: var(--hub-caption-regular-size);
  font-weight: var(--hub-caption-regular-weight);
  margin-block-end: var(--hub-spacing-lg);
}

/* ── cards ──────────────────────────────────────────────────────────────────── */

/* auto-fit + minmax means reflow is automatic: at 320px it is one column with no media query
 * and no horizontal scroll. */
.hub-cards {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));
  gap: var(--hub-spacing-xl);
  margin-block-start: var(--hub-spacing-xxl);
  padding: 0;
  list-style: none;
}
.hub-card {
  background: var(--hub-surface-elevated);
  border: 1px solid var(--hub-border-subtle);
  border-radius: var(--hub-spacing-md);
  padding: var(--hub-spacing-xl);
  display: flex;
  flex-direction: column;
  gap: var(--hub-spacing-md);
}
.hub-card__title { color: var(--hub-content-primary); }
.hub-card__body  { color: var(--hub-content-secondary); }
.hub-card__media img { width: 100%; height: auto; border-radius: var(--hub-spacing-sm); display: block; }

/* ── split ──────────────────────────────────────────────────────────────────── */

.hub-split {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--hub-spacing-xxl);
  align-items: center;
}
@media (min-width: 900px) {
  .hub-split { grid-template-columns: 1fr 1fr; }
  /* Media-first reads correctly in RTL too because order is logical, not left/right. */
  .hub-split--media-first .hub-split__media { order: -1; }
}
.hub-split__media img { width: 100%; height: auto; border-radius: var(--hub-spacing-md); display: block; }

/* Third-party embeds must reflow like everything else (WCAG 1.4.10).
 *
 * HubSpot's meetings embed (MeetingsEmbedCode.js, on /feedback-sessions) injects an iframe with
 * `min-width: 312px` as an INLINE style, which is 40px wider than the 272px content box at
 * 320dp and put the page 16px into horizontal scroll. `!important` because an inline style
 * written by someone else's script is the one case a stylesheet cannot otherwise beat, and
 * min-width wins over max-width whenever the two disagree — so max-width: 100% alone is inert
 * here.
 *
 * Scoped to iframes rather than applied broadly: this overrides an author's explicit intent,
 * and the only intent worth overriding is "this embed may exceed its container".
 *
 * NOTE: that embed is itself a cutover liability — it loads a script from HubSpot and points at
 * a HubSpot-hosted meetings page. This rule makes it reflow today; it does not make it survive
 * HubSpot being switched off. */
.hub-prose iframe,
.meetings-iframe-container iframe {
  max-width: 100%;
  min-width: 0 !important;
}
.hub-split__title { margin-block-end: var(--hub-spacing-lg); }
.hub-split__body  { color: var(--hub-content-secondary); }

/* ── stats ──────────────────────────────────────────────────────────────────── */

.hub-stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(200px, 100%), 1fr));
  gap: var(--hub-spacing-xl);
  margin: 0;
  padding: 0;
}
/* Each figure is a CARD, which is what the live site does and what the port had dropped: a bare
 * grid of numbers on the page background reads as one block of text, and the whole point of a
 * stats band is that each figure is separately legible. Matches the original's measurements —
 * 12px radius, ~24px padding, and the elevated surface (the live markup hardcodes
 * rgba(57,62,91), which is exactly --hub-surface-elevated in dark).
 *
 * The border is not decoration. On the dark theme, elevated against base is a 1.4:1 step, so
 * without an edge the card is a suggestion rather than a boundary — the same failure the feed
 * cards hit (ADR 0057 / WCAG 1.4.11 non-text contrast). */
.hub-stat {
  display: flex;
  flex-direction: column;
  gap: var(--hub-spacing-xs);
  padding: var(--hub-spacing-xl);
  background: var(--hub-surface-elevated);
  border: 1px solid var(--hub-border-subtle);
  border-radius: 12px;
}
/* The figure uses the TABULAR variant so digits align across the row — that is what the
 * tabular role exists for, rather than a bespoke font-feature-settings. It also stops the count-up
 * from reflowing the card on every frame, since each digit then has the same advance width.
 *
 * --hub-web-title-size, not --hub-title-regular-size: the app tokens are raw px and cannot
 * respond to the user's text-size setting (WCAG 1.4.4). */
.hub-stat__value {
  font-size: var(--hub-web-title-size);
  font-weight: var(--hub-title-regular-weight);
  font-variant-numeric: tabular-nums;
  color: var(--hub-content-primary);
}
.hub-stat__label { color: var(--hub-content-secondary); }
/* The qualifier under a figure's label ("Jan 1 - Dec 31, 2025"). It lives INSIDE the <dd> so it
   stays part of the figure's programmatic label — which means it needs display:block to read as
   a second line rather than running straight on from the label.
   Colour comes from .hub-muted and the type role from .hub-caption, both already applied on the
   element — so this rule owns layout only, which is the one thing those two cannot express. */
.hub-stat__note {
  display: block;
  margin-block-start: var(--hub-spacing-xs);
}

/* ── trust panel ────────────────────────────────────────────────────────────── */

.hub-trust {
  background: var(--hub-surface-elevated);
  border: 1px solid var(--hub-border-subtle);
  border-radius: var(--hub-spacing-lg);
  padding: var(--hub-spacing-xxl);
}
.hub-trust__title { margin-block-end: var(--hub-spacing-lg); }
.hub-trust__list { margin: 0; padding: 0; list-style: none; display: grid; gap: var(--hub-spacing-md); }
.hub-trust__item { display: flex; gap: var(--hub-spacing-md); align-items: flex-start; color: var(--hub-content-secondary); }
/* The marker is decorative; it is aria-hidden in the markup so the item's meaning is carried
 * by its text, never by the glyph alone. */
.hub-trust__marker { color: var(--hub-accent-surface); flex: none; }

/* ── cta strip ──────────────────────────────────────────────────────────────── */

.hub-cta {
  background: var(--hub-accent-surface);
  color: var(--hub-accent-content);
  border-radius: var(--hub-spacing-lg);
  padding: var(--hub-spacing-xxl);
  text-align: center;
}
.hub-cta__title { margin-block-end: var(--hub-spacing-md); }
.hub-cta__body { margin-block-end: var(--hub-spacing-xl); }
.hub-cta .hub-btn-group { justify-content: center; margin-block-start: 0; }
/* The primary button INVERTS the band rather than keeping its usual blue.
 *
 * `--hub-interactive-primary-surface` (#47A3FF) against `--hub-accent-surface` (#00B39E) is a
 * contrast ratio of 1.00 — the two differ in hue and are IDENTICAL in luminance. The label on
 * it is legible (7.02:1), so nothing looked broken; what fails is the button itself, whose
 * shape disappears for anyone who cannot separate the hues, and on any greyscale rendering.
 * WCAG 1.4.11 asks for 3:1 on the visual information that identifies a control, and 1.00 is
 * the worst value the metric has.
 *
 * Swapping to the accent pair's own inverse is the fix that cannot drift: `--hub-accent-content`
 * is BY DEFINITION the readable counterpart of `--hub-accent-surface`, so this stays correct if
 * the accent is ever retuned, and in both themes. Measured: 7.17:1 dark, 7.03:1 light — for the
 * button against the band AND for the label against the button, since it is the same pair.
 *
 * White would NOT have worked: #FFFFFF on this band is 2.64:1, still under 3.
 *
 * `tools/sweep` audits this pairing now (web_contrast.py); deleting this rule makes the
 * exemption recorded there a lie and the audit fails. */
.hub-cta .hub-btn--primary {
  background: var(--hub-accent-content);
  color: var(--hub-accent-surface);
}
.hub-cta .hub-btn--primary:hover {
  background: var(--hub-accent-content);
  opacity: 0.88;
}
.hub-cta .hub-btn--secondary {
  background: transparent;
  color: var(--hub-accent-content);
  border-color: var(--hub-accent-content);
}

/* ── prose ──────────────────────────────────────────────────────────────────── */

/* Long-form authored HTML (legal documents, articles). Caps the measure and maps the raw
 * heading tags onto the type roles, so authored markup cannot introduce an off-scale size. */
/* Body copy inside authored HTML follows the same web scale — otherwise a `description_html`
 * block would set at the phone size right beside a component that does not. */
.hub-prose { max-width: var(--hub-web-measure); color: var(--hub-content-secondary); }
.hub-prose h2 { font-size: var(--hub-web-title-size);    font-weight: var(--hub-title-regular-weight);    line-height: var(--hub-web-title-leading);    letter-spacing: var(--hub-web-title-tracking); color: var(--hub-content-primary); margin: var(--hub-spacing-xxl) 0 var(--hub-spacing-md); }
.hub-prose h3,
.hub-prose h4 { font-size: var(--hub-web-headline-size); font-weight: var(--hub-headline-regular-weight); line-height: var(--hub-web-headline-leading); color: var(--hub-content-primary); margin: var(--hub-spacing-xl) 0 var(--hub-spacing-sm); }
.hub-prose p,
.hub-prose li { font-size: var(--hub-web-body-size); font-weight: var(--hub-body-regular-weight); line-height: var(--hub-web-body-leading); }
.hub-prose p  { margin: 0 0 var(--hub-spacing-lg); }
.hub-prose ul,
.hub-prose ol { margin: 0 0 var(--hub-spacing-lg); padding-inline-start: var(--hub-spacing-xxl); display: grid; gap: var(--hub-spacing-sm); }
.hub-prose a  { color: var(--hub-content-link); }
.hub-prose table { width: 100%; border-collapse: collapse; margin-block-end: var(--hub-spacing-lg); }
.hub-prose th,
.hub-prose td { border: 1px solid var(--hub-border-subtle); padding: var(--hub-spacing-md); text-align: start; }
/* A wide table must scroll inside its own box rather than making the PAGE scroll sideways —
 * the 320px reflow requirement. */
.hub-prose__scroll { overflow-x: auto; }

/* ── skip link ──────────────────────────────────────────────────────────────── */

.hub-skip-link {
  position: absolute;
  inset-inline-start: -9999px;
  background: var(--hub-surface-elevated);
  color: var(--hub-content-primary);
  padding: var(--hub-spacing-md) var(--hub-spacing-lg);
  z-index: 100;
}
.hub-skip-link:focus { inset-inline-start: var(--hub-spacing-lg); top: var(--hub-spacing-lg); }

/* ── motion ─────────────────────────────────────────────────────────────────── */

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

/* ── faq (disclosure) ───────────────────────────────────────────────────────── */

/* Built on native <details>/<summary>: keyboard-operable, announced with its expanded state,
 * and readable with JS off. A div + aria-expanded reimplementation needs script to do what
 * the browser already does correctly. */
.hub-faq { display: grid; gap: var(--hub-spacing-md); margin-block-start: var(--hub-spacing-xxl); }
.hub-faq__item {
  background: var(--hub-surface-elevated);
  border: 1px solid var(--hub-border-subtle);
  border-radius: var(--hub-spacing-md);
  padding: var(--hub-spacing-lg) var(--hub-spacing-xl);
}
.hub-faq__q {
  cursor: pointer;
  color: var(--hub-content-primary);
  /* ≥44px target without a fixed height, so it still grows at 200% text. */
  min-height: 44px;
  display: flex;
  align-items: center;
  gap: var(--hub-spacing-md);
}
.hub-faq__q:focus-visible { outline: 2px solid var(--hub-focus-ring-color); outline-offset: 2px; }
.hub-faq__a { color: var(--hub-content-secondary); padding-block-start: var(--hub-spacing-md); }
.hub-faq__a > .hub-prose { max-width: none; }

/* ── quote ──────────────────────────────────────────────────────────────────── */

.hub-quotes {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));
  gap: var(--hub-spacing-xl);
  margin-block-start: var(--hub-spacing-xxl);
}
.hub-quote {
  margin: 0;
  background: var(--hub-surface-elevated);
  border: 1px solid var(--hub-border-subtle);
  border-inline-start: 3px solid var(--hub-accent-surface);
  border-radius: var(--hub-spacing-md);
  padding: var(--hub-spacing-xl);
  display: flex;
  flex-direction: column;
  gap: var(--hub-spacing-md);
}
.hub-quote__text { margin: 0; color: var(--hub-content-primary); }
.hub-quote__by { display: flex; flex-wrap: wrap; gap: var(--hub-spacing-sm); color: var(--hub-content-secondary); }
.hub-quote__name { color: var(--hub-content-primary); }

/* ── gallery ────────────────────────────────────────────────────────────────── */

.hub-gallery {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));
  gap: var(--hub-spacing-lg);
  margin-block-start: var(--hub-spacing-xxl);
  padding: 0;
  list-style: none;
}
.hub-gallery__item img {
  width: 100%;
  height: auto;
  display: block;
  border-radius: var(--hub-spacing-sm);
}

/* ── motion ─────────────────────────────────────────────────────────────────────
 *
 * Three effects, all optional and all off by default: a drifting gradient field behind a hero,
 * a fade-and-rise as a section enters view, and a lift on an interactive card.
 *
 * THE OPT-IN IS THE SAFETY PROPERTY. Every rule below is scoped to
 * `[data-hub-motion="on"]`, an attribute hub-motion.js sets on <html> only after it has
 * checked `prefers-reduced-motion`. So the page renders complete and legible with no
 * JavaScript, with JS disabled, with the script 404ing, and for anyone who has asked their OS
 * to stop animations. The alternative — hide in CSS, reveal in JS — turns any script failure
 * into a blank page, which is how "progressive enhancement" usually gets it backwards.
 *
 * The reduced-motion media query is repeated at the end anyway, because a user can change the
 * setting after load and the attribute would already be on.
 *
 * No parallax, no scroll-jacking, no autoplaying video: WCAG 2.2.2 requires a pause control
 * for anything moving longer than five seconds, and the honest way to avoid owing one is for
 * the movement to be slow, non-essential and ignorable. The gradient drifts over half a
 * minute; nothing here competes with reading.
 */
:root {
  --hub-motion-rise: 12px;
  --hub-motion-duration: 620ms;
  --hub-motion-stagger: 90ms;
  /* Decelerating, so movement arrives rather than stopping dead. */
  --hub-motion-ease: cubic-bezier(0.22, 1, 0.36, 1);
  --hub-motion-lift: 4px;
  --hub-motion-drift-duration: 34s;
}

/* Colour comes from the accent tokens via color-mix, so the field re-tints with the brand and
 * inverts correctly in dark mode instead of being a hardcoded wash.
 *
 * The three opacities are a CONTRAST BUDGET, not a taste call. Text sits on this field, so the
 * binding constraint is the darkest point it can reach — all three stops overlapping — measured
 * against `--hub-content-secondary`, the lightest text that lands on it. At the strengths this
 * started with (22/20/14%) that worst case computes to #8DABB9 and 4.33:1, which FAILS WCAG
 * 1.4.3 AA. At 15/14/10% it is #AAC0C9 and 5.55:1, with primary text at 9.98:1. Raising these
 * is a contrast change, not a colour change — recompute before you do. */
.hub-hero--mesh { position: relative; isolation: isolate; }
.hub-hero--mesh::before {
  content: "";
  position: absolute;
  inset: -25%;
  z-index: -1;
  pointer-events: none;
  background:
    radial-gradient(38% 44% at 18% 26%, color-mix(in oklab, var(--hub-accent-surface) 15%, transparent), transparent 70%),
    radial-gradient(34% 40% at 82% 18%, color-mix(in oklab, var(--hub-accent-secondary) 14%, transparent), transparent 70%),
    radial-gradient(46% 50% at 62% 82%, color-mix(in oklab, var(--hub-accent-surface) 10%, transparent), transparent 72%);
  /* A blur this wide turns three circles into one continuous field — without it the shapes
   * read as three distinct blobs, which is the tell of a cheap gradient background. */
  filter: blur(40px);
  transform: translate3d(0, 0, 0) scale(1);
}
[data-hub-motion="on"] .hub-hero--mesh::before {
  animation: hub-drift var(--hub-motion-drift-duration) ease-in-out infinite alternate;
}
@keyframes hub-drift {
  from { transform: translate3d(-2%, -1%, 0) scale(1); }
  to   { transform: translate3d(2%, 1.5%, 0) scale(1.08); }
}

/* Reveal-on-scroll. hub-motion.js adds .is-visible as each element enters the viewport. */
[data-hub-motion="on"] .hub-reveal {
  opacity: 0;
  transform: translate3d(0, var(--hub-motion-rise), 0);
  transition:
    opacity var(--hub-motion-duration) var(--hub-motion-ease),
    transform var(--hub-motion-duration) var(--hub-motion-ease);
  transition-delay: calc(var(--hub-reveal-index, 0) * var(--hub-motion-stagger));
}
[data-hub-motion="on"] .hub-reveal.is-visible {
  opacity: 1;
  transform: none;
}
/* Above the fold at load: present, not animated. See hub-motion.js for why this is applied
 * in the same task as enabling motion rather than a frame later. */
[data-hub-motion="on"] .hub-reveal.is-immediate { transition: none; }

/* Cards lift on hover AND on keyboard focus — a pointer-only affordance is a WCAG 2.4.7 miss
 * for anyone tabbing. `box-shadow` and `translate` only: animating layout would reflow. */
[data-hub-motion="on"] .hub-card,
[data-hub-motion="on"] .hub-btn {
  transition: transform 200ms var(--hub-motion-ease), box-shadow 200ms var(--hub-motion-ease);
}
[data-hub-motion="on"] .hub-card:hover,
[data-hub-motion="on"] .hub-card:focus-within {
  transform: translate3d(0, calc(var(--hub-motion-lift) * -1), 0);
  box-shadow: 0 12px 28px color-mix(in oklab, var(--hub-content-primary) 12%, transparent);
}
[data-hub-motion="on"] .hub-btn:hover {
  transform: translate3d(0, calc(var(--hub-motion-lift) * -0.5), 0);
}

/* The belt to the opt-in's braces: honours a preference changed AFTER the attribute was set. */
@media (prefers-reduced-motion: reduce) {
  [data-hub-motion="on"] .hub-hero--mesh::before { animation: none; }
  [data-hub-motion="on"] .hub-reveal { opacity: 1; transform: none; transition: none; }
  [data-hub-motion="on"] .hub-card,
  [data-hub-motion="on"] .hub-btn { transition: none; }
  [data-hub-motion="on"] .hub-card:hover,
  [data-hub-motion="on"] .hub-card:focus-within,
  [data-hub-motion="on"] .hub-btn:hover { transform: none; }
}

/* ── the signal field hero ──────────────────────────────────────────────────────
 *
 * A dark band carrying a WebGL network of nodes with pulses travelling between them
 * (hub-field.js). Dark is not a style choice here, it is what makes the rest possible: on a
 * light page anything energetic enough to be striking eats the contrast the copy needs, while
 * on #0B1215 white text starts near 18:1 and the graphics can be luminous AND safer.
 *
 * Theming is the design system's own mechanism rather than a new one: the element carries
 * data-hub-theme="dark", so every --hub-* token inside it resolves to its dark value and the
 * buttons, badges and body copy come out right without a single override.
 *
 * Degradation, each rung a finished hero: no CSS -> readable HTML; CSS -> the band with a
 * static gradient; + motion -> it drifts; + WebGL -> hub-field.js adds .hub-field--live and
 * takes over. Nothing below assumes the canvas exists.
 */
.hub-hero--field {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  background: var(--hub-surface-base);
  color: var(--hub-content-primary);
  /* Colours the canvas reads at boot, so the field stays token-driven rather than carrying its
   * own palette. Named separately from the semantic tokens because they describe LIGHT EMITTED
   * into a scene, not a surface or a content colour. */
  --hub-field-accent: var(--hub-accent-surface);
  --hub-field-accent-alt: var(--hub-accent-secondary);
  --hub-field-pulse: var(--hub-interactive-primary-surface);
  /* The UNRESOLVED state: cool and desaturated, so the morph into the accents reads as
   * information becoming trustworthy rather than as a colour cycle. Deliberately not a
   * semantic token — no surface or content is this colour; it is what particles look like
   * before they mean anything. */
  --hub-field-dust: #5A6B8C;
}

/* The fallback rung: a static version of the same idea, shown until (or instead of) the canvas. */
.hub-hero--field::before {
  content: "";
  position: absolute;
  inset: -20%;
  z-index: -2;
  pointer-events: none;
  background:
    radial-gradient(40% 46% at 22% 30%, color-mix(in oklab, var(--hub-accent-surface) 40%, transparent), transparent 70%),
    radial-gradient(36% 42% at 80% 22%, color-mix(in oklab, var(--hub-accent-secondary) 34%, transparent), transparent 70%);
  filter: blur(50px);
}
[data-hub-motion="on"] .hub-hero--field::before {
  animation: hub-drift var(--hub-motion-drift-duration) ease-in-out infinite alternate;
}
/* Once the canvas is live the static gradient would only mute it. */
.hub-hero--field.hub-field--live::before { opacity: 0.35; }

.hub-field__canvas {
  position: absolute;
  inset: 0;
  z-index: -2;
  width: 100%;
  height: 100%;
  display: block;
  pointer-events: none;
}

/* THE SCRIM IS THE CONTRAST GUARANTEE, and it is why the field is allowed to be bright.
 *
 * The canvas blends ADDITIVELY, which on a dark band means it can only make the background
 * LIGHTER — and lighter background against light text means LESS contrast, the opposite of the
 * intuition that additive is safe. Unbounded, a bright node landing under a headline reaches
 * #5FEBFF and 1.36:1. That is a total failure of WCAG 1.4.3, and it would be intermittent and
 * position-dependent, which is the worst kind to catch by eye.
 *
 * So the field is capped by a scrim, and the cap is computed against the pathological case —
 * every additive layer at full strength on one pixel. The gradient runs 0.88 down to 0.72, so
 * the WEAKEST point anywhere on the band is 0.72, which puts that worst case at #24535B:
 * 8.17:1 for primary text and 5.81:1 for secondary. Both clear AA with room.
 *
 * Lowering the 0.72 end is a contrast change. Recompute before touching it. */
.hub-hero--field::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  /* Narrow viewports: the copy spans the full width, so the guarantee has to as well. Uniform
   * 0.80 -> worst case #1C3D44, 11.18:1 primary and 7.95:1 secondary. */
  background: color-mix(in srgb, var(--hub-surface-base) 80%, transparent);
}

/* Wide viewports: the copy is capped to the left 56% of the container, so the scrim only has to
 * hold there — and the right side is free to show the field at nearly full strength. That
 * asymmetry is the whole reason the hero can be striking without ever being unreadable.
 *
 * The strong region runs to 62%, which is past where text can reach at any width: the container
 * is capped at 1140px and centred, so at 1440px the copy ends at ~55% of the band and the
 * fraction only shrinks as the viewport grows. */
@media (min-width: 900px) {
  /* ONLY on the single-column hero. `hub-hero--full` is emitted exactly when the hero has no
   * image, so this is the case where the copy column spans the whole container and would
   * otherwise run under the field at full strength.
   *
   * On a TWO-column hero the copy column is already ~48% of the container — inside the scrim's
   * strong region on its own — and capping it again at 56% OF THAT gives a 276px column in a
   * 1017px hero. Measured on /how-it-works: the text was 27% of the hero width, wrapping every
   * three or four words with half the left side empty. */
  .hub-hero--field.hub-hero--full .hub-hero__grid > div:first-child { max-width: 56%; }
  .hub-hero--field::after {
    /* The ramp is SHARP on purpose. A gentle fade to the right edge keeps the scrim at ~65%
     * across the whole field region, which mutes it everywhere and buys nothing — the copy
     * stops at 56%. So the guarantee is held flat to 60% and then released quickly, giving the
     * field ~35% of the band at almost full strength. 60->78% is a wide enough transition that
     * no vertical seam is visible. */
    background: linear-gradient(
      100deg,
      color-mix(in srgb, var(--hub-surface-base) 93%, transparent) 0%,
      color-mix(in srgb, var(--hub-surface-base) 88%, transparent) 42%,
      color-mix(in srgb, var(--hub-surface-base) 80%, transparent) 60%,
      color-mix(in srgb, var(--hub-surface-base) 22%, transparent) 78%,
      color-mix(in srgb, var(--hub-surface-base) 4%, transparent) 100%
    );
  }
}

/* The band needs height to read as a scene rather than a stripe, but never more than the
 * viewport — a hero taller than the screen hides the fact that the page continues. */
.hub-hero--field { padding-block: clamp(var(--hub-spacing-xxl), 12vh, calc(var(--hub-spacing-xxl) * 4)); }
.hub-hero--field .hub-hero__grid { min-height: min(58vh, 34rem); align-content: center; }

@media (prefers-reduced-motion: reduce) {
  [data-hub-motion="on"] .hub-hero--field::before { animation: none; }
}

/* ── illustration figure ────────────────────────────────────────────────────────
 *
 * A brand illustration presented as artwork rather than as a background.
 *
 * The art is warm daylight; the site around it is dark and abstract. That contrast is the
 * point and is not something to sand down — the hero says the system is precise, the
 * illustration says it is for people. So the treatment is a frame, a radius and generous dark
 * around it: hung, not pasted. Tinting or dimming to "match the theme" would flatten the one
 * warm thing on the page, and a dark-ground rendition is a new piece of brand art
 * (agents/media-studio), never a CSS filter.
 *
 * The frame's own surface shows through the image's transparent edges if any, and gives the
 * plate a defined boundary against the page — without it a light image on a dark page reads as
 * a hole punched in the layout.
 */
.hub-figure { margin: 0; }
.hub-figure__frame {
  border-radius: var(--hub-spacing-lg);
  overflow: hidden;
  border: 1px solid var(--hub-border-subtle);
  background: var(--hub-surface-elevated);
  /* line-height 0 removes the inline-descender gap under the image, which otherwise shows as a
   * few pixels of surface along the bottom edge only — an asymmetry that reads as a mistake. */
  line-height: 0;
}
.hub-figure__frame img,
.hub-figure__frame video {
  width: 100%;
  height: auto;
  display: block;
}
/* The caption is the editorial line, so it takes the prose measure rather than the image's
 * width — a single sentence set 1100px wide has no return sweep at all. */
.hub-figure__caption {
  margin-block-start: var(--hub-spacing-md);
  color: var(--hub-content-secondary);
  max-width: var(--hub-web-measure);
}

/* ── site chrome: header + footer ───────────────────────────────────────────────
 *
 * A HAMBURGER ON NARROW SCREENS — reversing the earlier "no hamburger" decision, because the
 * premise it rested on stopped being true.
 *
 * That decision was made when the header scrolled away with the page: a nav wrapping onto a
 * second line cost nothing once you had scrolled past it. The header is sticky now, so the cost
 * is permanent. Measured at 390px: the bar is 231px tall across three rows, which is 27% of an
 * 844px viewport, pinned, on every screen of the site.
 *
 * The old objection is still right about the risk, so it is engineered away rather than
 * accepted. This is PROGRESSIVE ENHANCEMENT: the nav is visible and the button is `display:
 * none` until JavaScript sets data-hub-nav="enhanced" on the header. With no script, or a
 * broken one, the page falls back to exactly the previous behaviour — a nav that wraps and
 * stays directly operable. "Links unreachable" is not a state this can enter.
 *
 * And it is a disclosure, not a modal: the panel is in normal flow below the bar, so there is
 * no focus trap, no dialog semantics to announce, and Tab just continues into the links.
 */
/* STICKY, and condensing once the page moves.
 *
 * z-index 30 clears the hero's own layers (the field canvas and its scrim sit at 1-3).
 *
 * The condensed state is driven by a `data-hub-condensed` attribute on <html> rather than by
 * `position: sticky`'s own stuck-ness, because CSS has no "is stuck" selector that works here
 * (:stuck does not exist, and a scroll-driven animation would still need a fallback). The
 * attribute is set once past a threshold, so the header does not thrash between states while
 * someone hovers the scroll wheel at the boundary. */
/* A sticky header covers whatever the page just scrolled to — an in-page anchor, or the
 * element that received focus while tabbing. `scroll-padding` tells the browser to stop short
 * by the header's height instead, which fixes both at once (and is what keeps WCAG 2.4.11
 * "focus not obscured" true rather than approximately true). Sized to the CONDENSED bar,
 * because by the time anything has scrolled, the header is condensed. */
html {
  scroll-padding-block-start: calc(var(--hub-spacing-xxl) * 2.5);
}

.hub-header {
  position: sticky;
  inset-block-start: 0;
  z-index: 30;
}
.hub-header__bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--hub-spacing-md) var(--hub-spacing-xl);
  padding-block: var(--hub-spacing-lg);
  transition: padding-block 180ms ease, background-color 180ms ease;
}
/* Once the page has moved, the bar earns a surface. Sticky chrome over running text is
 * unreadable without one — and this is the only state where the header sits over content it
 * does not own. `color-mix` + blur keeps the page visible through it rather than boxing the
 * top of the screen off. */
[data-hub-condensed="true"] .hub-header__bar {
  padding-block: var(--hub-spacing-sm);
  background: color-mix(in srgb, var(--hub-surface-base) 88%, transparent);
  -webkit-backdrop-filter: blur(12px);
  backdrop-filter: blur(12px);
  border-block-end: 1px solid var(--hub-border-subtle);
}
.hub-header__mark {
  display: inline-flex;
  align-items: center;
  /* 44px tall so the home link is a compliant target even though the mark inside is smaller. */
  min-height: 44px;
  text-decoration: none;
  margin-inline-end: auto;
  /* A flex item's automatic minimum size is its MIN-CONTENT width, which for an image is its
     full intrinsic width — so without this the lockup refuses to shrink and the bar wraps the
     menu button onto a second row instead. See the img rule below. */
  min-inline-size: 0;
}
/* Height-driven, width auto: the lockup keeps its own ratio, and the intrinsic width/height on
   the <img> still reserve the box before the SVG arrives.
 
   40px, sized against the nav rather than picked: the nav links are 19px text in 44px targets
   and the CTA is 58px tall, so a 30px lockup — barely taller than a single line of nav text —
   read as an afterthought beside them. At 40px the wordmark inside the lockup lands at roughly
   the nav's own text size, which is the alignment the eye is actually looking for. */
/* SIZED BY WHICHEVER CONSTRAINT BINDS FIRST — height on a wide screen, width on a narrow one.
 *
 * This used to be `height: 48px; width: auto`, which silently assumed every lockup has roughly
 * the same aspect ratio. They do not: hub and phapp are 148x64 (2.31:1) but wehealth is
 * 256.82x50 (5.14:1), so at a shared 48px height wehealth renders 247px wide against their 111px
 * — more than double. On a 375px iPhone 6 that left 327px of bar for a 247px logo plus a 93px
 * menu button, and the button wrapped onto a second row, doubling the header to 136px.
 *
 * BOTH dimensions are maxima with both sizes auto, which is the one form that cannot distort: a
 * replaced element with an explicit `height` and a binding `max-width` gets its ratio broken,
 * because the used width becomes the cap while the height stays put. With both auto the browser
 * scales the intrinsic box down until it satisfies both, preserving the ratio.
 *
 * The height cap still expresses the design intent — 48px, sized against the nav — it just stops
 * being the only thing that decides. */
.hub-header__mark img {
  max-block-size: 48px;
  /* A WIDTH CAP TOO, because height alone does not bound a lockup.
   *
   * `100%` here would resolve against the <a>, which is itself sized by this image — circular,
   * and it constrained nothing. This is an absolute cap on the lockup's box, and it is a design
   * constraint rather than a number tuned to today's nav: a lockup occupies at most 200x48,
   * whatever its ratio. hub and phapp render 111px wide and never touch it; wehealth's 5.14:1
   * cut would be 247px and is brought to 200x39.
   *
   * Tuning it to the nav instead was the tempting version and is the wrong one — at 1140px the
   * bar fits mark+nav+search+CTA with ~35px to spare on paper, yet the CTA still wrapped to a
   * second row at 247px and did not at 180px. A cap derived from "what currently fits" would
   * silently expire the next time a nav label changes. */
  max-inline-size: 200px;
  block-size: auto;
  inline-size: auto;
  display: block;
  transition: max-block-size 180ms ease;
}
[data-hub-condensed="true"] .hub-header__mark img { max-block-size: 32px; }
/* ── narrow-screen disclosure ─────────────────────────────────────────────── */

/* Hidden until JS says otherwise, and hidden at every width once the nav fits on one line. */
.hub-nav__toggle { display: none; }

@media (max-width: 899px) {
  [data-hub-nav="enhanced"] .hub-nav__toggle {
    display: inline-flex;
    align-items: center;
    gap: var(--hub-spacing-sm);
    /* 44px, like every other target here (WCAG 2.5.5). */
    min-height: 44px;
    padding-inline: var(--hub-spacing-md);
    margin-inline-start: auto;
    background: none;
    border: 1px solid var(--hub-border-subtle);
    border-radius: var(--hub-spacing-xs);
    color: var(--hub-content-primary);
    font: inherit;
    font-size: var(--hub-footnote-regular-size);
    cursor: pointer;
  }
  /* The nav and the CTA both collapse. Leaving the CTA in the bar would keep two of the three
     rows and defeat the point. */
  [data-hub-nav="enhanced"] .hub-nav,
  [data-hub-nav="enhanced"] .hub-header__cta { display: none; }

  /* Leave room for the menu button on the SAME row.
   *
   * `flex-wrap: wrap` is load-bearing here — it is what lets the open nav take a full second
   * row — but it also means an over-full line BREAKS before its items shrink. So a lockup wide
   * enough to crowd the button does not get squeezed, it pushes the button onto its own row and
   * doubles the header. The percentage resolves against .hub-header__bar (the mark is its flex
   * item), so unlike a max-width on the image it actually constrains something.
   *
   * Sized from the real control: the button measures ~93px with its "Menu" label, plus the bar's
   * column gap. 8rem reserves that with margin to spare, and being rem it holds if the label is
   * translated into something longer. */
  .hub-header__mark { max-inline-size: calc(100% - 8rem); }

  [data-hub-nav="enhanced"][data-hub-nav-open="true"] .hub-nav {
    display: block;
    flex-basis: 100%;
  }
  [data-hub-nav="enhanced"][data-hub-nav-open="true"] .hub-header__cta {
    display: inline-flex;
    flex-basis: 100%;
    justify-content: center;
  }
  [data-hub-nav="enhanced"][data-hub-nav-open="true"] .hub-nav__list {
    flex-direction: column;
    gap: 0;
    padding-block: var(--hub-spacing-sm);
  }
  /* Full-width rows so the whole line is the target, not just the words. */
  [data-hub-nav="enhanced"][data-hub-nav-open="true"] .hub-nav__link {
    display: flex;
    width: 100%;
    padding-block: var(--hub-spacing-sm);
  }
  /* The open panel always needs a surface: at the top of an overlay page the bar is
     transparent, and a menu over a hero is unreadable. */
  [data-hub-nav="enhanced"][data-hub-nav-open="true"] .hub-header__bar {
    background: var(--hub-surface-base);
    border-block-end: 1px solid var(--hub-border-subtle);
  }
}

/* The bars are drawn, not an icon font or an SVG file: three lines is less CSS than either,
   and it cannot 404. currentColor so it follows the button's own colour. */
.hub-nav__toggle-bars {
  position: relative;
  display: block;
  width: 18px;
  height: 2px;
  background: currentColor;
}
.hub-nav__toggle-bars::before,
.hub-nav__toggle-bars::after {
  content: "";
  position: absolute;
  inset-inline: 0;
  height: 2px;
  background: currentColor;
}
.hub-nav__toggle-bars::before { inset-block-start: -6px; }
.hub-nav__toggle-bars::after { inset-block-start: 6px; }
/* Open: the bars become an X. Rotating the existing lines rather than swapping glyphs keeps
   the control's meaning continuous for anyone watching it change. */
[data-hub-nav-open="true"] .hub-nav__toggle-bars { background: transparent; }
[data-hub-nav-open="true"] .hub-nav__toggle-bars::before {
  inset-block-start: 0;
  transform: rotate(45deg);
}
[data-hub-nav-open="true"] .hub-nav__toggle-bars::after {
  inset-block-start: 0;
  transform: rotate(-45deg);
}

/* ── site search ────────────────────────────────────────────────────────────
 *
 * HIDDEN UNTIL ENHANCED, and this is the load-bearing rule rather than a nicety. These sites are
 * static objects in a bucket: there is no endpoint to submit to, so a search field that stayed
 * visible with JavaScript off would be a control that silently does nothing. hub-search.js sets
 * data-hub-search-ready on the header once it can actually answer.
 *
 * `position: static` is deliberate on the form and `absolute` on the results: the panel must
 * escape the header bar's flex row, but the form itself must stay a normal flex item or the bar
 * loses its gap. */
.hub-search { display: none; }
[data-hub-search-ready="true"] .hub-search {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  position: relative;
  gap: var(--hub-spacing-sm);
}
/* Visually hidden, NOT display:none — the element must stay in the accessibility tree. The
 * 1px-clip idiom rather than `clip-path: inset(50%)` because it is the one form every screen
 * reader in our support matrix handles.
 *
 * Shared, because there are now two callers: the search field's label, and the count-up's copy of
 * the final figure (hub-count.js), which is what keeps a screen reader from reading a half-counted
 * number. A third private copy of this idiom is how they start to differ. */
.hub-visually-hidden,
.hub-search__label {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}
/* 44px target (WCAG 2.5.5) as a MINIMUM, so the field grows with the text rather than clipping
 * it (WCAG 1.4.4). `inline-size` in ch tracks the type size — which only works if the type size
 * is itself relative, hence the --hub-web-* scale below.
 *
 * THE WEB SCALE (rem), NOT THE APP SCALE (px). This block first reached for
 * --hub-footnote-regular-size, a hard 15px, and then claimed in this very comment that the
 * field "GROWS with the text at 200% zoom". It did not: an adversarial review measured the input
 * at exactly 154×44 at both 100% and 200% text, because a px font-size cannot respond to the
 * user's setting and `18ch` of a fixed font is a fixed pixel box. The --hub-web-* tokens exist
 * for precisely this (see their own comment: "every clamp keeps a rem term … that is what makes
 * it survive 200% text zoom"), and every other role on these pages already uses them.
 *
 * SURFACE-ELEVATED, NOT SURFACE-SUBTLE. The label is visually hidden, so the placeholder is the
 * whole visible name of this control — and --hub-content-placeholder on --hub-surface-subtle
 * measures 2.75:1 dark / 2.73:1 light, failing 1.4.3 in both themes. On elevated with the
 * secondary content token it is 7.11:1 dark / 7.08:1 light, and it matches the results panel
 * directly beneath it. */
.hub-search__input {
  min-height: 44px;
  inline-size: 18ch;
  max-inline-size: 100%;
  padding-inline: var(--hub-spacing-md);
  background: var(--hub-surface-elevated);
  border: 1px solid var(--hub-border-subtle);
  border-radius: var(--hub-spacing-xs);
  color: var(--hub-content-primary);
  font: inherit;
  font-size: var(--hub-web-footnote-size);
}
.hub-search__input::placeholder { color: var(--hub-content-secondary); opacity: 1; }
.hub-search__input:focus-visible {
  outline: 2px solid var(--hub-focus-ring-color);
  outline-offset: 2px;
  border-color: var(--hub-border-focus);
}
/* The live region.
 *
 * NOT `:empty { display: none }`, which is what this was. A display:none element is not in the
 * accessibility tree, so every query was INSERTING the region with its text already present
 * rather than mutating a region that was already there — the case live-region implementations
 * handle least reliably. It stays rendered and simply has nothing in it; empty, it contributes
 * no width and less height than the 44px input beside it, so it moves nothing. */
.hub-search__status {
  margin: 0;
  color: var(--hub-content-secondary);
  font-size: var(--hub-web-caption-size);
}
/* The results panel. Absolutely positioned so opening it cannot reflow the header — a bar that
 * grows by 300px under the pointer is how you mis-click a nav link. */
.hub-search__results {
  position: absolute;
  inset-block-start: calc(100% + var(--hub-spacing-xs));
  inset-inline-end: 0;
  z-index: 40;
  inline-size: min(28rem, calc(100vw - 2 * var(--hub-spacing-lg)));
  max-block-size: min(60vh, 32rem);
  overflow-y: auto;
  margin: 0;
  padding: var(--hub-spacing-xs);
  list-style: none;
  background: var(--hub-surface-elevated);
  border: 1px solid var(--hub-border-subtle);
  border-radius: var(--hub-spacing-xs);
}
.hub-search__results[hidden] { display: none; }
.hub-search__link {
  display: flex;
  flex-direction: column;
  gap: 2px;
  /* Padding, not a fixed height: a two-line title at 200% zoom must make the row taller
   * instead of spilling out of it. */
  min-height: 44px;
  justify-content: center;
  padding: var(--hub-spacing-sm) var(--hub-spacing-md);
  border-radius: var(--hub-spacing-xs);
  text-decoration: none;
  color: var(--hub-content-primary);
}
.hub-search__link:hover { background: var(--hub-surface-hover); }
.hub-search__result-title { color: var(--hub-content-primary); font-size: var(--hub-web-body-size); }
/* The snippet is clamped to two lines rather than truncated to a character count: a count cuts
 * mid-word at one width and mid-sentence at another, and the clamp reflows correctly at 320dp. */
.hub-search__result-snippet {
  color: var(--hub-content-secondary);
  font-size: var(--hub-web-caption-size);
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

@media (max-width: 899px) {
  /* Search collapses with the nav rather than competing with the logo for a phone's bar width.
   * Same disclosure, so there is still exactly one thing to open. */
  [data-hub-search-ready="true"] .hub-search { display: none; }
  [data-hub-search-ready="true"][data-hub-nav-open="true"] .hub-search {
    display: flex;
    flex-basis: 100%;
  }
  [data-hub-search-ready="true"][data-hub-nav-open="true"] .hub-search__input {
    inline-size: 100%;
  }
  /* Full width and in flow on a phone: 28rem of floating panel on a 320dp screen would hang off
   * the edge, and there is no hover here to justify the overlay in the first place. */
  [data-hub-search-ready="true"][data-hub-nav-open="true"] .hub-search__results {
    position: static;
    inline-size: 100%;
    max-block-size: 50vh;
  }
}

.hub-nav__list,
.hub-footer__list {
  display: flex;
  flex-wrap: wrap;
  gap: var(--hub-spacing-md) var(--hub-spacing-xl);
  margin: 0;
  padding: 0;
  list-style: none;
}
.hub-footer__list { flex-direction: column; gap: var(--hub-spacing-sm); }
.hub-footer__list--row { flex-direction: row; }
/* 44px minimum target, met by padding rather than by a fixed height so the link still grows
 * with the text at 200% zoom instead of clipping. */
.hub-nav__link {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  color: var(--hub-content-secondary);
  text-decoration: none;
}
.hub-nav__link:hover { color: var(--hub-content-primary); }
/* The current page is marked by an underline AND by aria-current in the markup. Colour alone
 * would leave the state unavailable to anyone who cannot separate the two greys. */
.hub-nav__link.is-current {
  color: var(--hub-content-primary);
  text-decoration: underline;
  text-underline-offset: 0.4em;
}
.hub-header__cta {
  min-height: 40px;
  padding-block: var(--hub-spacing-sm);
  transition: min-height 180ms ease, padding-block 180ms ease, font-size 180ms ease;
}
.hub-nav__link { transition: min-height 180ms ease, font-size 180ms ease; }

/* Condensed sizes. The nav and CTA step from the BODY role to the FOOTNOTE role rather than to
 * an invented px value — the type scale has five roles and this is a change of role, not a new
 * size (ADR 0076).
 *
 * The 44px minimum target is deliberately NOT reduced. It is the one dimension here that is a
 * requirement rather than a taste (WCAG 2.5.5), and a header that shrinks its own hit targets
 * on scroll is exactly the pattern that makes a phone unusable one-handed. Only the padding and
 * the type move. */
[data-hub-condensed="true"] .hub-nav__link,
[data-hub-condensed="true"] .hub-header__cta {
  font-size: var(--hub-footnote-regular-size);
}
[data-hub-condensed="true"] .hub-header__cta {
  min-height: 36px;
  padding-block: var(--hub-spacing-xs);
}

@media (prefers-reduced-motion: reduce) {
  .hub-header__bar,
  .hub-header__mark img,
  .hub-header__cta,
  .hub-nav__link { transition: none; }
}

/* OVERLAY lifts the header onto a full-bleed hero so the art still reaches the top edge.
 *
 * Only from 700px up. Below that the nav wraps to two or three rows of unpredictable height,
 * and an absolutely positioned element of unpredictable height over content is a collision
 * waiting for the narrowest phone. On a small screen "flush to the top" is worth less than
 * "the heading is not underneath the menu", so the header simply sits in flow there. The
 * generator adds the modifier only when the first section IS a hero, so /privacy and /terms —
 * which open with body copy — never get an overlay at any width. */
@media (min-width: 700px) {
  /* A ZERO-HEIGHT sticky box. The header must not consume layout height — the hero has to
   * reach the very top edge — but it also has to stay pinned while the page scrolls, and
   * `position: absolute` can only do the first of those. Giving the sticky element no height
   * and letting its bar overflow gets both, with no magic offset to keep in step with the
   * header's real height as it condenses. */
  .hub-header--overlay {
    height: 0;
  }
  .hub-header--overlay + main .hub-hero--field { padding-block-start: calc(var(--hub-spacing-xxl) * 2.5); }
}

.hub-footer {
  border-block-start: 1px solid var(--hub-border-subtle);
  padding-block: calc(var(--hub-spacing-xxl) * 1.5) var(--hub-spacing-xxl);
  margin-block-start: var(--hub-spacing-xxl);
}
.hub-footer__groups {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(190px, 100%), 1fr));
  gap: var(--hub-spacing-xxl) var(--hub-spacing-xl);
}
.hub-footer__heading {
  color: var(--hub-content-secondary);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  margin-block-end: var(--hub-spacing-md);
}
.hub-footer__social { margin-block-start: var(--hub-spacing-xxl); }
.hub-footer__legal {
  margin-block-start: var(--hub-spacing-xxl);
  padding-block-start: var(--hub-spacing-lg);
  border-block-start: 1px solid var(--hub-border-subtle);
  color: var(--hub-content-secondary);
}

/* Chrome links are styled here rather than inheriting the prose link treatment: a header and a
   footer full of blue underlined text reads as a page of links rather than as chrome. The
   underline arrives on hover and focus, so the affordance is still there when reached. */
.hub-footer a {
  color: var(--hub-content-secondary);
  text-decoration: none;
}
.hub-footer a:hover,
.hub-footer a:focus-visible {
  color: var(--hub-content-primary);
  text-decoration: underline;
  text-underline-offset: 0.15em;
}
.hub-header__mark:hover { text-decoration: none; }

/* ── logo row ────────────────────────────────────────────────────────────────
   A row of partner marks that scrolls only when it does not fit.

   WHY NOT A MARQUEE, and why there is no pause button any more.

   WCAG 2.2.2 applies to movement that starts automatically and lasts MORE THAN FIVE SECONDS.
   An infinite marquee is squarely inside that and needs a mechanism to stop it; a row that
   moves once, briefly, on first sight is outside it. Designing the motion to be short removes
   the obligation rather than satisfying it, and removes the control that came with it — which,
   however it was styled, read as a call to action competing with the page's real one.

   What is left is a native scroll container. That is not a compromise: horizontal scrolling is
   swipeable on touch, works with a trackpad, responds to arrow keys once focused, and needs no
   script at all. The arrows are a pointer convenience on top. When the marks fit — any
   reasonably wide window — nothing overflows, the arrows hide, and there is no motion.

   Only ONE copy of the logos exists now. The marquee needed four to close its loop, which meant
   a screen reader met every partner four times.
*/
.hub-logos {
  display: flex;
  align-items: center;
  gap: var(--hub-spacing-sm);
  margin-block-start: var(--hub-spacing-xl);
  /* The arrows are positioned against this box — see .hub-logos__nav. */
  position: relative;
}
.hub-logos__heading {
  color: var(--hub-content-secondary);
  text-align: center;
  margin-block-end: var(--hub-spacing-lg);
}
.hub-logos__track {
  display: flex;
  align-items: center;
  gap: var(--hub-spacing-xxl);
  /* CENTRED when the row fits, start-aligned when it scrolls. `safe` is what makes that one
     declaration instead of two: a centred flex line that overflows would otherwise push its
     first item off the start edge, where no amount of scrolling can reach it. */
  justify-content: safe center;
  flex: 1 1 auto;
  min-width: 0;
  margin: 0;
  padding: 0;
  list-style: none;
  overflow-x: auto;
  /* Releasing a drag settles on a logo boundary instead of wherever momentum stopped, so a
     mark is never left half-cut at the leading edge — which is what "I can see it while I drag
     and then it hides again" actually was. PROXIMITY, not mandatory: mandatory fights the
     reveal tween, which writes scrollLeft every frame and would be re-snapped mid-flight. */
  scroll-snap-type: x proximity;
  /* NO `scroll-behavior: smooth` HERE, and it is worth saying why it is absent rather than
     leaving a gap someone helpfully fills.
 
     The property makes EVERY programmatic scroll animate — including a direct `scrollLeft = n`
     assignment. The reveal is a per-frame tween that writes scrollLeft ~60 times a second, so
     each write started its own competing smooth animation: the row accelerated, stalled,
     reversed and accelerated again. It also swallowed the initial park outright, because the
     animation it started never completed.
 
     The arrows pass `behavior: "smooth"` to scrollBy explicitly, so paging still eases. Motion
     is requested per call now, never imposed on every write. */
  /* The scrollbar is suppressed because the arrows and the fade are the affordance, and a
     horizontal bar under six logos is visual noise on the one platform that always shows it.
     The region stays scrollable by every input that could use the bar. */
  scrollbar-width: none;
  -ms-overflow-style: none;
}
.hub-logos__track::-webkit-scrollbar { display: none; }
/* NO EDGE FADE. It was removed rather than tuned.
 
   The ramp existed to hint that content continues past the edge — but the chevrons already say
   that, unambiguously and with a control attached. What the fade actually did was dim whichever
   logo straddled the edge, which is a partner's mark rendered wrong. Three attempts to make it
   behave (symmetric insets, then per-edge activation) each fixed one end and left the other
   looking deliberate-but-odd. The affordance the arrows provide is enough; the fade was cost
   with no remaining benefit.
 
   The inline padding stays: it keeps the first mark off the container edge, which is spacing
   rather than signalling. */
/* NO leading padding on the track. `.hub-container` already insets the row from the viewport
   edge; adding another 24px here inset the first mark twice and cost the row 24px of usable
   width — which was the whole margin by which the second logo missed. Measured at 390px: the
   second mark ended 22px past the track's right edge, and removing this recovered it. */
.hub-logos[data-scrollable="true"] .hub-logos__track {
  padding-inline: 0;
  scroll-padding-inline-start: 0;
}
/* The trailing inset is a flex SPACER, not padding: Safari and older Chrome drop a flex scroll
   container's trailing padding, which would leave the last mark tight against the edge while
   the first sits clear. Zero-width — the gap flex puts before it IS the inset. */
.hub-logos[data-scrollable="true"] .hub-logos__track::after {
  content: "";
  flex: 0 0 0px;
}
.hub-logos__item {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  scroll-snap-align: start;
}
.hub-logos__item img {
  /* Normalise on HEIGHT: these marks have very different aspect ratios and a shared width makes
     a wordmark tower over a roundel. */
  height: 44px;
  width: auto;
  max-width: none;
  display: block;
}
/* Smaller on a phone, so more than one partner is on screen at a time.
 
   At 44px the widest mark renders 176px, and on a 390px viewport that is a row showing exactly
   one logo however it is scrolled — the marks are introduced one at a time, like a slideshow
   nobody asked for. At 32px the same mark is ~128px, so two fit with the gap between them and
   the row reads as a row. */
@media (max-width: 600px) {
  .hub-logos__item img { height: 32px; }
}
/* Partner marks are dark artwork drawn for a light page. `grayscale(1)` alone — which is what
   the payload's flag literally asks for — renders them near-black on a near-black ground;
   measured, only the one mark with a light element in it was legible. Flattening to white makes
   contrast a property of the treatment rather than of each partner's artwork. */
.hub-logos--muted .hub-logos__item img {
  filter: grayscale(1) brightness(0) invert(1);
  opacity: 0.7;
}
.hub-logos--muted:focus-within .hub-logos__item img { opacity: 1; }
@media (hover: hover) and (pointer: fine) {
  .hub-logos--muted:hover .hub-logos__item img { opacity: 1; }
}

/* The arrows OVERLAY the row rather than sharing the flex line with it.
 
   THIS IS THE FIX FOR THE JUMP. They used to be flex items toggled with `display: none`, one
   side at a time, on a rule that read "each side appears only when that side has something
   hidden" — so the instant you scrolled off the start edge a 44px button plus its gap appeared
   in the line, the track lost 52px, and every logo lurched sideways. Reaching the far end did it
   again in reverse. The affordance that exists to say "there is more this way" was reflowing the
   very thing it points at.
 
   Taking them out of flow settles it completely: the track keeps one width for the whole
   interaction, so an arrow appearing, disappearing or dimming moves nothing. It also retires the
   argument the old rule was built on — the 44px is no longer the track's to reclaim, because the
   arrows never take it in the first place.
 
   Absolute against .hub-logos rather than `position: sticky`: sticky pins to a SCROLLPORT, and
   these are siblings of the scroller, not children of it. Pinning them to the row's own edges is
   what "stays put while the marks move" actually means here. */
.hub-logos__nav { display: none; }
.hub-logos[data-scrollable="true"] .hub-logos__nav {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  position: absolute;
  inset-block-start: 50%;
  transform: translateY(-50%);
  z-index: 1;
  /* 44px, like every other target (WCAG 2.5.5). */
  width: 44px;
  height: 44px;
  padding: 0;
  /* A surface, not `none`. Floating over the marks it now has something behind it, and a
     transparent circle over a logo reads as damage to the logo. */
  background: var(--hub-surface-base);
  border: 1px solid var(--hub-border-subtle);
  border-radius: 50%;
  color: var(--hub-content-secondary);
  cursor: pointer;
  transition: opacity 160ms ease, color 160ms ease, border-color 160ms ease;
}
.hub-logos__nav--prev { inset-inline-start: 0; }
.hub-logos__nav--next { inset-inline-end: 0; }
/* At a limit the arrow DIMS rather than vanishing. Same reason as above — it has nowhere to go,
   which is information, and removing it entirely is what made the row twitch. pointer-events
   off so it cannot be clicked into a no-op. */
.hub-logos[data-scrollable="true"][data-at-start="true"] .hub-logos__nav--prev,
.hub-logos[data-scrollable="true"][data-at-end="true"] .hub-logos__nav--next {
  opacity: 0.3;
  pointer-events: none;
}
.hub-logos[data-scrollable="true"] .hub-logos__nav:hover {
  color: var(--hub-content-primary);
  border-color: var(--hub-border-default);
}

/* The room the overlaid arrows need, given only when they are actually there.
 
   Without this the first and last marks sit UNDER the arrows — which is how the Arizona
   Department of Health Services logo, the last one in the row, ended up permanently half-covered
   at the end of the scroll. scroll-padding matches so a snap lands the mark clear of the arrow
   rather than beneath it, and scroll-margin on the items keeps the same clearance when the
   browser scrolls one into view for a keyboard user. */
.hub-logos[data-scrollable="true"] .hub-logos__track {
  padding-inline: calc(44px + var(--hub-spacing-md));
  /* scroll-padding, not scroll-margin on the items: it offsets BOTH the snap position and
     scrollIntoView (which is how a keyboard user reaches a mark), from one declaration. Setting
     both would add the two offsets together and over-indent every snap. Items already carry
     scroll-snap-align: start, so a settled position now lands the mark clear of the arrow
     rather than beneath it. */
  scroll-padding-inline: calc(44px + var(--hub-spacing-md));
}

@media (prefers-reduced-motion: reduce) {
  .hub-logos[data-scrollable="true"] .hub-logos__nav { transition: none; }
}

/* A chevron drawn from a rotated square: no icon font, no SVG file, nothing that can 404, and
   it follows currentColor. */
.hub-logos__chev {
  display: block;
  width: 9px;
  height: 9px;
  border-inline-start: 2px solid currentColor;
  border-block-start: 2px solid currentColor;
}
.hub-logos__nav--prev .hub-logos__chev { transform: rotate(-45deg) translate(1px, 1px); }
.hub-logos__nav--next .hub-logos__chev { transform: rotate(135deg) translate(1px, 1px); }

/* Reduced motion is handled entirely in script now: the reveal is skipped, and the arrows pass
   behavior:"auto" so paging jumps rather than glides. There is no CSS scroll-behavior left to
   override. */

/* ── numbered checklist ─────────────────────────────────────────────────────── */
.hub-checklist {
  margin-block-start: var(--hub-spacing-xl);
  padding: 0;
  list-style: none;
  counter-reset: hub-step;
  display: grid;
  gap: var(--hub-spacing-xl);
}
.hub-checklist__item {
  counter-increment: hub-step;
  position: relative;
  padding-inline-start: calc(var(--hub-spacing-xxl) + var(--hub-spacing-md));
}
.hub-checklist__item::before {
  content: counter(hub-step);
  position: absolute;
  inset-inline-start: 0;
  inset-block-start: 0;
  width: var(--hub-spacing-xxl);
  height: var(--hub-spacing-xxl);
  display: grid;
  place-items: center;
  border-radius: 50%;
  background: var(--hub-surface-elevated);
  color: var(--hub-accent-surface);
  border: 1px solid var(--hub-border-subtle);
}

/* ── blog post ──────────────────────────────────────────────────────────────── */
/* ── tags ──────────────────────────────────────────────────────────────────────
 * The blog's own taxonomy, linked from the index, from every tagged post and from each other.
 * A pill rather than a plain link because a row of them reads as a set; the count is a span so
 * it is part of the link's accessible name ("Product 14") rather than a second tab stop. */
.hub-tags {
  display: flex;
  flex-wrap: wrap;
  gap: var(--hub-spacing-sm);
  margin-block-start: var(--hub-spacing-xxl);
}
.hub-tag {
  display: inline-flex;
  align-items: center;
  gap: var(--hub-spacing-sm);
  /* Block padding gets the target to the 44dp WCAG 2.5.5 floor without setting a height, which
   * would stop it growing when the page is at 200% text. */
  padding: var(--hub-spacing-md) var(--hub-spacing-lg);
  min-height: 44px;
  box-sizing: border-box;
  border: 1px solid var(--hub-border-subtle);
  border-radius: 999px;
  background: var(--hub-surface-elevated);
  color: var(--hub-content-primary);
  text-decoration: none;
  /* THE `--hub-web-*` SCALE, NOT `--hub-*-regular-size`. The latter are raw px and do not move
   * under text zoom: measured at 320dp, a pill inheriting the page's 19px was byte-identical at
   * 100% and 200% — WCAG 1.4.4 asks for 200%, and a control that ignores it is the same defect
   * the search field shipped with. This scale is rem-based and survives. */
  font-size: var(--hub-web-body-size);
}
.hub-tag:hover { border-color: var(--hub-border-strong); }
.hub-tag__count { color: var(--hub-content-secondary); }
.hub-post__tags { margin-block-start: var(--hub-spacing-xl); }

.hub-post { display: block; }
.hub-post__header { margin-block-end: var(--hub-spacing-xl); }
.hub-post__body { margin-block-start: var(--hub-spacing-xl); max-width: var(--hub-web-measure); }
.hub-post__body img { max-width: 100%; height: auto; border-radius: var(--hub-spacing-sm); }
.hub-post__body h2 { margin-block-start: var(--hub-spacing-xxl); }
.hub-post__body h3 { margin-block-start: var(--hub-spacing-xl); }

/* The "Learn more" affordance on a discovery card. aria-hidden in markup — the card's heading
   is the link and its accessible name — so this is purely visual. */
.hub-card__more {
  margin-block-start: var(--hub-spacing-md);
  color: var(--hub-interactive-primary-surface);
}
