/* ============================================================
   AQUATIC PRECISION FRAMEWORK — Design Tokens
   Ionic CSS Variable Overrides + PaddleStats Semantic Tokens
   ============================================================ */

:root {
  /* ---- Ionic Color Slots ---- */
  --ion-color-primary: #102dde;
  --ion-color-primary-rgb: 16, 45, 222;
  --ion-color-primary-contrast: #ffffff;
  --ion-color-primary-contrast-rgb: 255, 255, 255;
  --ion-color-primary-shade: #0e28c3;
  --ion-color-primary-tint: #2842e1;

  --ion-color-secondary: #374df5;
  --ion-color-secondary-rgb: 55, 77, 245;
  --ion-color-secondary-contrast: #ffffff;
  --ion-color-secondary-contrast-rgb: 255, 255, 255;
  --ion-color-secondary-shade: #3044d8;
  --ion-color-secondary-tint: #4b5ff6;

  --ion-color-success: #2dd36f;
  --ion-color-success-rgb: 45, 211, 111;
  --ion-color-success-contrast: #000000;
  --ion-color-success-contrast-rgb: 0, 0, 0;
  --ion-color-success-shade: #28ba62;
  --ion-color-success-tint: #42d77d;

  --ion-color-warning: #ffc409;
  --ion-color-warning-rgb: 255, 196, 9;
  --ion-color-warning-contrast: #000000;
  --ion-color-warning-contrast-rgb: 0, 0, 0;
  --ion-color-warning-shade: #e0ac08;
  --ion-color-warning-tint: #ffca22;

  --ion-color-danger: #eb445a;
  --ion-color-danger-rgb: 235, 68, 90;
  --ion-color-danger-contrast: #ffffff;
  --ion-color-danger-contrast-rgb: 255, 255, 255;
  --ion-color-danger-shade: #cf3c4f;
  --ion-color-danger-tint: #ed576b;

  --ion-color-light: #f1f4f8;
  --ion-color-light-rgb: 241, 244, 248;
  --ion-color-light-contrast: #1b1b1f;
  --ion-color-light-contrast-rgb: 27, 27, 31;
  --ion-color-light-shade: #d4d7db;
  --ion-color-light-tint: #f2f5f9;

  --ion-color-medium: #92949c;
  --ion-color-medium-rgb: 146, 148, 156;
  --ion-color-medium-contrast: #ffffff;
  --ion-color-medium-contrast-rgb: 255, 255, 255;
  --ion-color-medium-shade: #808289;
  --ion-color-medium-tint: #9d9fa6;

  --ion-color-dark: #1b1b1f;
  --ion-color-dark-rgb: 27, 27, 31;
  --ion-color-dark-contrast: #ffffff;
  --ion-color-dark-contrast-rgb: 255, 255, 255;
  --ion-color-dark-shade: #18181b;
  --ion-color-dark-tint: #323235;

  --ion-background-color: #f7fafe;
  --ion-background-color-rgb: 247, 250, 254;
  --ion-card-background: #ffffff;
  --ion-item-background: #ffffff;
  --ion-toolbar-background: #ffffff;
  --ion-text-color: #1b1b1f;
  --ion-text-color-rgb: 27, 27, 31;
  --ion-tab-bar-background: #ffffff;
  --ion-tab-bar-color: #92949c;
  --ion-tab-bar-color-selected: #102dde;
  --ion-font-family: 'Inter', system-ui, -apple-system, sans-serif;

  /* ============================================================
     PS SEMANTIC DESIGN TOKENS
     Use these in all custom CSS. They auto-flip in dark mode.
     ============================================================ */

  /* ---- Brand ---- */
  --ps-primary: #102dde;
  --ps-primary-rgb: 16, 45, 222;
  --ps-primary-container: #374df5;
  --ps-primary-container-rgb: 55, 77, 245;
  --ps-primary-light: #6b8cff;
  --ps-on-primary: #ffffff;
  --ps-split-cyan: #01bfcb;           /* Logo gradient terminus (teal/cyan) — accent only, never text on light. Locked to actual SVG. */
  --ps-split-cyan-rgb: 1, 191, 203;
  --ps-velocity-blue: #0146e9;        /* Logo gradient start. Locked to actual SVG. */
  --ps-velocity-blue-rgb: 1, 70, 233;
  --ps-ink: #071a33;                  /* Wordmark, monochrome logo */
  --ps-regatta-navy: #061b3a;         /* Dark brand surface */

  /* ---- Surfaces (tonal nesting) ---- */
  --ps-surface: #f7fafe;
  --ps-surface-container-low: #f1f4f8;
  --ps-surface-container-lowest: #ffffff;
  --ps-surface-container-high: #e5e8ec;
  --ps-surface-nested: #ebeef2;
  /* Section background — quiet inset panels, list dividers, :active row
     flashes (EventCard, UpcomingEventsWidget, MiniCalendar, NewsFeedPage…).
     Same tone family as --ps-surface-container-low in light; a step above
     the card surface in dark so it stays visible on both #121214 and
     #1e1e22 parents. */
  --ps-surface-section: #f1f4f8;

  /* ---- Text ---- */
  --ps-on-surface: #181c1f;
  --ps-on-surface-variant: #656468;
  --ps-on-surface-tertiary: #444656;
  /* Darkened from #757688, which measured 3.91:1 on the fact-sheet tint and
     4.04:1 in the footer — both below the 4.5:1 body-text threshold. */
  --ps-on-surface-muted: #6b6c7c;
  /* Muted text alias used by list metadata + input placeholders. */
  --ps-text-muted: #6b6c7c;

  /* ---- Outline / container text ----
     Referenced by AthleteCard, ClubCard, CountryCard, NationalTeamCard and
     MedalCharts long before they were declared here. Until now every one of
     those ~30 declarations fell through to a hardcoded literal, which is why
     card text did not change colour in dark mode at all. */
  --ps-outline: #6b6c7c;
  --ps-on-secondary-container: #4c4e5a;
  /* Hairline/divider outline — chart gridlines and tooltip borders in
     MedalCharts and RaceProgressionChart. All three call sites hardcoded
     `#e5e8ec` as a fallback, so the gridlines stayed light grey in dark mode
     and glowed against the near-black chart. Light value matches the fallback
     exactly; the dark value sits one step above --ps-surface-container-high
     (#2a2a2e) so a 1px rule stays perceivable on both the #1e1e22 card and the
     #2a2a2e section. */
  --ps-outline-variant: #e5e8ec;

  /* ---- Tonal tint plate (indigo container) ----
     A quiet lavender chip that is neither brand-filled nor neutral grey —
     CountryCard's discipline chips. Those two call sites reach for
     --ps-tertiary-fixed / --ps-on-tertiary-fixed, which were never declared, so
     the frozen light literals #dee0ff / #1b1b3a rendered in dark mode too: a
     pale lilac pill punched into a near-black card.
     --ps-surface-tint / --ps-on-surface-tint are the mobile-canon names for the
     same pair and are aliased onto it, so either name resolves to one value and
     the two can never drift. Both pairings are AA: 14.3:1 light, 9.2:1 dark. */
  --ps-tertiary-fixed: #dee0ff;
  --ps-on-tertiary-fixed: #1b1b3a;
  --ps-surface-tint: var(--ps-tertiary-fixed);
  --ps-on-surface-tint: var(--ps-on-tertiary-fixed);

  /* ---- Borders ---- */
  --ps-border-ghost: 1px solid rgba(197, 197, 217, 0.15);
  --ps-border-subtle: 1px solid rgba(197, 197, 217, 0.25);
  --ps-border-color: rgba(197, 197, 217, 0.15);
  --ps-border-color-muted: rgba(197, 197, 217, 0.3);
  --ps-separator-color: rgba(197, 197, 217, 0.2);

  /* ---- Shadows (4-level elevation) ---- */
  --ps-shadow-flat: none;
  --ps-shadow-resting: 0 1px 3px rgba(24, 28, 31, 0.04), 0 4px 12px rgba(24, 28, 31, 0.03);
  --ps-shadow-raised: 0 2px 8px rgba(24, 28, 31, 0.06), 0 12px 32px rgba(24, 28, 31, 0.06);
  --ps-shadow-floating: 0 4px 16px rgba(24, 28, 31, 0.08), 0 24px 48px rgba(24, 28, 31, 0.12);
  --ps-shadow-prominent: 0 8px 24px rgba(16, 45, 222, 0.20), 0 24px 48px rgba(16, 45, 222, 0.10);
  --ps-shadow-brand: 0 12px 32px rgba(16, 45, 222, 0.25);
  --ps-shadow-brand-glow: 0 12px 32px rgba(16, 45, 222, 0.2);

  /* ---- Glass ---- */
  --ps-glass-frosted-bg: rgba(255, 255, 255, 0.60);
  --ps-glass-frosted-border: rgba(255, 255, 255, 0.40);
  --ps-glass-frosted-blur: blur(24px);
  --ps-glass-tinted-bg: rgba(55, 77, 245, 0.06);
  --ps-glass-tinted-border: rgba(55, 77, 245, 0.08);
  --ps-glass-tinted-blur: blur(20px);
  --ps-glass-dark-bg: rgba(18, 18, 20, 0.70);
  --ps-glass-dark-border: rgba(255, 255, 255, 0.08);
  --ps-glass-dark-blur: blur(32px);
  --ps-glass-dark-text: #ffffff;

  /* ---- Gradients ---- */
  /* Surface accent gradient — primary-tinted, for hero cards / featured surfaces. */
  /* NOT the brand-locked logo gradient (which is cyan→blue, see --ps-gradient-logo). */
  --ps-gradient-surface-accent: linear-gradient(135deg, #102dde 0%, #3d56da 100%);
  /* Brand-locked logo gradient — Velocity Blue → Split Cyan. Matches actual SVG. */
  /* Reserved for: the LOGO MARK ITSELF, one hero CTA per page, loading bars, */
  /* accent rules, and empty-state hero illustrations. Never paint surfaces with it. */
  --ps-gradient-logo: linear-gradient(90deg, #0146e9 0%, #01bfcb 100%);
  /* Brand hero gradient — same Velocity Blue → Split Cyan family as --ps-gradient-logo, */
  /* but tuned (135° + tri-stop) to PAINT FULL-BLEED SURFACES (hero backgrounds). */
  /* Use this on HomePage, ContributePage, ContactPage, AthleteProfilePage, */
  /* CountryProfilePage, TeamProfilePage, EventDetailPage heroes. */
  /* If you want a thin accent stripe or CTA pill, use --ps-gradient-logo instead. */
  --ps-gradient-brand-hero: linear-gradient(135deg, #0146e9 0%, #2a5ce8 35%, #01bfcb 100%);
  /* Softer variant for less-prominent heroes (event detail, country profile inline) */
  /* — same hue family but desaturated mid-tones so it reads as background, not paint. */
  --ps-gradient-brand-hero-soft: linear-gradient(135deg, #2a5ce8 0%, #4f7be3 45%, #4ec9d2 100%);
  /* Solid fallback for browsers without gradient support / for partial overlays. */
  --ps-hero-bg: #0146e9;
  /* Text colours on the brand hero gradient.
     MEASURED, 2026-08-12 — the previous comment here claimed "≥ 4.5:1 on every
     stop", which is false and had been relied on. White against each stop:
       #102dde  8.56:1   PASS
       #0146e9  6.88:1   PASS
       #01bfcb  2.25:1   FAIL
       #4ec9d2  1.98:1   FAIL
     So white text is only AA-compliant over the blue end of the gradient. Any
     text placed over the cyan terminus needs a scrim or a darker ink — do not
     assume this pairing is safe just because it is tokenised.
     --ps-on-hero-muted at 0.78 alpha is correspondingly worse, not better.
     Same values in dark mode: heroes use the same gradient, so the failure is
     identical in both palettes. */
  --ps-on-hero: #ffffff;
  --ps-on-hero-muted: rgba(255, 255, 255, 0.78);
  /* Decorative only — watermark initials, hairlines. Never put text on this. */
  --ps-on-hero-faint: rgba(255, 255, 255, 0.15);
  /* Chip sitting ON the hero gradient. Was hardcoded per-usage as
     rgba(222,224,255,.9) on #00105b, which never flipped for dark mode.
     The dark-mode inversion lives in .ion-palette-dark — declaring it here as
     well is what made it dead for two releases. */
  --ps-on-hero-chip-bg: rgba(255, 255, 255, 0.92);
  --ps-on-hero-chip-fg: #00105b;
  /* Scrim for small text that must sit ANYWHERE on the hero gradient, including
     its cyan end (white alone is 2.25:1 there, see above). Chip-ink navy at
     50%: white on the scrim over #01bfcb measures ~6.2:1 and over #0146e9
     well above 7:1. Used by the duplicate-report trigger on the athlete hero. */
  --ps-on-hero-scrim: rgba(0, 16, 91, 0.5);
  /* Bottom-up scrim over a hero photo, so on-hero text keeps contrast whatever
     the photograph does. Same ink in both palettes — a photo is not theme-aware. */
  --ps-gradient-scrim-photo: linear-gradient(
    to top,
    rgba(7, 26, 51, 0.85) 0%,
    rgba(7, 26, 51, 0.45) 40%,
    rgba(7, 26, 51, 0.08) 70%,
    transparent 100%
  );

  /* ---- Medals (legacy — used by MedalBadge, rank badges) ---- */
  --ps-medal-gold: #ffd700;
  --ps-medal-gold-bg: #fef9e7;
  --ps-medal-gold-fg: #b8860b;
  --ps-medal-silver: #c0c0c0;
  --ps-medal-silver-bg: #f4f4f4;
  --ps-medal-silver-fg: #808080;
  --ps-medal-bronze: #cd7f32;
  --ps-medal-bronze-bg: #fdf2e9;
  --ps-medal-bronze-fg: #cd7f32;

  /* ---- Podium rank TEXT (AA-safe ≥4.5:1 on the card surface) ----
     The *-fg medal tokens above are decorative chips that fail AA as small
     rank-digit text on #ffffff (gold 3.25:1, silver 3.95:1, bronze 3.14:1).
     These darkened variants clear 4.5:1 for the live-table rank number. */
  --ps-medal-gold-text: #8a6400;
  --ps-medal-silver-text: #666c77; /* 4.80:1 on --ps-medal-silver-bg, 5.28:1 on card */
  --ps-medal-bronze-text: #9d5c18; /* 4.79:1 on --ps-medal-bronze-bg, 5.28:1 on card */

  /* Caution: a statement is true but qualified — a domestic regime that cannot be
     compared across countries, a crew we hold only part of. Distinct from an error,
     which means something failed. */
  --ps-caution-color: #b45309;
  --ps-caution-bg: #fff7ed;
  --ps-caution-border: #fed7aa;

  /* ---- Event-level badge chips (EventLevelBadge via config/event-levels.ts) ----
     Filled brand chips for the international tiers; quiet tonal chips for
     european/continental/national/unknown. Every pair is defined here AND in
     .ion-palette-dark — the light tonal grey (#e5e8ec) used to be hardcoded
     and glowed like a white pill on dark cards. */
  --ps-level-olympic-bg: #102dde;
  --ps-level-olympic-fg: #ffffff;
  --ps-level-world-bg: #374df5;
  --ps-level-world-fg: #ffffff;
  --ps-level-world-cup-bg: #6b8cff;
  --ps-level-world-cup-fg: #ffffff;
  --ps-level-continental-bg: #e5e8ec;
  --ps-level-continental-fg: #444656;
  --ps-level-national-bg: #e5e8ec;
  --ps-level-national-fg: #656468;

  /* ---- Qualification badges (AA-safe in both palettes) ----
     Replaces the hardcoded #15803d / #0d9488 / rgba tints that never flipped
     for dark mode. Each fg clears 4.5:1 on its bg tint. */
  --ps-qual-final-bg: rgba(21, 128, 61, 0.12);
  /* Darkened from #15803d (4.27:1 — AA FAIL for small label text) to #0f6e2e,
     measured against the COMPOSITED 0.12 green tint over the white card
     (#e3f0e8), not raw white: 5.44:1 — AA. */
  --ps-qual-final-fg: #0f6e2e;
  --ps-qual-semi-bg: rgba(16, 45, 222, 0.10);
  --ps-qual-semi-fg: var(--ps-primary);
  --ps-qual-time-bg: rgba(13, 148, 136, 0.12);
  --ps-qual-time-fg: #0f766e;

  /* ---- Canoe polo advancement bands ----
     A group table at a world championship knocks nobody out: every place leads
     somewhere, and what separates them is how high that somewhere reaches. So
     the bands are ONE hue fading down the table rather than a different colour
     per band — a men's lower second-round group produces five of them, and five
     competing colours would say nothing at all. Gold is reserved for the band
     still playing for a medal. Tints only: the row keeps --ps-on-surface text,
     which stays above 12:1 on every one of them, and the band is also written
     out under the table, so colour is never the only channel. */
  --ps-polo-band-medals: rgba(255, 215, 0, 0.18);
  --ps-polo-band-1: rgba(16, 45, 222, 0.12);
  --ps-polo-band-2: rgba(16, 45, 222, 0.085);
  --ps-polo-band-3: rgba(16, 45, 222, 0.055);
  --ps-polo-band-4: rgba(16, 45, 222, 0.03);
  /* The rule between two bands. Carries the boundary on its own where two
     neighbouring tints are close, and for anyone who cannot tell them apart. */
  --ps-polo-band-cut: rgba(24, 28, 31, 0.18);

  /* ---- Live (broadcast red — RESERVED for the "live" signal ONLY) ----
     Red appears nowhere else in the system. It marks the live pulse, the LIVE
     badge, the current-race accent ring and the just-changed row flash. Never
     reuse it for errors, danger or destructive actions (those stay on
     --ion-color-danger). */
  --ps-live-accent: #d4283b;
  --ps-live-accent-rgb: 212, 40, 59;
  --ps-live-bg-soft: rgba(212, 40, 59, 0.08);
  --ps-live-row-changed: rgba(212, 40, 59, 0.12);
  /* Text-on-tint variant: a slightly darker red used for the LIVE label and
     the changed-row marker text so accent text on --ps-live-bg-soft clears
     4.5:1 (the raw #d4283b sits at 4.47:1 on the tint — just under AA). */
  --ps-live-accent-text: #b81f30;
  /* Deeper fill carrying WHITE text (e.g. a solid pill). White on #c2152a is
     ≥5.5:1; the brighter --ps-live-accent stays for the dot/ring only. */
  --ps-live-accent-fill: #c2152a;
  /* Amber "data is stale" text (>5min during racing). #b45309 on white is
     5.9:1 — clears AA. */
  --ps-live-stale-fg: #b45309;
  /* Solid CTA fill for the small live CTAs (widget + compare). White on this
     clears 4.5:1 across the whole label — unlike the brand-hero gradient whose
     lightest cyan stop is only 2.25:1 with white. */
  --ps-live-cta-bg: #102dde;

  /* ---- Chart Rank (monochromatic blue for data viz) ---- */
  --ps-chart-rank-1: #102dde;
  --ps-chart-rank-1-muted: rgba(16, 45, 222, 0.45);
  --ps-chart-rank-1-text: #0e28c3;
  --ps-chart-rank-2: #374df5;
  --ps-chart-rank-2-muted: rgba(55, 77, 245, 0.45);
  --ps-chart-rank-2-text: #3044d8;
  --ps-chart-rank-3: #6b8cff;
  --ps-chart-rank-3-muted: rgba(107, 140, 255, 0.45);
  --ps-chart-rank-3-text: #4b6bcc;
  --ps-chart-rank-4: #2e9bb0;
  --ps-chart-rank-5: #1a8a5c;
  --ps-chart-rank-6: #c2841a;
  --ps-chart-rank-7: #d45a2b;
  --ps-chart-rank-8: #9b4dca;
  --ps-chart-rank-9: #7a7a8a;

  /* ---- Compare (athlete A = cyan, athlete B = navy/blue) ----
     Two persistent identity colors used across the whole Compare view so the
     eye never re-learns who's who. A draws from the Split Cyan logo terminus,
     B from the brand primary. `-strong` variants are darkened for legible text
     on light surfaces; both flip lighter in dark mode below. */
  --ps-compare-a: #06b6d4;
  --ps-compare-a-rgb: 6, 182, 212;
  --ps-compare-a-strong: #0b7c8a;
  --ps-compare-a-grad: linear-gradient(145deg, #22d3ee 0%, #0b7c8a 100%);
  --ps-compare-b: #102dde;
  --ps-compare-b-rgb: 16, 45, 222;
  --ps-compare-b-strong: #0e28c3;
  --ps-compare-b-grad: linear-gradient(145deg, #374df5 0%, #0e28c3 100%);
  /* Ink rendered ON the gradient avatars (initials). The light-mode gradients
     are deep enough for white ink; the dark-mode gradients are deliberately
     lightened, so the ink flips near-black there (see .ion-palette-dark). */
  --ps-compare-a-on: rgba(255, 255, 255, 0.9);
  --ps-compare-b-on: rgba(255, 255, 255, 0.9);

  /* ---- Radius ---- */
  --ps-radius-xs: 4px;
  --ps-radius-sm: 8px;
  --ps-radius-md: 12px;
  --ps-radius-lg: 16px;
  --ps-radius-xl: 20px;
  --ps-radius-2xl: 24px;
  --ps-radius-full: 999px;

  /* ---- Font Families ---- */
  --ps-font-display: 'Sora', 'Inter', system-ui, -apple-system, sans-serif;
  --ps-font-body: 'Inter', system-ui, -apple-system, sans-serif;
  --ps-font-mono: 'Geist Mono', 'JetBrains Mono', ui-monospace, monospace;

  /* ---- Typography ---- */
  --ps-text-display-lg: 3.5rem;
  --ps-text-display-md: 2.5rem;
  --ps-text-display-sm: 2rem;
  --ps-text-headline-md: 1.5rem;
  --ps-text-headline-sm: 1.25rem;
  /* Title role at body-lg size — a section/card heading that is NOT a headline.
     Referenced by LiveEventsHub, CompareDisciplineFilter and CompareZoneHeader
     before it was declared; the two Compare sites passed NO fallback, so those
     `font-size` declarations were invalid and dropped, silently rendering at the
     inherited size. LiveEventsHub's own fallback was already 1.125rem. */
  --ps-text-title-md: 1.125rem;
  --ps-text-body-lg: 1.125rem;
  --ps-text-body-md: 1rem;
  /* Unsuffixed alias of the default body size. One caller (EventDetailPage)
     reaches for it with a `1rem` fallback. Kept as an alias, not a literal, so
     the two can never drift apart. */
  --ps-text-body: var(--ps-text-body-md);
  --ps-text-body-sm: 0.875rem;
  /* 13px rung. The ramp jumped 0.75rem → 0.875rem with nothing between, so 27
     `font-size` declarations hardcode 13px / 0.8125rem to fill the gap
     (ContextSidebar, SideNavBar labels, MedalCharts totals, …). */
  --ps-text-body-xs: 0.8125rem;
  --ps-text-label-lg: 0.875rem;
  --ps-text-label-md: 0.75rem;
  --ps-text-label-sm: 0.6875rem;
  /* Smallest text rung, same size as label-md but for prose, not labels.
     Referenced by EventResultsPage with a 0.78rem fallback — an off-ramp value.
     Snapped to the ramp's 0.75rem, so that one site shrinks by 0.48px. */
  --ps-text-xs: 0.75rem;

  /* ---- Line height ----
     The ramp had none, so 14 distinct line-heights ship today. These four rungs
     cover the largest clusters (1.5 ×23, 1.3 ×20, 1.6 ×14, 1.1 ×8); snap to the
     nearest rung rather than adding a fifth. */
  --ps-leading-tight: 1.1;
  --ps-leading-snug: 1.3;
  --ps-leading-normal: 1.5;
  --ps-leading-relaxed: 1.6;

  /* ---- Letter spacing ----
     Likewise none, so 12 distinct letter-spacings ship. `-wide` (0.06em ×62) and
     `-label` (0.1em ×23) are the two big intentional clusters — the uppercase
     eyebrow/label voice. `-tight` is for large display type; `-normal` exists so
     a component can explicitly cancel inherited tracking. */
  --ps-tracking-tight: -0.01em;
  --ps-tracking-normal: 0;
  --ps-tracking-wide: 0.06em;
  --ps-tracking-label: 0.1em;

  --ps-weight-display: 900;
  --ps-weight-headline: 700;
  --ps-weight-body: 400;
  --ps-weight-label: 700;
  /* Intermediate weights. Referenced by profile/bio styles long before they were
     declared here, which silently rendered them at the browser default of 400. */
  --ps-weight-semibold: 600;
  --ps-weight-medium: 500;

  /* ---- Spacing scale ----
     Introduced because section styles were reaching for raw 3px/6px/9px values,
     which is how the athlete profile ended up with 9px labels. Anything that is
     not a hairline border should come from this scale. */
  --ps-space-1: 4px;
  --ps-space-2: 8px;
  --ps-space-3: 12px;
  --ps-space-4: 16px;
  --ps-space-5: 24px;
  --ps-space-6: 32px;
  /* NOT FOR MOBILE. --ps-space-7 and --ps-space-8 must not be used below
     600px — a 48px gap on a 360px screen spends 13% of the width on nothing,
     and canon §D3 is that vertical/edge chrome is the expensive failure. Both
     rungs stay in the ramp for >=600px layouts (desktop shell, hero blocks).
     Below 600px, step down to --ps-space-6 or lower. */
  --ps-space-7: 40px;
  --ps-space-8: 48px;

  /* Half-steps (mobile canon §1.1). Ordinal naming cannot absorb a new rung
     without renumbering, and renumbering --ps-space-5 would silently move all
     37 live call sites — so the half-steps are suffixed and collide with
     nothing. Nothing above is renamed, renumbered or retired.
     3px / 5px / 7px / 9px / 11px deliberately get NO token: they are 315
     combined uses of off-grid noise and must be snapped to the nearest rung.
     Tokenising them would legitimise the drift. */
  --ps-space-0-5: 2px;
  --ps-space-1-5: 6px;
  --ps-space-2-5: 10px;
  --ps-space-3-5: 14px;
  --ps-space-4-5: 20px;
  --ps-space-5-5: 28px;

  /* ---- Density (mobile canon §1.3) ----
     Resolved THROUGH the ramp above, never restating pixels, so a later
     decision to tighten mobile is one line per token.
     Today the entity cards disagree: AthleteCard 24px, Club/Country/NT 20px,
     StatCard 16px, EventCard 10px 12px — a visible content-width difference
     between the Athletes tab and the Clubs tab on a 360px screen. */
  --ps-card-pad-x: var(--ps-space-4); /* 16px */
  --ps-card-pad-y: var(--ps-space-4); /* 16px */
  --ps-card-pad-x-compact: var(--ps-space-3); /* 12px */
  --ps-card-pad-y-compact: var(--ps-space-2-5); /* 10px */
  /* Mobile content gutter. Deliberately named APART from the
     --ps-shell-gutter-* family, which governs the >=768 desktop shell and must
     not be touched. */
  --ps-page-gutter-x: var(--ps-space-4); /* 16px */
  --ps-stack-gap: var(--ps-space-3); /* 12px */
  /* Row height for a list record. Derived from --ps-tap-min by construction: a
     record IS a touch target — the most-tapped one in the app — so the two must
     not drift. Note this makes a record 48px, not the 44px in the canon §1.3
     table; that is the deliberate consequence of the single-value tap minimum. */
  --ps-record-h: var(--ps-tap-min); /* 48px */
  --ps-record-h-compact: var(--ps-tap-min-dense); /* 36px — see the dense exception */
  --ps-chip-pad-x: var(--ps-space-2); /* 8px */
  --ps-chip-pad-y: var(--ps-space-0-5); /* 2px */

  /* ---- Canoe polo (match format) ----
     Dimensional only. Canoe polo introduces NO new colour of its own: wherever
     the discipline is labelled it wears --ps-discipline-polo, resolved through
     core/util/disciplineBrand.ts like every other discipline. Spacing does not
     vary by theme, so these are declared once and never re-stated under dark
     mode. */

  /* Width of the score column. Measured on the WIDEST thing that column ever
     holds, which is a scheduled match's kick-off time ("10:20"), not a
     two-digit score — so a card keeps its geometry when the match moves from
     scheduled to finished and the day's list does not reflow while it is read. */
  --ps-polo-score-col: 56px;

  /* Reserved whether or not a flag exists. CountryFlag renders nothing when
     flag_emoji is null, and one team at the 2024 World Championships has no
     country, so without a fixed box the table rows would not align. */
  --ps-polo-flag-col: var(--ps-space-5);

  /* Position column in the group table and the final classification.
     28px fits "=22". */
  --ps-polo-rank-col: var(--ps-space-5-5);

  /* One statistic column (P/W/D/L/GF/GA/GD). Widened to --ps-space-6 from 768px
     by the component itself. */
  --ps-polo-stat-col: var(--ps-space-5-5);

  /* One team row inside a match card. On the ramp: --ps-space-5-5. */
  --ps-polo-team-row-h: var(--ps-space-5-5);

  /* Minimum width of the team column in a group table.
     In rem, deliberately. It was 8em, and `table-layout: fixed` resolves column
     widths from the FIRST row — the header, where font-size is 11px — so 8em
     came out as 88px, not the 128 it looks like. The table then fitted its
     container, the scroller had nothing to scroll, and the surplus left the one
     column carrying text at ~100px, of which the flag and its gap take 32.
     9.5rem is measured on "Netherlands", the longest name at the 2024 worlds. */
  --ps-polo-team-col: 9.5rem;

  /* ---- Touch targets ----
     ONE number, both platforms. 48 clears Apple's 44pt and Material's 48dp
     because it is larger than both, so no platform detection is involved.
     An earlier draft scoped a 44/48 pair to Ionic's .ios / .md root classes.
     That is more precise but makes every touch target in the app silently
     depend on Ionic's platform sniffing being right inside a WebView — a
     dependency that breaks without failing loudly. A single value cannot drift
     and cannot be half-applied.
     Nothing in the codebase did this before: 44px appeared raw 123 times and
     48px 66 times, with nothing distinguishing the two.
     The visible glyph may stay small; the HIT AREA is what must reach the
     minimum. Use the .ps-hit / .ps-hit--y utilities in interactions.css, which
     read this token. */
  --ps-tap-min: 48px;
  /* Documented exception, NOT a second default. Permitted only for dense inline
     rows that keep >=12px of separation from neighbouring targets, and every
     use must carry a comment saying why. */
  --ps-tap-min-dense: 36px;

  /* ---- Fluid display typography (T-CFH) ----
     Scale smoothly between mobile and desktop so hero/page titles fill their
     two-col tablet layouts without snapping at hard breakpoints. Apply ONLY to
     hero titles and page titles — body / h2 / h3 / label sizes stay fixed so
     the reading rhythm does not shift as the viewport scales. Uses `vw` for
     maximum browser support (equivalent to `vi` on the inline axis in
     horizontal-tb writing modes, which is every page in this app). */
  /* Floors lowered 2rem→1.75rem (lg) and 1.75rem→1.5rem (md) so the fluid range
     actually engages at phone widths. lg was pinned flat at its 32px floor
     everywhere below 400px — every phone from a 320px SE to a 390px 14 Pro got
     the identical oversized value and the 2.4vw term did nothing. Crossover now
     sits at 233px, i.e. always engaged. Ceilings unchanged (56px / 44px), so
     tablet and desktop render exactly as before.
     Caveat for md: its floor was NOT the binding constraint at 360px (preferred
     28.16px already cleared the 28px floor there), so this only changes widths
     below 350px. What keeps md nearly flat across phones is the shallow 1.6vw
     slope against a 1.4rem intercept — 28.16px at 360 to 29.28px at 430 — and
     that is a slope decision, not a floor one. */
  --ps-text-display-lg-fluid: clamp(1.75rem, 1.4rem + 2.4vw, 3.5rem);
  --ps-text-display-md-fluid: clamp(1.5rem, 1.4rem + 1.6vw, 2.75rem);
  --ps-text-display-sm-fluid: clamp(1.5rem, 1.3rem + 1vw, 2.25rem);
  --ps-text-page-title-fluid: clamp(1.5rem, 1.3rem + 0.8vw, 2rem);

  /* ---- Hero content frame (T-CFH) ----
     Caps the *content* rectangle of the brand hero so it never stretches
     edge-to-edge visually. The gradient surface itself can still span the
     page width (HomePage TA12 fix) — the inner copy / stats / illustration
     sits inside this clamp with auto margins. */
  --ps-hero-content-max: 1280px;

  /* ---- Timing ---- */
  --ps-duration-fast: 100ms;
  --ps-duration-normal: 150ms;
  --ps-duration-moderate: 250ms;
  --ps-duration-slow: 400ms;
  --ps-duration-glacial: 600ms;

  /* ---- Easing ---- */
  --ps-ease-default: cubic-bezier(0.25, 0.1, 0.25, 1);
  --ps-ease-out: cubic-bezier(0, 0, 0.58, 1);
  --ps-ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
  --ps-ease-snappy: cubic-bezier(0.2, 0, 0, 1);

  /* ---- Focus ---- */
  --ps-focus-ring: 0 0 0 2px #f7fafe, 0 0 0 4px #102dde;

  /* ---- Shell Layout ---- */
  --ps-shell-max-width: 1360px;
  --ps-shell-gutter-xs: 24px;
  --ps-shell-gutter-sm: 40px;
  /* One desktop alignment for navigation, page content and footer.
     Keep md/lg as aliases for existing page shells. Values are total gutters. */
  --ps-shell-gutter-desktop: 56px;
  --ps-shell-gutter-md: var(--ps-shell-gutter-desktop);
  --ps-shell-gutter-lg: var(--ps-shell-gutter-desktop);
  --ps-shell-width-desktop: min(var(--ps-shell-max-width), calc(100% - var(--ps-shell-gutter-desktop)));
  --ps-browse-sidebar-gap: 28px;

  /* ---- News feature images ----
     Every news cover comes out of the card generator at 1200x630 (the OG/social
     card format). Any box that renders one with `object-fit: cover` MUST use
     this ratio, or cover silently slices the graphic — and these covers carry
     baked-in text and the PaddleStats logo hard against the left edge, so a
     crop eats the branding first. A 16/9 box (1.778) already cost ~7% of the
     width; a 104x72 thumb (1.444) cost ~24%. Centering does not help: the loss
     is symmetric. Match the ratio instead of nudging object-position. */
  --ps-news-cover-ratio: 1200 / 630;

  /* ---- Side Navigation Rail (T-B tablet pass) ----
     Default is 0 (mobile: rail is hidden, drawer overlay instead).
     Promoted to 72px at 768+ (icon-only rail) and 240px at 1024+ (icon+label).
     Pages add `padding-inline-start: var(--ps-rail-width)` to clear the rail.
     A user-controlled collapse on the rail can re-set `--ps-rail-width` to 72
     at landscape via a class on <html> (`.ps-rail-collapsed`). */
  --ps-rail-width: 0px;
  --ps-rail-width-collapsed: 72px;
  --ps-rail-width-expanded: 240px;

  /* ---- Layout contract (tablet audit 2026-07) ----
     Persistent filter sidebars and multi-column layouts are DESKTOP-CHROME
     features: they may appear only at >=1024px, exactly where MobileBottomNav
     hides and the AppHeader top-nav appears. 768-1023 is single-column with
     the mobile toolbar/filter-panel pattern. A third column (program rail /
     summary) requires >=1280px. Main content column must never drop below
     --ps-main-col-min. */
  --ps-main-col-min: 480px; /* reference value for reviews/tests; not consumed by layout math */

  /* Vertical space consumed by the floating bottom-nav pill (66px pill +
     12px offset + 18px breathing room) + safe area. The LAST element of a
     page's scroll runway (usually AppFooter) carries this clearance so
     content is never trapped under the pill. Zeroed at >=1024px below —
     the same breakpoint where MobileBottomNav hides. (P-M1) */
  --ps-bottom-nav-clearance: calc(96px + env(safe-area-inset-bottom, 0px));

  /* ---- Breakpoints (reference values; @media still uses raw px) ----
     Canonical 5-tier ramp adopted in iteration 5 (tablet pass).
     mobile           ≤599   (phones)
     mobile-l         600-767 (large phones, foldables open)
     tablet-portrait  768-1023 (iPad mini portrait, iPad portrait)
     tablet-landscape 1024-1279 (iPad landscape, small laptops)
     desktop          ≥1280 (iPad Pro 12.9 landscape, laptops, desktops) */
  --ps-bp-mobile-l: 600px;
  --ps-bp-tablet-portrait: 768px;
  --ps-bp-tablet-landscape: 1024px;
  --ps-bp-desktop: 1280px;

  /* ---- Sticky Toolbar (coordinated with WS-B list page shell) ---- */
  /* Used by list pages: SearchToolbar sticky bar (top:0), */
  /* active-filter chip strip (top:--ps-toolbar-height), and scroll-margin-top */
  /* on list anchors so anchored scrolls don't hide under both layers. */
  --ps-toolbar-height: 56px;
  --ps-toolbar-active-chips-height: 44px;
  --ps-scroll-margin-list-anchor: 112px; /* toolbar + chips + 12px breathing room */

  /* ---- Data Viz ---- */
  --ps-viz-blue: #3b82f6;
  --ps-viz-emerald: #10b981;
  --ps-viz-amber: #f59e0b;
  --ps-viz-rose: #f43f5e;
  --ps-viz-cyan: #06b6d4;
  --ps-viz-violet: #8b5cf6;

  /* ---- Cards ---- */
  --ps-card-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(246, 249, 255, 0.98));
  --ps-card-border: 1px solid rgba(24, 28, 31, 0.06);
  --ps-card-shadow: 0 16px 34px rgba(24, 28, 31, 0.06);
  --ps-card-inset: inset 0 1px 0 rgba(255, 255, 255, 0.7);
  --ps-card-combined-shadow: 0 16px 34px rgba(24, 28, 31, 0.06), inset 0 1px 0 rgba(255, 255, 255, 0.7);
  --ps-surface-card: #ffffff;

  /* ---- Status / Semantic Colors ---- */
  --ps-status-active-color: #15803d;
  --ps-status-active-bg: rgba(21, 128, 61, 0.08);
  --ps-gender-female-color: #b4235a;
  --ps-gender-female-bg: rgba(244, 114, 182, 0.14);
  /* Something FAILED — a request errored, a panel could not load. Distinct from
     --ps-caution-* (true but qualified) and from --ps-live-accent (broadcast red,
     reserved for the live signal and never reusable for errors).
     AthleteCompareSearchPanel's error state referenced this before it existed and
     fell through to --ps-on-surface, rendering an error identically to body text.
     6.5:1 on white / 9.7:1 on the #1e1e22 dark card — AA in both. */
  --ps-status-error-color: #b3261e;

  /* ---- Overlays ---- */
  --ps-overlay-dropdown-bg: rgba(255, 255, 255, 0.96);
  --ps-overlay-panel-bg: rgba(255, 255, 255, 0.96);
  --ps-overlay-panel-border: rgba(255, 255, 255, 0.42);
  --ps-overlay-backdrop: rgba(10, 14, 20, 0.34);
  --ps-overlay-border: 1px solid rgba(24, 28, 31, 0.08);
  /* Color-only border for nested separators inside overlay panels (e.g.
   * the divider between command-palette header and body). Pair with an
   * explicit `1px solid` declaration site-side. */
  --ps-overlay-divider: rgba(24, 28, 31, 0.08);
  --ps-search-btn-bg: rgba(255, 255, 255, 0.9);
  --ps-context-link-bg: rgba(238, 242, 249, 0.9);
  --ps-flag-box-gradient: linear-gradient(180deg, #ffffff, #edf2fb);
  --ps-hover-subtle-bg: rgba(24, 28, 31, 0.04);
  /* Active filter chip (news discipline chips + friends): a primary tint
     that stays clearly LIGHTER than the inactive chip in both palettes —
     the old hardcoded rgba(16,45,222,0.1) went near-black in dark mode. */
  --ps-filter-chip-active-bg: rgba(16, 45, 222, 0.10);

  /* ---- Z-Index ---- */
  --ps-z-base: 0;
  --ps-z-raised: 10;
  --ps-z-sticky: 100;
  --ps-z-overlay: 200;
  --ps-z-modal: 300;
  --ps-z-toast: 400;

  /* ============================================================
     Global Search Modal — Mobile redesign tokens
     See docs/audit/08-global-search-visual.md §12 for spec.
     ============================================================ */

  /* Header & searchbar */
  --ps-search-header-bg: var(--ps-gradient-brand-hero);
  --ps-search-input-bg: rgba(255, 255, 255, 0.16);
  --ps-search-input-fill-strong: rgba(255, 255, 255, 0.22);
  --ps-search-input-focus-ring: 0 0 0 3px rgba(1, 191, 203, 0.55);
  --ps-search-on-input: var(--ps-on-hero);
  --ps-search-on-input-muted: var(--ps-on-hero-muted);
  --ps-search-clear-btn-bg: rgba(255, 255, 255, 0.22);

  /* Scope chips */
  --ps-search-chip-active-bg: var(--ps-on-hero);
  --ps-search-chip-active-fg: var(--ps-primary);
  --ps-search-chip-inactive-bg: rgba(255, 255, 255, 0.14);
  --ps-search-chip-inactive-fg: var(--ps-on-hero);
  --ps-search-chip-mask-fade: linear-gradient(to right, transparent 0, black 24px, black calc(100% - 24px), transparent 100%);

  /* Top hit card */
  --ps-search-tophit-bg: var(--ps-gradient-brand-hero-soft);
  --ps-top-hit-shadow: 0 8px 24px rgba(1, 70, 233, 0.22);
  --ps-top-hit-avatar-bg: rgba(255, 255, 255, 0.92);
  --ps-top-hit-avatar-fg: var(--ps-primary);
  --ps-top-hit-avatar-ring: 2px solid rgba(255, 255, 255, 0.4);
  --ps-top-hit-flag-badge-ring: 0 0 0 2px rgba(255, 255, 255, 0.95);
  --ps-top-hit-chevron-bg: rgba(255, 255, 255, 0.18);
  --ps-top-hit-active-glow: 0 0 0 3px rgba(1, 191, 203, 0.55), 0 8px 28px rgba(1, 191, 203, 0.35);
  --ps-top-hit-eyebrow-glyph-color: var(--ps-split-cyan);

  /* Entity accents — Athletes */
  --ps-entity-athlete-accent: var(--ps-primary);
  --ps-entity-athlete-tint-bg: rgba(16, 45, 222, 0.08);
  --ps-entity-athlete-tint-fg: var(--ps-primary);
  /* Entity accents — Events */
  --ps-entity-event-accent: var(--ps-split-cyan);
  --ps-entity-event-tint-bg: rgba(1, 191, 203, 0.10);
  --ps-entity-event-tint-fg: #01939d;
  /* Entity accents — Clubs */
  --ps-entity-club-accent: #0891b2;
  --ps-entity-club-tint-bg: rgba(8, 145, 178, 0.10);
  --ps-entity-club-tint-fg: #0891b2;
  /* Entity accents — Countries */
  --ps-entity-country-accent: #b45309;
  --ps-entity-country-tint-bg: rgba(180, 83, 9, 0.08);
  --ps-entity-country-tint-fg: #b45309;
  /* Entity accents — Coaches (defined for completeness; not currently rendered in v1) */
  --ps-entity-coach-accent: #7c3aed;
  --ps-entity-coach-tint-bg: rgba(124, 58, 237, 0.08);
  --ps-entity-coach-tint-fg: #7c3aed;

  /* Result row interactions */
  --ps-search-row-active-bg: rgba(16, 45, 222, 0.06);
  --ps-search-row-press-bg: rgba(16, 45, 222, 0.10);

  /* Section header */
  --ps-search-section-title-color: var(--ps-on-surface);
  --ps-search-section-count-color: var(--ps-on-surface-muted);
  --ps-search-see-all-bg: var(--ps-context-link-bg);
  --ps-search-see-all-bg-hover: rgba(16, 45, 222, 0.12);
  --ps-search-see-all-fg: var(--ps-primary);

  /* Idle state */
  --ps-search-recent-chip-bg: var(--ps-surface-container-low);
  --ps-search-recent-chip-fg: var(--ps-on-surface);
  --ps-search-recent-chip-remove-fg: var(--ps-on-surface-muted);
  --ps-search-tile-athletes-bg: rgba(16, 45, 222, 0.15);
  --ps-search-tile-events-bg: rgba(1, 191, 203, 0.15);
  --ps-search-tile-clubs-bg: rgba(8, 145, 178, 0.15);
  --ps-search-tile-countries-bg: rgba(180, 83, 9, 0.13);

  /* No-results */
  --ps-search-no-results-glow: radial-gradient(circle, rgba(1, 191, 203, 0.12) 0%, transparent 70%);
  --ps-search-suggestion-pill-bg: var(--ps-glass-tinted-bg);
  --ps-search-suggestion-pill-fg: var(--ps-primary);

  /* Skeleton */
  --ps-skeleton-top-hit-bg: linear-gradient(135deg, rgba(1, 191, 203, 0.06), rgba(1, 70, 233, 0.06));

  /* Motion */
  --ps-duration-modal-present: 220ms;
  --ps-duration-stagger-row: 30ms;
  --ps-duration-stagger-section: 60ms;

  /* Grab handle (sheet dismiss affordance) */
  --ps-overlay-grab-handle: rgba(255, 255, 255, 0.4);

  /* ============================================================
     Mobile Bottom Nav — Floating Glass Pill
     See docs/audit/09-bottom-nav-glass.md for spec.
     ============================================================ */
  --ps-bottom-nav-glass-bg: rgba(247, 250, 254, 0.70);
  --ps-bottom-nav-glass-stroke: rgba(255, 255, 255, 0.55);
  --ps-bottom-nav-edge-highlight: rgba(255, 255, 255, 0.65);
  --ps-bottom-nav-glass-shadow: 0 8px 24px rgba(24, 28, 31, 0.08), 0 2px 6px rgba(24, 28, 31, 0.04);
  --ps-bottom-nav-glass-fallback-bg: var(--ps-surface-card);
  --ps-bottom-nav-tab-active-bg: rgba(16, 45, 222, 0.12);
  --ps-bottom-nav-tab-press-bg: rgba(16, 45, 222, 0.16);
}

/* Bottom-nav clearance collapses where the pill disappears — MUST stay on
   the same breakpoint as MobileBottomNav's `display: none` (1024px). */
@media (min-width: 1024px) {
  :root {
    --ps-bottom-nav-clearance: 0px;
  }
}

/* ============================================================
   DARK MODE — Class-based (.ion-palette-dark)
   ============================================================ */
.ion-palette-dark {
  --ion-background-color: #121214;
  --ion-background-color-rgb: 18, 18, 20;
  --ion-text-color: #e4e4e8;
  --ion-text-color-rgb: 228, 228, 232;
  --ion-card-background: #1e1e22;
  --ion-item-background: #1e1e22;
  --ion-toolbar-background: #1b1b1f;
  --ion-tab-bar-background: #1b1b1f;
  --ion-tab-bar-color: #6b6b73;
  --ion-tab-bar-color-selected: #6b8cff;
  --ion-color-light: #2a2a2e;
  --ion-color-light-rgb: 42, 42, 46;
  --ion-color-light-contrast: #e4e4e8;
  --ion-color-light-contrast-rgb: 228, 228, 232;
  --ion-color-light-shade: #242428;
  --ion-color-light-tint: #353539;

  /* PS Semantic Overrides */
  --ps-primary: #6b8cff;
  --ps-primary-rgb: 107, 140, 255;
  --ps-on-primary: #1b1b1f;

  /* Dark mode inverts the hero chip: a translucent ink plate carries white text,
     so it reads as part of the dark UI instead of a light patch punched into it.
     These lived in :root, where a later duplicate overwrote them — the intent was
     written down but never reached the screen. */
  --ps-on-hero-chip-bg: rgba(9, 12, 28, 0.55);
  --ps-on-hero-chip-fg: #ffffff;
  /* Dark hero ends on a lighter cyan (#2dd5d8); 50% chip ink still keeps
     white at ~5.4:1 there. */
  --ps-on-hero-scrim: rgba(0, 16, 91, 0.5);
  /* The :root glow is tuned to brand blue on white; on near-black surfaces it is
     invisible, so dark mode needs the lifted brand tint. Same story — it was
     declared in :root, which meant LIGHT mode was rendering the dark glow. */
  --ps-shadow-brand-glow: 0 12px 32px rgba(107, 140, 255, 0.18);

  --ps-surface: #121214;
  --ps-surface-container-low: #1b1b1f;
  --ps-surface-container-lowest: #1e1e22;
  --ps-surface-container-high: #2a2a2e;
  --ps-surface-nested: #2a2a2e;
  /* Section panels/dividers: one step ABOVE the card tone (#1e1e22) so the
     inset panel / divider stays perceivable on both page and card parents. */
  --ps-surface-section: #2a2a2e;

  --ps-on-surface: #e4e4e8;
  --ps-on-surface-variant: #9d9fa6;
  --ps-on-surface-tertiary: #9d9fa6;
  /* Lifted from #6b6b73 for the same reason as the light value. */
  --ps-on-surface-muted: #9799a8;
  --ps-outline: #9799a8;
  --ps-on-secondary-container: #b9bbc7;
  /* Chart gridlines / tooltip borders (dark). One step above
     --ps-surface-container-high (#2a2a2e) so the 1px rule survives on the
     #1e1e22 card and the #2a2a2e section alike. */
  --ps-outline-variant: #3a3a40;

  /* Tonal tint plate (dark): the indigo container inverts — a deep indigo
     ground carrying pale indigo ink, instead of the light lilac pill that was
     frozen in from the CountryCard fallbacks. 9.2:1 — AA.
     The --ps-surface-tint / --ps-on-surface-tint aliases follow automatically;
     they are declared once in :root and must NOT be repeated here, or they
     would pin themselves to the light values. */
  --ps-tertiary-fixed: #262a3d;
  --ps-on-tertiary-fixed: #c9cdf0;
  /* Brighter than --ps-on-surface-muted: placeholders sit on the lifted
     --ps-surface-section (#2a2a2e), where #6b6b73 drops under ~2.6:1. */
  --ps-text-muted: #9d9fa6;

  --ps-border-ghost: 1px solid rgba(197, 197, 217, 0.08);
  --ps-border-subtle: 1px solid rgba(197, 197, 217, 0.15);
  --ps-border-color: rgba(197, 197, 217, 0.08);
  --ps-border-color-muted: rgba(197, 197, 217, 0.15);
  --ps-separator-color: rgba(197, 197, 217, 0.1);

  --ps-shadow-resting: 0 1px 3px rgba(0, 0, 0, 0.15), 0 4px 12px rgba(0, 0, 0, 0.10);
  --ps-shadow-raised: 0 2px 8px rgba(0, 0, 0, 0.20), 0 12px 32px rgba(0, 0, 0, 0.15);
  --ps-shadow-floating: 0 4px 16px rgba(0, 0, 0, 0.25), 0 24px 48px rgba(0, 0, 0, 0.20);
  --ps-shadow-prominent: 0 8px 24px rgba(42, 63, 199, 0.30), 0 24px 48px rgba(42, 63, 199, 0.15);
  --ps-shadow-brand: 0 12px 32px rgba(107, 140, 255, 0.2);

  --ps-glass-frosted-bg: rgba(30, 30, 34, 0.70);
  --ps-glass-frosted-border: rgba(255, 255, 255, 0.06);
  --ps-glass-tinted-bg: rgba(107, 140, 255, 0.08);
  --ps-glass-tinted-border: rgba(107, 140, 255, 0.12);

  --ps-gradient-surface-accent: linear-gradient(135deg, #2a3fc7 0%, #2e44b0 100%);
  /* Logo gradient (dark): slightly brighter so it reads on near-black surfaces */
  --ps-gradient-logo: linear-gradient(90deg, #2a6df0 0%, #2dd5d8 100%);
  /* Brand hero gradient (dark): lifted stops so it reads on near-black surfaces, */
  /* while preserving the Velocity-Blue → Split-Cyan brand voice. */
  --ps-gradient-brand-hero: linear-gradient(135deg, #2a6df0 0%, #4a8be8 40%, #2dd5d8 100%);
  --ps-gradient-brand-hero-soft: linear-gradient(135deg, #4a8be8 0%, #6ba0e6 45%, #65cfd6 100%);
  --ps-hero-bg: #2a6df0;
  /* On-hero text: same as light mode — the hero gradient is the same family in dark mode, */
  /* so white-on-gradient still passes WCAG AA (and the hero is *more* saturated in dark, */
  /* not less, so contrast is preserved). */
  --ps-on-hero: #ffffff;
  --ps-on-hero-muted: rgba(255, 255, 255, 0.78);

  --ps-medal-gold-bg: rgba(255, 215, 0, 0.12);
  --ps-medal-silver-bg: rgba(192, 192, 192, 0.10);
  --ps-medal-bronze-bg: rgba(205, 127, 50, 0.12);
  /* Medal fg accents lifted for dark surfaces (same values as the AA-safe
     *-text variants below) — the light values (#b8860b/#808080/#cd7f32)
     only read as 3px borders, not as text, on near-black. */
  --ps-medal-gold-fg: #e3b341;
  --ps-medal-silver-fg: #c4c8cf;
  --ps-medal-bronze-fg: #e0935a;

  /* Polo advancement bands (dark): the blue lifts to the dark primary and the
     tints carry further, because a 0.06 tint is invisible on near-black. */
  /* The dark-palette gold (#e3b341), not the raw #ffd700: pure yellow at low
     alpha over near-black composites olive rather than gold. */
  --ps-polo-band-medals: rgba(227, 179, 65, 0.18);
  --ps-polo-band-1: rgba(107, 140, 255, 0.22);
  --ps-polo-band-2: rgba(107, 140, 255, 0.16);
  --ps-polo-band-3: rgba(107, 140, 255, 0.1);
  --ps-polo-band-4: rgba(107, 140, 255, 0.055);
  --ps-polo-band-cut: rgba(255, 255, 255, 0.22);

  /* ---- Live (broadcast red — dark): brighter so the pulse reads on dark surfaces ---- */
  --ps-live-accent: #ff5366;
  --ps-live-accent-rgb: 255, 83, 102;
  --ps-live-bg-soft: rgba(255, 83, 102, 0.12);
  --ps-live-row-changed: rgba(255, 83, 102, 0.18);
  /* Live label text (dark). The accent dot/ring keeps the brighter #ff5366
     (graphical object, 3:1 suffices) but the small LABEL text must clear 4.5:1
     on the bg-soft tint over EVERY dark surface it can land on — including the
     lightest --ps-surface-container-high #2a2a2e, where #ff5366 only reaches
     3.92:1 (fails). #ff7d8c is verified ≥4.5:1 in every case (small label size):
       over rgba(255,83,102,0.12) tint composited on
         #2a2a2e (high)   = 5.03:1  ← worst case, PASS
         #1e1e22 (lowest) = 5.87:1
         #1b1b1f (low)    = 6.05:1
         #121214 (surface)= 6.68:1
     White text on a solid #ff5366 fill is only 3.14:1 — so any FILLED pill
     carrying white text uses a deeper fill instead (white on #d11f33 ≈ 4.6:1). */
  --ps-live-accent-text: #ff7d8c;
  --ps-live-accent-fill: #d11f33;
  /* Amber stale text (dark): lighter so it reads on dark surfaces (≥4.5:1). */
  --ps-live-stale-fg: #fbbf24;
  /* Live CTA fill (dark): a saturated blue that still carries white at ≥4.5:1
     (dark --ps-primary #6b8cff would only give ~2.4:1 with white). */
  --ps-live-cta-bg: #374df5;

  /* Podium rank text (dark): lifted so it clears 4.5:1 on the #1e1e22 card. */
  --ps-medal-gold-text: #e3b341;
  --ps-medal-silver-text: #c4c8cf;
  --ps-medal-bronze-text: #e0935a;

  --ps-caution-color: #f0a868;
  --ps-caution-bg: #2a1d12;
  --ps-caution-border: #4a3320;

  /* Event-level badge chips (dark). Brand-filled tiers keep their light
     values (filled chips read fine on dark cards); the tonal tiers flip
     from #e5e8ec to the lifted section grey with AA-safe label text. */
  --ps-level-olympic-bg: #102dde;
  --ps-level-olympic-fg: #ffffff;
  --ps-level-world-bg: #374df5;
  --ps-level-world-fg: #ffffff;
  --ps-level-world-cup-bg: #6b8cff;
  --ps-level-world-cup-fg: #ffffff;
  --ps-level-continental-bg: #2a2a2e;
  --ps-level-continental-fg: #c4c8cf;
  --ps-level-national-bg: #2a2a2e;
  --ps-level-national-fg: #9d9fa6;

  /* Qualification badges (dark): tints + text tuned for the dark card surface.
     fg verified ≥4.5:1 (small label) on the tint composited over the #1e1e22
     card body: final #4ade80 = 6.73:1 · time #2dd4bf = 6.45:1. */
  --ps-qual-final-bg: rgba(74, 222, 128, 0.16);
  --ps-qual-final-fg: #4ade80; /* 6.73:1 on tint over #1e1e22 — AA */
  --ps-qual-semi-bg: rgba(107, 140, 255, 0.18);
  /* Dedicated dark value: raw --ps-primary (#6b8cff) is only 4.10:1 on this tint
     over the #1e1e22 card (fails 4.5:1 for the ~11px small label). Brightened to
     #a8b6ff = 6.52:1 — AA, in line with the sibling final/time fg tokens. */
  --ps-qual-semi-fg: #a8b6ff;
  --ps-qual-time-bg: rgba(45, 212, 191, 0.16);
  --ps-qual-time-fg: #2dd4bf; /* 6.45:1 on tint over #1e1e22 — AA */

  /* Compare identity colors (dark): lift the fills and brighten the text variants */
  --ps-compare-a: #22d3ee;
  --ps-compare-a-rgb: 34, 211, 238;
  --ps-compare-a-strong: #67e8f5;
  --ps-compare-a-grad: linear-gradient(145deg, #67e8f5 0%, #22d3ee 100%);
  --ps-compare-b: #6b8cff;
  --ps-compare-b-rgb: 107, 140, 255;
  --ps-compare-b-strong: #a9bcff;
  --ps-compare-b-grad: linear-gradient(145deg, #8aa3ff 0%, #374df5 100%);
  /* On-gradient ink flips to near-black: white ink would land at ~1.6:1 on
     the lifted cyan A gradient and ~2.4:1 on the light stop of B. */
  --ps-compare-a-on: #1b1b1f;
  --ps-compare-b-on: #1b1b1f;

  --ps-focus-ring: 0 0 0 2px #121214, 0 0 0 4px #6b8cff;

  /* Cards */
  --ps-card-gradient: linear-gradient(180deg, rgba(30, 30, 34, 0.98), rgba(26, 26, 30, 0.98));
  --ps-card-border: 1px solid rgba(255, 255, 255, 0.06);
  --ps-card-shadow: 0 16px 34px rgba(0, 0, 0, 0.15);
  --ps-card-inset: 0 0 0 0 transparent;
  --ps-card-combined-shadow: 0 16px 34px rgba(0, 0, 0, 0.15);
  --ps-surface-card: #1e1e22;

  /* Status / Semantic Colors */
  --ps-status-active-color: #4ade80;
  --ps-status-active-bg: rgba(74, 222, 128, 0.10);
  --ps-gender-female-color: #f472b6;
  --ps-gender-female-bg: rgba(244, 114, 182, 0.12);
  /* Error text (dark): the light #b3261e is only ~1.7:1 on the #1e1e22 card.
     Lifted to the pale-red container ink — 9.7:1 on that card. */
  --ps-status-error-color: #f2b8b5;

  /* Overlays */
  --ps-overlay-dropdown-bg: rgba(30, 30, 34, 0.96);
  --ps-overlay-panel-bg: rgba(30, 30, 34, 0.96);
  --ps-overlay-panel-border: rgba(255, 255, 255, 0.08);
  --ps-overlay-backdrop: rgba(0, 0, 0, 0.5);
  --ps-overlay-border: 1px solid rgba(255, 255, 255, 0.06);
  --ps-overlay-divider: rgba(255, 255, 255, 0.08);
  --ps-search-btn-bg: rgba(42, 42, 46, 0.9);
  --ps-context-link-bg: rgba(42, 42, 46, 0.9);
  --ps-flag-box-gradient: linear-gradient(180deg, #2a2a2e, #1e1e22);
  --ps-hover-subtle-bg: rgba(255, 255, 255, 0.06);
  /* Active filter chip (dark): the dark-primary tint at a strength that
     reads LIGHTER than the inactive #1e1e22 chip (matches --ps-qual-semi-bg). */
  --ps-filter-chip-active-bg: rgba(107, 140, 255, 0.18);

  /* ---- Global Search Modal — dark variants ---- */

  /* Header & searchbar (gradient itself already auto-flips) */
  --ps-search-input-bg: rgba(255, 255, 255, 0.10);
  --ps-search-input-fill-strong: rgba(255, 255, 255, 0.14);
  --ps-search-clear-btn-bg: rgba(255, 255, 255, 0.14);

  /* Scope chips */
  --ps-search-chip-inactive-bg: rgba(255, 255, 255, 0.08);

  /* Top hit card */
  --ps-top-hit-shadow: 0 12px 32px rgba(42, 109, 240, 0.30);
  --ps-top-hit-avatar-bg: rgba(30, 30, 34, 0.92);
  --ps-top-hit-chevron-bg: rgba(255, 255, 255, 0.10);

  /* Entity accents (dark-mode lifted variants) */
  --ps-entity-athlete-tint-bg: rgba(107, 140, 255, 0.14);
  --ps-entity-event-accent: #2dd5d8;
  --ps-entity-event-tint-bg: rgba(45, 213, 216, 0.14);
  --ps-entity-event-tint-fg: #2dd5d8;
  --ps-entity-club-accent: #22b8d4;
  --ps-entity-club-tint-bg: rgba(34, 184, 212, 0.14);
  --ps-entity-club-tint-fg: #22b8d4;
  --ps-entity-country-accent: #f59e0b;
  --ps-entity-country-tint-bg: rgba(245, 158, 11, 0.14);
  --ps-entity-country-tint-fg: #f59e0b;
  --ps-entity-coach-accent: #a78bfa;
  --ps-entity-coach-tint-bg: rgba(167, 139, 250, 0.14);
  --ps-entity-coach-tint-fg: #a78bfa;

  /* Result row interactions */
  --ps-search-row-active-bg: rgba(107, 140, 255, 0.10);
  --ps-search-row-press-bg: rgba(107, 140, 255, 0.16);

  /* Section header */
  --ps-search-see-all-bg-hover: rgba(107, 140, 255, 0.18);

  /* Idle state — heavy tile tints lifted for dark surfaces */
  --ps-search-tile-athletes-bg: rgba(107, 140, 255, 0.22);
  --ps-search-tile-events-bg: rgba(45, 213, 216, 0.22);
  --ps-search-tile-clubs-bg: rgba(34, 184, 212, 0.22);
  --ps-search-tile-countries-bg: rgba(245, 158, 11, 0.20);

  /* No-results */
  --ps-search-no-results-glow: radial-gradient(circle, rgba(45, 213, 216, 0.18) 0%, transparent 70%);

  /* Skeleton */
  --ps-skeleton-top-hit-bg: linear-gradient(135deg, rgba(45, 213, 216, 0.10), rgba(42, 109, 240, 0.10));

  /* Grab handle */
  --ps-overlay-grab-handle: rgba(255, 255, 255, 0.25);

  /* Mobile Bottom Nav — Floating Glass Pill (dark) */
  --ps-bottom-nav-glass-bg: rgba(28, 28, 32, 0.62);
  --ps-bottom-nav-glass-stroke: rgba(255, 255, 255, 0.06);
  --ps-bottom-nav-edge-highlight: rgba(255, 255, 255, 0.10);
  --ps-bottom-nav-glass-shadow: 0 8px 24px rgba(0, 0, 0, 0.45), 0 2px 6px rgba(0, 0, 0, 0.25);
  --ps-bottom-nav-glass-fallback-bg: var(--ps-surface-card);
  --ps-bottom-nav-tab-active-bg: rgba(107, 140, 255, 0.18);
  --ps-bottom-nav-tab-press-bg: rgba(107, 140, 255, 0.26);
}

/* Ionic's palettes/dark.class.css (imported BEFORE this file in style.css)
   re-declares the core colors under `.ion-palette-dark.ios` / `.md`
   (specificity 0,2,0), which beats the single-class block above — on real
   iPhones (plt mode "ios") the page background became pure #000, breaking
   the "no pure black" rule and creating hard black bands next to #1b1b1f
   sections. Re-assert the PaddleStats dark palette at equal specificity so
   the platform mode never changes our surface tones. */
.ion-palette-dark.ios,
.ion-palette-dark.md {
  --ion-background-color: #121214;
  --ion-background-color-rgb: 18, 18, 20;
  --ion-text-color: #e4e4e8;
  --ion-text-color-rgb: 228, 228, 232;
  --ion-card-background: #1e1e22;
  --ion-item-background: #1e1e22;
  --ion-toolbar-background: #1b1b1f;
  --ion-tab-bar-background: #1b1b1f;
}

/* ---- Global Focus ---- */
:focus-visible {
  outline: none;
  box-shadow: var(--ps-focus-ring);
}

:focus:not(:focus-visible) {
  outline: none;
  box-shadow: none;
}

/* Ionic buttons never received the ring above. The focusable element is
   `button.button-native` inside the component's shadow root — it carries
   `outline: none` from Ionic's own stylesheet, and the host element does not
   match :focus-visible, so neither selector reached it. Measured effect: the
   only focus feedback on the header back / search / share buttons was a
   background shift of 1.11:1, which is invisible.

   ::part() is the one hook that crosses the shadow boundary. Without this the
   app cannot be navigated by keyboard or by iOS/Android switch control. */
ion-button::part(native):focus-visible,
ion-back-button::part(native):focus-visible,
ion-tab-button::part(native):focus-visible,
ion-item::part(native):focus-visible,
ion-searchbar input:focus-visible {
  outline: none;
  box-shadow: var(--ps-focus-ring);
  /* The ring is drawn outside the button box; without this it is clipped by
     the toolbar's overflow. */
  z-index: 1;
}

/* ---- Discipline Colors ----
   Brand palette, canonical in docs/brand/03-color.md ("Discipline palette").
   Three tokens per discipline:
     --ps-discipline-X        main colour (text, icons, accents)
     --ps-discipline-X-bg     8% wash for UI chips — translucent, so it also
                              works on dark surfaces
     --ps-discipline-X-tint   the brand's solid light background, for light-only
                              artwork (news covers, IG cards, print). Never use
                              it as a UI surface: it does not adapt to dark mode. */
:root {
  --ps-discipline-sprint: #102dde;
  --ps-discipline-sprint-bg: rgba(16, 45, 222, 0.08);
  --ps-discipline-sprint-tint: #f1f3fc;
  --ps-discipline-slalom: #0891b2;
  --ps-discipline-slalom-bg: rgba(8, 145, 178, 0.08);
  --ps-discipline-slalom-tint: #f2f9fb;
  --ps-discipline-polo: #7c3aed;
  --ps-discipline-polo-bg: rgba(124, 58, 237, 0.08);
  --ps-discipline-polo-tint: #f7f5fc;
  --ps-discipline-sup: #059669;
  --ps-discipline-sup-bg: rgba(5, 150, 105, 0.08);
  --ps-discipline-sup-tint: #f1f9f5;
  --ps-discipline-ocean: #0f6b78;
  --ps-discipline-ocean-bg: rgba(15, 107, 120, 0.08);
  --ps-discipline-ocean-tint: #f2f8f9;
  --ps-discipline-marathon: #d97706;
  --ps-discipline-marathon-bg: rgba(217, 119, 6, 0.08);
  --ps-discipline-marathon-tint: #fcf7f0;
  /* Outside the brand palette — kept for existing tags only. */
  --ps-discipline-paracanoe: #e11d48;
  --ps-discipline-paracanoe-bg: rgba(225, 29, 72, 0.08);
  --ps-discipline-wildwater: #1d4ed8;
  --ps-discipline-wildwater-bg: rgba(29, 78, 216, 0.08);
  --ps-discipline-dragon: #dc2626;
  --ps-discipline-dragon-bg: rgba(220, 38, 38, 0.08);
}

/* ============================================================
   Live signal — the single broadcast-red pulse.
   `.ps-live-dot` is the on-air dot reused by the widget, the page header band
   and any "LIVE" badge. The pulse is one keyframe; under reduced-motion it
   collapses to a static ring (no animation), so motion-sensitive users still
   get the red signal without movement.
   ============================================================ */
/* Compositor-only pulse: a pseudo-element ring grows via transform: scale() +
   opacity (both GPU-composited, no paint) instead of an animated box-shadow
   spread (paint-bound, repainting the always-visible dot ~60×/s for the whole
   race day). Visual result is identical — an expanding fading ring. */
@keyframes ps-live-pulse {
  0% {
    transform: scale(1);
    opacity: 0.55;
  }
  70% {
    transform: scale(2.5);
    opacity: 0;
  }
  100% {
    transform: scale(2.5);
    opacity: 0;
  }
}

.ps-live-dot {
  position: relative;
  display: inline-block;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--ps-live-accent);
  flex-shrink: 0;
}

/* The expanding ring lives on a pseudo-element so the solid dot itself never
   repaints — only the compositor transforms/fades this overlay. */
.ps-live-dot::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: rgba(var(--ps-live-accent-rgb), 0.55);
  animation: ps-live-pulse 1.6s ease-out infinite;
  /* Promote to its own compositor layer; never intercept pointer events. */
  will-change: transform, opacity;
  pointer-events: none;
}

@media (prefers-reduced-motion: reduce) {
  .ps-live-dot::after {
    animation: none;
    /* Static ring stand-in for the pulse so the live signal still reads, without
       any motion (matches the previous reduced-motion box-shadow fallback). */
    background: transparent;
    box-shadow: 0 0 0 3px rgba(var(--ps-live-accent-rgb), 0.25);
  }
}

/* ============================================================
   --ps-rail-width: kept at 0 across all viewports. iPad mini and iPad
   Air are treated as mobile (no persistent rail), and the desktop
   AppHeader owns primary nav at ≥1024. Token preserved so any future
   consumer can opt back in.
   ============================================================ */
