/* 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.
 */

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