/* 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. It is not a SURFACE or a
   * CONTENT colour — nothing in the UI is this colour; it is what particles look like before
   * they mean anything — which is why it lives in its own `field/` token group.
   *
   * ⚖️ NO LOCAL DEFINITION ANY MORE (2026-08-17, ADR 0087 §4 / docs/ui/onboarding.md D17).
   * This block used to hardcode `--hub-field-dust: #5A6B8C`, in a file whose own header rule
   * is "EVERY value here is a --hub-* token. No raw hex." The exemption was defensible and
   * its consequence was not: the Flutter port of this field had nothing to read, so the value
   * would have been carried twice, by hand, in two languages. It is generated into
   * hub-tokens.css now, from packages/design_tokens/tokens/{light,dark}.tokens.json, and
   * simply cascades in — including the dark value, because this element carries
   * data-hub-theme="dark".
   *
   * ⛔ Do not "restore" a local fallback: `--hub-field-dust: var(--hub-field-dust, …)` is a
   * CYCLIC custom property and resolves to guaranteed-invalid, not to the fallback. If the
   * token is ever absent, hub-field.js already carries its own literal default. */
}

/* 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;
}

/* ── hero background video (ENG-1314) ───────────────────────────────────────────
 *
 * The alternative to the field, not a variant: same band, same scrim, same dark theming — only
 * what sits behind the scrim changes. hub-field.js declines to mount when .hub-hero--video is
 * present, so the canvas and the clip can never both run.
 *
 * z-index -2 is the contrast guarantee: same negative layer as the canvas it replaces, ABOVE the
 * ::before gradient (equal z-index, and a pseudo-element precedes real children in tree order),
 * BELOW the ::after scrim at -1. Nothing the clip does can land on top of the scrim.
 *
 * object-fit: cover — the clips are 16:9 and the band is not. `fill` stretches faces; `contain`
 * letterboxes, which reads as a broken image rather than a design. */
.hub-hero__video {
  position: absolute;
  inset: 0;
  z-index: -2;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  pointer-events: none;
  /* The clip fills the band exactly and `overflow: hidden` clips it, which left a hard horizontal
   * line where the hero met the next section. Fading the BOTTOM of the video to transparent
   * reveals the band's own `background: var(--hub-surface-base)` underneath — #0B1215, so it
   * reads as a fade to black — and the band then meets the next section on a flat colour instead
   * of on a cut frame.
   *
   * A MASK on the video rather than another overlay, for two reasons: the scrim (::after, z -1)
   * and the static gradient (::before, z -2) are both already spoken for, and — the one that
   * matters — an overlay would darken the video AND anything else in that strip, while a mask
   * removes only the video. The pause control sits in exactly that strip.
   *
   * It masks the POSTER too, since that is the same element: reduced-motion and no-JS visitors
   * get the identical edge rather than a still with a hard cut.
   *
   * -webkit- first for Safari < 15.4, which supports only the prefixed property. A browser that
   * understands neither shows the previous hard edge — a cosmetic regression, not a broken hero. */
  --hub-hero-video-fade: 24%;
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - var(--hub-hero-video-fade)), transparent 100%);
  mask-image: linear-gradient(to bottom, #000 calc(100% - var(--hub-hero-video-fade)), transparent 100%);
}

/* With a clip present the static gradient would only mute it — same treatment and reasoning as
 * .hub-field--live. It stays visible under the poster, so the band never flashes flat. */
.hub-hero--video::before { opacity: 0.35; }

/* ⛔ THE SCRIM DOES NOT RELEASE WHEN THE RIGHT COLUMN CARRIES A FORM. MEASURED — ENG-1314.
 *
 * The gradient releases to 22% at 78% and 4% at 100% because the copy stops at 56% and
 * everything past it was assumed decorative. Four heroes put a FORM there.
 *
 * Composited against real frames of the real clips: that column measured 1.00-1.44:1, white on
 * white. The copy column measured 10.7-11.3:1 in the same run — so the scrim is not wrong, it is
 * being asked a question it was not designed for. The field survived the same geometry because
 * it is sparse points on a near-black band; a photographic clip is bright everywhere.
 *
 * 88% uniform (the field's own 42% stop) measures 13.12:1 primary / 9.33:1 secondary against a
 * PURE WHITE pixel, which the field could never produce.
 *
 * ⚠️ 80% would already pass — 9.87:1 / 7.02:1 against that same white pixel. The neighbouring
 * comment's 11.18:1 is against the FIELD's brightest output, not white, which is why the two
 * figures disagree. 88% buys margin and calms the motion behind an input.
 *
 * Costs the right-hand reveal on four pages. An unreadable form is not a design.
 * Recompute before touching the 88%, as the field's scrim also instructs. */
.hub-hero--video.hub-hero--scrim-held::after {
  background: color-mix(in srgb, var(--hub-surface-base) 88%, transparent);
}

/* THE PAUSE CONTROL (WCAG 2.2.2). At the band's corner, not beside the copy: it belongs to the
 * background, and in the text column it would read as a call to action. It sits where the scrim
 * has released to 4%, so it carries its own opaque backing rather than borrowing the scrim's —
 * solid, not translucent, because a translucent chip over a bright frame is the intermittent,
 * position-dependent contrast failure the scrim comment warns about.
 *
 * It ships `hidden`, revealed by hub-video.js once there is something to pause. ⛔ That attribute
 * is INERT without the [hidden] rule below — see it before touching either. */
.hub-hero__motion-toggle {
  position: absolute;
  z-index: 1;
  inset-block-end: var(--hub-spacing-md);
  inset-inline-end: var(--hub-spacing-md);
  /* 44px target (WCAG 2.5.5) as a MINIMUM, not a fixed size, so it grows at 200% text. */
  min-width: 44px;
  min-height: 44px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  cursor: pointer;
  border-radius: 999px;
  background: var(--hub-surface-base);
  border: 1px solid var(--hub-border-subtle);
  color: var(--hub-content-primary);
}

/* ⛔ WITHOUT THIS, `hidden` DOES NOTHING. `[hidden] { display: none }` is a USER-AGENT rule and
 * any author `display` outranks it, so the inline-flex above renders the control anyway.
 *
 * Not cosmetic: hub-video.js returns BEFORE attaching its click handler in every state that sets
 * `hidden`, so what shipped was a 44px button — focusable, announced "Pause background video",
 * wired to nothing, drawn as a solid square because `data-state` is never set on those paths.
 * Measured in Chromium: hidden=true, display=flex, box=44x44, isVisible()=true.
 *
 * This file already knew — `.hub-form__back[hidden]`, `.hub-form__next[hidden]` and
 * `.hub-form__submit[hidden]` carry the same line because `.hub-btn` is also inline-flex, plus
 * five more elsewhere. This rule was the one that forgot. */
.hub-hero__motion-toggle[hidden] { display: none; }

/* `outline-offset: 2px` puts the gap AND the ring outside the chip, over arbitrary video —
 * measured 1.12:1 against the 3:1 WCAG 1.4.11 requires of a focus indicator. The box-shadow
 * extends the chip's own surface 4px past the border box, so the ring's adjacent colour is
 * always that surface and never the clip. */
.hub-hero__motion-toggle:focus-visible {
  outline: 2px solid var(--hub-focus-ring-color);
  outline-offset: 2px;
  box-shadow: 0 0 0 4px var(--hub-surface-base);
}

/* Drawn from data-state, which hub-video.js sets alongside the accessible name from the same
 * value — so icon and label cannot disagree about what pressing it will do. */
.hub-hero__motion-icon {
  display: block;
  width: 14px;
  height: 14px;
  background-color: currentColor;
}
[data-state="playing"] .hub-hero__motion-icon {
  /* ▮▮ */
  clip-path: polygon(0 0, 38% 0, 38% 100%, 0 100%, 0 0, 62% 0, 100% 0, 100% 100%, 62% 100%, 62% 0);
}
[data-state="paused"] .hub-hero__motion-icon {
  /* ▶ — nudged right: a triangle centred by its bounding box reads as left of centre in a circle. */
  clip-path: polygon(15% 0, 100% 50%, 15% 100%);
}

/* ⛔ IN FORCED COLORS THE GLYPH DISAPPEARS. `background-color` is a forced property, so icon and
 * button are repainted to the same system colour — measured rgb(0,0,0) on rgb(0,0,0), 1:1 —
 * while the clip-path survives and paints black on black. Screen-reader users are fine (the
 * aria-label carries the state); the sighted low-vision user this mode exists for sees an empty
 * circle. ButtonText pairs with the button's own forced background. */
@media (forced-colors: active) {
  .hub-hero__motion-icon {
    background-color: ButtonText;
    forced-color-adjust: 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 not a screenful by
 * default — a mid-page hero taller than the screen hides the fact that the page continues.
 * ⚠️ The hero that OPENS a page is the exception, by owner ruling (2026-08-27, ENG-1325): it is a
 * screenful, and the rule for that lives with the header overlay at the end of the chrome
 * section, because the two are one composition. */
.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; }
/* ── the disclosure, and WHEN it applies: whenever the wide bar does not fit ──────
 *
 * FIT-DRIVEN, NOT A BREAKPOINT (ENG-1325). This block was `@media (max-width: 899px)`. Between
 * 900px and ~1150px the wehealth bar did not fit — 200px lockup + 444px nav + 162px search +
 * 178px CTA + three 24px gaps, inside a 1140px container carrying 48px of padding, is ~1128px —
 * so "Join the Forum" wrapped onto a SECOND ROW: 146px of sticky header over the hero, with the
 * CTA sitting on the H1. Measured on the built site at 900, 960, 1000, 1064 and 1100
 * (2026-08-27). The comment on the lockup cap above had already met the shape at 1140px and drawn
 * the right conclusion — a value "derived from what currently fits would silently expire the next
 * time a nav label changes" — and a breakpoint is that value by another name: three sites, three
 * navs, three lockups, and one translation pass moves all of them at once.
 *
 * So hub-motion.js MEASURES. It lays the bar out wide, asks whether it wrapped, and sets
 * data-hub-nav-collapsed on the header; every rule below keys on that attribute and nothing here
 * reads the viewport width. The progressive-enhancement story is unchanged: no script means no
 * attribute, which means a visible nav that wraps and stays operable.
 *
 * data-hub-nav-measuring is the measurement itself: the wide layout at its LARGEST — uncondensed
 * type, the 48px lockup, no transition in flight — so the answer is a function of the viewport and
 * the content, never of how far the page has scrolled. Without it the condensed bar (footnote
 * type, 32px lockup) fits where the resting bar does not, and the menu would come and go with the
 * scroll wheel. The script sets and clears it synchronously around one read, so nothing paints in
 * between.
 *
 * ⚠️ As of 2026-08-27 NOTHING SETS data-hub-condensed: the script that did was removed in
 * 95490db7f (a logo-row change whose message never mentions it), so the condensed rules below are
 * dead and these pins guard a state the page cannot currently enter. They stay because the rules
 * stay: the day condensing comes back, a measurement that read the condensed bar would
 * un-collapse on scroll and re-collapse at the top, silently. (That regression is filed on its
 * own; it is not this change's to fix.)
 *
 * data-hub-nav-settling holds the transitions off for ONE more frame after the measurement. A
 * forced style recalc under `transition: none` becomes the before-change style, so releasing the
 * pins and the transitions in the same frame would animate the lockup and the type from the
 * pinned sizes back to the condensed ones — visible, if condensing ever returns.
 *
 * ⚠️ These pin exactly the WIDTHS the condensed rules further down change. A condensed rule that
 * changes another width needs a line here too, or the header measures the wrong layout. */
.hub-header[data-hub-nav-measuring="true"] .hub-header__bar,
.hub-header[data-hub-nav-measuring="true"] .hub-header__mark img,
.hub-header[data-hub-nav-measuring="true"] .hub-nav__link,
.hub-header[data-hub-nav-measuring="true"] .hub-header__cta,
.hub-header[data-hub-nav-settling="true"] .hub-header__bar,
.hub-header[data-hub-nav-settling="true"] .hub-header__mark img,
.hub-header[data-hub-nav-settling="true"] .hub-nav__link,
.hub-header[data-hub-nav-settling="true"] .hub-header__cta { transition: none; }
.hub-header[data-hub-nav-measuring="true"] .hub-header__mark img { max-block-size: 48px; }
.hub-header[data-hub-nav-measuring="true"] .hub-nav__link { font-size: inherit; }
.hub-header[data-hub-nav-measuring="true"] .hub-header__cta { font-size: var(--hub-web-body-size); }

/* Hidden until the measurement says otherwise — and hidden again the moment the nav fits. */
.hub-nav__toggle { display: none; }

[data-hub-nav-collapsed="true"] .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 the second row and
   defeat the point. */
[data-hub-nav-collapsed="true"] .hub-nav,
[data-hub-nav-collapsed="true"] .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. */
[data-hub-nav-collapsed="true"] .hub-header__mark { max-inline-size: calc(100% - 8rem); }

[data-hub-nav-collapsed="true"][data-hub-nav-open="true"] .hub-nav {
  display: block;
  flex-basis: 100%;
}
[data-hub-nav-collapsed="true"][data-hub-nav-open="true"] .hub-header__cta {
  display: inline-flex;
  flex-basis: 100%;
  justify-content: center;
}
[data-hub-nav-collapsed="true"][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-collapsed="true"][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-collapsed="true"][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. */
/* ⛔ THE STATUS HANGS BELOW THE INPUT, LIKE THE PANEL — IT IS NOT IN THE BAR'S ROW (ENG-1325).
 *
 * It was an inline flex sibling of the input, and the paragraph above said it "contributes no
 * width" — true only while it is EMPTY. "4 results" is 57px; the form grew from 162px to 219px,
 * and at EVERY desktop width (measured 1150 through 1920) the bar wrapped to 146px with the CTA
 * over the H1 — the exact defect the fit-driven collapse had just fixed, reached by typing into
 * search. It persisted after blur, and a resize with the query present measured the wrapped bar
 * and handed a 1920px desktop the hamburger. Found by the independent review; the first detector
 * only ever measured an empty status.
 *
 * So it floats: same anchor and width as the results panel, drawn as the panel's own top card, and
 * the panel starts beneath it. Absolute, so it can say anything without moving the bar. It stays
 * rendered when empty (it is the live region — see above) and simply draws nothing then. */
.hub-search {
  /* The chip's height, so the panel can start under it. rem-based, so it follows text zoom. */
  --hub-search-status-block: calc(var(--hub-web-caption-size) * 1.55 + 2 * var(--hub-spacing-sm) + 2px);
}
.hub-search__status {
  position: absolute;
  inset-block-start: calc(100% + var(--hub-spacing-xs));
  inset-inline-end: 0;
  z-index: 40;
  box-sizing: border-box;
  inline-size: min(28rem, calc(100vw - 2 * var(--hub-spacing-lg)));
  margin: 0;
  padding: var(--hub-spacing-sm) var(--hub-spacing-md);
  line-height: 1.55;
  color: var(--hub-content-secondary);
  font-size: var(--hub-web-caption-size);
  background: var(--hub-surface-elevated);
  border: 1px solid var(--hub-border-subtle);
  border-radius: var(--hub-spacing-xs);
}
.hub-search__status:empty {
  padding: 0;
  border: 0;
  background: none;
}
/* 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;
  /* Under the status chip, which is always filled while this is showing (hub-search.js sets both
   * in the same render). */
  inset-block-start: calc(100% + var(--hub-spacing-xs) + var(--hub-search-status-block) + var(--hub-spacing-xs));
  inset-inline-end: 0;
  z-index: 40;
  /* border-box, like the status chip above it, so the two share an edge — content-box put the
   * panel 10px wider than the chip and made its in-panel `inline-size: 100%` overflow the form. */
  box-sizing: border-box;
  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;
}

/* Search collapses with the nav rather than competing with the lockup for the bar's width. Same
 * disclosure, so there is still exactly one thing to open — and the same fit measurement decides
 * it (see the disclosure block), not a breakpoint: search is ~160px of bar, and it arrives after
 * the nav does, which is one of the two things the measurement is re-run for. */
[data-hub-nav-collapsed="true"][data-hub-search-ready="true"] .hub-search { display: none; }
[data-hub-nav-collapsed="true"][data-hub-search-ready="true"][data-hub-nav-open="true"] .hub-search {
  display: flex;
  flex-basis: 100%;
}
[data-hub-nav-collapsed="true"][data-hub-search-ready="true"][data-hub-nav-open="true"] .hub-search__input {
  inline-size: 100%;
}
/* Full width and in flow inside the panel: 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-nav-collapsed="true"][data-hub-search-ready="true"][data-hub-nav-open="true"] .hub-search__results {
  position: static;
  inline-size: 100%;
  max-block-size: 50vh;
}
/* In the panel the status is just a line under the field: the form is already full-width and in
 * flow, so there is nothing for a floating chip to protect. */
[data-hub-nav-collapsed="true"][data-hub-search-ready="true"][data-hub-nav-open="true"] .hub-search__status {
  position: static;
  inline-size: auto;
  padding: var(--hub-spacing-xs) 0;
  background: none;
  border: 0;
}

.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;
  }
  /* CLEARANCE UNDER THE BAR (ENG-1325). The resting bar is 90px — 16px of padding, the 58px CTA,
   * 16px more — and the hero's copy used to begin at 80px: the H1 started UNDER the header. Four
   * spacing units is 128px, which clears the bar with a line of breathing room. Padding, not a
   * margin or an offset, so the band's background still reaches the top edge. */
  .hub-header--overlay + main .hub-hero--field { padding-block-start: calc(var(--hub-spacing-xxl) * 4); }

  /* THE OPENING HERO IS A SCREENFUL (owner, 2026-08-27, ENG-1325): "make the hero fill the whole
   * vertical screen — in the old design each section was a screenful at a time". This reverses
   * the "never more than the viewport" reasoning on .hub-hero--field for exactly ONE hero: the one
   * that opens a page, which is also the one the header overlays. Mid-page heroes keep their band.
   *
   * `min-height`, never `height`. The HubSpot-era hero was `height: 100vh` and at 200% zoom its
   * copy escaped BOTH ends of the box (apps/hs_cms/tests/e2e/hero-and-heading-overspill.spec.js);
   * a minimum lets a hero taller than the screen grow the page instead. `svh` where it exists, so
   * a mobile browser's retracting URL bar does not leave a strip below the fold. border-box, so
   * the clearance above and the room for the pause control below are INSIDE the screenful rather
   * than added to it — the next section must start exactly at the fold.
   *
   * Flex-centred, not grid min-height: the band's only in-flow child is .hub-container (the clip,
   * the scrims and the pause control are all absolutely positioned), so a column flex with
   * justify-content: center puts the copy in the middle of the screen at any content height and
   * pushes the band taller when the copy is taller than the screen.
   *
   * ⛔ NOT a hero carrying a form (hub-hero--form, emitted by the template). Its second column
   * changes height with every step of the stepped form, and centring it vertically would make the
   * copy jump with each answer. It keeps the clearance and its own band height. */
  .hub-header--overlay + main > .hub-section:first-child > .hub-hero--field:not(.hub-hero--form) {
    box-sizing: border-box;
    min-height: 100vh;
    min-height: 100svh;
    display: flex;
    flex-direction: column;
    justify-content: center;
    /* Room for the pause control at the band's bottom-right corner (12px inset + 44px), so copy
     * that reaches the bottom of a short viewport does not run under it. */
    padding-block-end: 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 — and that
   control, however it was styled, read as a call to action competing with the page's real one.
   A row that never moves on its own owes nothing, because there is no motion to pause. (A
   one-shot reveal, budgeted under the five seconds, sat between the two for a while; the owner
   cut it on 2026-08-27, ENG-1336 — see hub-motion.js.)

   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 and the arrows hide. The row never scrolls by
   itself either way; the only motion its section gets is the page-wide one-time entrance
   (.hub-reveal), which moves the section, not the marks within it.

   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: the snap is a courtesy at
     the end of a flick, and mandatory would also drag every keyboard nudge and every scrollBy
     to the nearest boundary. */
  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 — so any script that positions the track would have to opt out of it write by
     write. (While the row still had its reveal tween, it made every per-frame write start a
     competing animation: the row accelerated, stalled and reversed. The tween is gone; the
     rule below stands on its own.)
 
     The arrows pass `behavior: "smooth"` to scrollBy explicitly, and "auto" under reduced
     motion, so paging eases and motion is requested per call — decided in one place, 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 in script: the arrows pass behavior:"auto" so paging jumps rather
   than glides. Nothing else on the row moves at all, and there is no CSS scroll-behavior 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);
}

/* ── First-party forms (ADR 0168) ─────────────────────────────────────────────
 *
 * The MARKUP for these is built at view time by hub-form.js from the definition the platform
 * serves, so this stylesheet is the only place the form's appearance is decided. There is no
 * template to read alongside it — which is why each rule says what it is for.
 *
 * Every control is 44px minimum in its block axis (WCAG 2.5.5 target size), takes a visible
 * focus ring from the same tokens as the search field (2.4.7), and carries its error as text
 * rather than as colour alone (1.4.1). */
.hub-form { display: block; }

/* The form sits in the hero's second column on a wide screen and below the copy on a narrow
 * one, which the hero grid already does — so this only needs to own its own rhythm. */
.hub-form__form { display: flex; flex-direction: column; gap: var(--hub-spacing-md); }

.hub-form__field { display: flex; flex-direction: column; gap: var(--hub-spacing-xs); }

/* A REAL <label>, never a placeholder standing in for one: a placeholder disappears on focus,
 * which leaves someone who was interrupted mid-form with an unlabelled box (3.3.2). */
.hub-form__label {
  color: var(--hub-content-primary);
  font-size: var(--hub-web-footnote-size);
}
/* "Required" as WORDS beside the label, not an asterisk and not colour. An asterisk is
 * announced inconsistently and means nothing to a reader who has not been told the convention. */
.hub-form__required {
  color: var(--hub-content-secondary);
  font-size: var(--hub-web-caption-size);
}
.hub-form__help {
  margin: 0;
  color: var(--hub-content-secondary);
  font-size: var(--hub-web-caption-size);
}

/* ⛔ --hub-border-default AND A TRANSPARENT FILL, measured rather than chosen.
 *
 * The form sits inside the hero's dark band (data-hub-theme="dark", --hub-surface-base #0B1215).
 * The obvious styling — the search field's --hub-surface-elevated fill with a --hub-border-subtle
 * edge — gives a fill/band ratio of 1.81:1 and a border/fill ratio of 1.42:1, so NEITHER
 * identifies where the field is. WCAG 1.4.11 (Non-text Contrast) needs 3:1 for a control
 * boundary, and a form you cannot see the edges of is the whole failure.
 *
 * Transparent fill means there is exactly ONE boundary to assess, and --hub-border-default
 * measures 4.31:1 against the dark band and 3.39:1 against the light page. Both pass. (The
 * search field's elevated fill is correct for IT: its label is visually hidden, so its
 * placeholder contrast is load-bearing in a way this form's is not — it has real labels.) */
.hub-form__input {
  min-height: 44px;
  inline-size: 100%;
  max-inline-size: 100%;
  box-sizing: border-box;
  padding: var(--hub-spacing-xs) var(--hub-spacing-md);
  background: transparent;
  border: 1px solid var(--hub-border-default);
  border-radius: var(--hub-spacing-xs);
  color: var(--hub-content-primary);
  font: inherit;
  font-size: var(--hub-web-footnote-size);
}
/* Multi-line grows from a sensible floor rather than being pinned: a message box that cannot
 * grow is the single most common complaint about embedded forms, and `resize` is the visitor's
 * to control. */
.hub-form__input--multiline { min-height: 8rem; resize: vertical; }
.hub-form__input::placeholder { color: var(--hub-content-secondary); opacity: 1; }
.hub-form__input:focus-visible {
  outline: 2px solid var(--hub-focus-ring-color);
  outline-offset: 2px;
  border-color: var(--hub-border-focus);
}
/* The invalid state is a BORDER PLUS the error text below; the border alone would be meaning
 * carried by colour (1.4.1), which is why aria-invalid is what actually announces it. */
.hub-form__input[aria-invalid="true"] { border-color: var(--hub-border-error); }

/* The checkbox row.
 *
 * ⛔ THE LABEL IS THE TARGET, and it has to be: `margin-block` on the box is OUTSIDE the element,
 * so an earlier version claiming "keeps its own 44px target" actually shipped a 20x20 pointer
 * target — ADR 0057 sets >=44dp as a hard requirement. A <label for> is clickable, so making the
 * whole row 44px tall and putting the box INSIDE it gives a target that is genuinely 44px in the
 * block axis and as wide as the consent sentence.
 *
 * It is also a ROW rather than the column every other field uses: a checkbox above its own
 * sentence reads as two unrelated things. */
.hub-form__field--checkbox { flex-direction: row; align-items: flex-start; gap: var(--hub-spacing-sm); }
.hub-form__field--checkbox .hub-form__label {
  display: flex;
  align-items: center;
  gap: var(--hub-spacing-sm);
  min-block-size: 44px;
  cursor: pointer;
}
.hub-form__checkbox {
  inline-size: 1.5rem;
  block-size: 1.5rem;
  min-inline-size: 1.5rem;
  flex: none;
  accent-color: var(--hub-interactive-primary-surface);
}
.hub-form__checkbox:focus-visible {
  outline: 2px solid var(--hub-focus-ring-color);
  outline-offset: 2px;
}

/* Per-field error. Hidden until it has something to say, and `hidden` rather than a class so
 * the element is genuinely out of the accessibility tree while empty. */
.hub-form__error {
  margin: 0;
  color: var(--hub-status-high);
  font-size: var(--hub-web-caption-size);
}
.hub-form__error[hidden] { display: none; }

/* The summary is role="alert" and focusable, so a rejected submission announces itself and
 * takes focus — otherwise pressing Send appears to do nothing at all. */
.hub-form__summary {
  padding: var(--hub-spacing-md);
  border: 1px solid var(--hub-border-error);
  border-radius: var(--hub-spacing-xs);
  color: var(--hub-content-primary);
}
.hub-form__summary[hidden] { display: none; }
.hub-form__summary-title { margin: 0 0 var(--hub-spacing-xs); font-weight: 600; }

.hub-form__consent {
  margin: 0;
  color: var(--hub-content-secondary);
  font-size: var(--hub-web-caption-size);
}
.hub-form__submit { align-self: flex-start; }
.hub-form__submit[disabled] { opacity: 0.6; cursor: progress; }

/* ── One question per screen ─────────────────────────────────────────────────
 *
 * The form asks for one field at a time rather than presenting every empty box
 * at once. `hidden` does the hiding (set in hub-form.js), not a class: it takes
 * the step out of the accessibility tree AND the tab order, which is the whole
 * point — a `display:none` class would need `inert` beside it to match.
 *
 * The incoming step SLIDES IN, directionally — forward from the right, back from
 * the left — so next/back reads as movement across one horizontal surface rather
 * than one page replacing another. */
.hub-form__step { display: flex; flex-direction: column; gap: var(--hub-spacing-sm); }
.hub-form__step[hidden] { display: none; }

/* ⛔ ONLY THE INCOMING STEP ANIMATES. Sliding the outgoing one out would mean
 * keeping it rendered while it leaves — and a rendered step is still in the
 * accessibility tree and the tab order, so there would briefly be two live
 * questions. `hidden` prevents that and cannot be transitioned, which is the
 * trade: direction still reads, nothing is ever double-mounted.
 *
 * Transform + opacity only. Animating height or margin would run layout on every
 * frame; these two are composited, so a mid-range phone holds 60fps.
 *
 * ⚠️ 180ms is chosen against FOCUS, not taste. Focus moves immediately rather
 * than on animationend, so this window is how long the caret can be somewhere
 * the eye has not arrived yet. Long enough to read as motion, short enough that
 * a fast typist never races it. Do not lengthen it for elegance.
 *
 * `translateX` is LOGICAL-ish here only because these sites are LTR today; a RTL
 * locale wants the directions swapped, which is a real follow-up and not
 * something a transform can infer. */
@keyframes hub-form-enter-forward {
  from { opacity: 0; transform: translateX(1.5rem); }
  to   { opacity: 1; transform: translateX(0); }
}
@keyframes hub-form-enter-back {
  from { opacity: 0; transform: translateX(-1.5rem); }
  to   { opacity: 1; transform: translateX(0); }
}
.hub-form__step[data-enter="forward"] {
  animation: hub-form-enter-forward 180ms ease-out both;
}
.hub-form__step[data-enter="back"] {
  animation: hub-form-enter-back 180ms ease-out both;
}

/* ⛔ THE GUARD THAT MAKES THE ANIMATION PERMISSIBLE (WCAG 2.3.3). Motion
 * triggered by interaction must be disableable, and the OS setting is the
 * mechanism. The script checks this too; both exist so the animation stays off
 * if either path is ever bypassed. `animation: none` rather than a 0s duration,
 * so no animationend is scheduled at all. */
@media (prefers-reduced-motion: reduce) {
  .hub-form__step[data-enter] { animation: none; }
}

/* Each question gets room to breathe — this is the density fix, not decoration.
 * A single field on its own screen should read as a question being asked, not as
 * one survivor of a form that lost its other rows. */
.hub-form__step .hub-form__label { font-size: var(--hub-web-title-size); line-height: 1.25; }

.hub-form__nav {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--hub-spacing-sm);
  margin-block-start: var(--hub-spacing-md);
}

/* Pushed to the end of the row so the count sits opposite the actions, and it
 * wraps below them rather than squeezing the buttons at 320dp (ADR 0057
 * reflow). */
.hub-form__progress { margin: 0; margin-inline-start: auto; color: var(--hub-content-secondary); }

.hub-form__back[hidden],
.hub-form__next[hidden],
.hub-form__submit[hidden] { display: none; }

/* ⚠️ The buttons are ≥44dp targets via .hub-btn. Do not shrink them here for a
 * denser nav row — target size is a WCAG 2.5.5 floor, not a style preference. */

/* The acknowledgement REPLACES the form, so there is no stale set of filled fields beside a
 * thank-you — and it is role="status", so it is announced rather than only seen. */
.hub-form__done { color: var(--hub-content-primary); }

/* The hero's form column. The hero grid is already two-column at width; this only stops the
 * form stretching to a line length nobody can scan on a very wide screen. */
.hub-hero__form { inline-size: 100%; max-inline-size: 34rem; }
