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

/* 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; }
.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; }
.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;
}
.hub-stat { display: flex; flex-direction: column; gap: var(--hub-spacing-xs); }
/* 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. */
.hub-stat__value {
  font-size: var(--hub-title-regular-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; }
.hub-cta .hub-btn--secondary {
  background: transparent;
  color: var(--hub-accent-content);
  border-color: var(--hub-accent-content);
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

/* Wide viewports: the copy is capped to the left 56% of the container, so the scrim only has to
 * hold there — and the right side is free to show the field at nearly full strength. That
 * asymmetry is the whole reason the hero can be striking without ever being unreadable.
 *
 * The strong region runs to 62%, which is past where text can reach at any width: the container
 * is capped at 1140px and centred, so at 1440px the copy ends at ~55% of the band and the
 * fraction only shrinks as the viewport grows. */
@media (min-width: 900px) {
  .hub-hero--field .hub-hero__grid > div:first-child { max-width: 56%; }
  .hub-hero--field::after {
    /* The ramp is SHARP on purpose. A gentle fade to the right edge keeps the scrim at ~65%
     * across the whole field region, which mutes it everywhere and buys nothing — the copy
     * stops at 56%. So the guarantee is held flat to 60% and then released quickly, giving the
     * field ~35% of the band at almost full strength. 60->78% is a wide enough transition that
     * no vertical seam is visible. */
    background: linear-gradient(
      100deg,
      color-mix(in srgb, var(--hub-surface-base) 93%, transparent) 0%,
      color-mix(in srgb, var(--hub-surface-base) 88%, transparent) 42%,
      color-mix(in srgb, var(--hub-surface-base) 80%, transparent) 60%,
      color-mix(in srgb, var(--hub-surface-base) 22%, transparent) 78%,
      color-mix(in srgb, var(--hub-surface-base) 4%, transparent) 100%
    );
  }
}

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

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

/* ── illustration figure ────────────────────────────────────────────────────────
 *
 * A brand illustration presented as artwork rather than as a background.
 *
 * The art is warm daylight; the site around it is dark and abstract. That contrast is the
 * point and is not something to sand down — the hero says the system is precise, the
 * illustration says it is for people. So the treatment is a frame, a radius and generous dark
 * around it: hung, not pasted. Tinting or dimming to "match the theme" would flatten the one
 * warm thing on the page, and a dark-ground rendition is a new piece of brand art
 * (agents/media-studio), never a CSS filter.
 *
 * The frame's own surface shows through the image's transparent edges if any, and gives the
 * plate a defined boundary against the page — without it a light image on a dark page reads as
 * a hole punched in the layout.
 */
.hub-figure { margin: 0; }
.hub-figure__frame {
  border-radius: var(--hub-spacing-lg);
  overflow: hidden;
  border: 1px solid var(--hub-border-subtle);
  background: var(--hub-surface-elevated);
  /* line-height 0 removes the inline-descender gap under the image, which otherwise shows as a
   * few pixels of surface along the bottom edge only — an asymmetry that reads as a mistake. */
  line-height: 0;
}
.hub-figure__frame img {
  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 ───────────────────────────────────────────────
 *
 * NO HAMBURGER, and that is a decision rather than an omission. Four links and a button wrap
 * onto a second line at 320px and stay directly operable; a disclosure menu would add a script,
 * a focus trap, an expanded/collapsed state to announce, and a way for all of it to break —
 * to hide four words. The nav that needs no JavaScript is the one that cannot fail.
 */
.hub-header { position: relative; z-index: 3; }
.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);
}
.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;
}
/* 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. */
.hub-header__mark img {
  height: 30px;
  width: auto;
  display: block;
}
.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); }

/* 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) {
  .hub-header--overlay {
    position: absolute;
    inset-block-start: 0;
    inset-inline: 0;
  }
  .hub-header--overlay + main .hub-hero--field { padding-block-start: calc(var(--hub-spacing-xxl) * 2.5); }
}

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

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

/* ── logo row ────────────────────────────────────────────────────────────────
   A continuous marquee of partner marks.

   MOTION WITH A CONTROL, not motion or nothing. Content that moves automatically for more
   than five seconds needs a mechanism to pause it (WCAG 2.2.2), so this row provides three:
   an explicit toggle button, pause on hover, and pause on keyboard focus anywhere inside it.
   `prefers-reduced-motion` skips the animation entirely and the row simply wraps — no
   transform, no toggle needed, nothing to stop.

   The track is duplicated in markup and translated by exactly -50%, which is what makes the
   loop seamless: at the end of the cycle the second copy sits precisely where the first
   started, so there is no jump to hide. */
.hub-logos {
  margin-block-start: var(--hub-spacing-xl);
  overflow: hidden;
  /* Fade the marks into the page at both ends so a logo does not appear sliced off by the
     container edge. A mask rather than a gradient overlay, so it works on any background. */
  -webkit-mask-image: linear-gradient(to right, transparent, #000 8%, #000 92%, transparent);
  mask-image: linear-gradient(to right, transparent, #000 8%, #000 92%, transparent);
}
.hub-logos__heading {
  color: var(--hub-content-secondary);
  text-align: center;
  margin-block-end: var(--hub-spacing-lg);
}
/* The animated element is the VIEWPORT, holding both track copies side by side. Animating the
   tracks themselves would translate each in place and they would stack, not scroll. */
.hub-logos__viewport {
  display: flex;
  width: max-content;
  animation: hub-logos-marquee 42s linear infinite;
}
.hub-logos:hover .hub-logos__viewport,
.hub-logos:focus-within .hub-logos__viewport,
.hub-logos[data-paused="true"] .hub-logos__viewport {
  animation-play-state: paused;
}
/* NO gap on the track or the viewport. Spacing lives on the ITEM as a trailing margin, so the
   two copies are exactly equal in width and -50% lands the clone precisely where the original
   began. A gap between the tracks would make the loop overshoot by half a gap and put a visible
   hitch in an otherwise continuous row, once every cycle. */
.hub-logos__track {
  display: flex;
  align-items: center;
  margin: 0;
  padding: 0;
  list-style: none;
}
.hub-logos__item {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  margin-inline-end: var(--hub-spacing-xxl);
}
.hub-logos__item img {
  /* Normalise on HEIGHT, not width: 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;
}
/* Partner marks are supplied as dark artwork for a LIGHT page. This site is dark, so
   `grayscale(1)` — which is what the payload's `grayscale` flag literally asks for — renders
   near-black marks on a near-black ground: measured on staging, only the one logo with a light
   element in it was legible at all, and the rest were invisible rather than subtle.
 
   `brightness(0) invert(1)` flattens each mark to pure white first, so every partner reads at
   the same weight regardless of its source colours, and contrast against --hub-surface-base is
   guaranteed rather than dependent on the artwork. Hover lifts the opacity; it does NOT restore
   the original colours, because that would put the dark marks back on the dark page. */
.hub-logos--muted .hub-logos__item img {
  filter: grayscale(1) brightness(0) invert(1);
  opacity: 0.7;
}
.hub-logos--muted:hover .hub-logos__item img,
.hub-logos--muted:focus-within .hub-logos__item img {
  opacity: 1;
}
.hub-logos__control {
  display: block;
  margin: var(--hub-spacing-md) auto 0;
  background: none;
  border: 1px solid var(--hub-border-subtle);
  border-radius: var(--hub-spacing-xs);
  color: var(--hub-content-secondary);
  padding: var(--hub-spacing-xs) var(--hub-spacing-md);
  font: inherit;
  font-size: var(--hub-footnote-regular-size);
  cursor: pointer;
  min-height: 44px; /* WCAG 2.5.5 target size (ADR 0057) */
}
.hub-logos__control:hover {
  color: var(--hub-content-primary);
  border-color: var(--hub-border-default);
}

/* Exactly -50%: at the end of the cycle the second copy sits where the first began, so the
   loop has no seam to hide. */
/* NO var() in here. A custom property inside a calc() inside a keyframe's transform does not
   resolve — the 100% frame is dropped as invalid and the row animates from 0 to 0, which looks
   exactly like "the animation is running" in devtools: correct animation-name, playState
   "running", and a transform matrix that never leaves identity. Measured on staging before the
   fix. Keep this arithmetic-free. */
@keyframes hub-logos-marquee {
  from { transform: translateX(0); }
  to   { transform: translateX(-50%); }
}

@media (prefers-reduced-motion: reduce) {
  .hub-logos {
    -webkit-mask-image: none;
    mask-image: none;
  }
  .hub-logos__viewport {
    animation: none;
    width: auto;
    flex-wrap: wrap;
    justify-content: center;
  }
  .hub-logos__track { flex-wrap: wrap; justify-content: center; }
  /* The clone exists only to close the loop. With no loop it is duplicate content. */
  .hub-logos__track[data-clone="true"] { display: none; }
  .hub-logos__control { display: none; }
}

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