/* AscendChess Professional Design System */
:root {
    /* Brand Colors */
    --ascend-primary: #042047;      /* Deep Navy - Main brand color */
    --ascend-primary-dark: #031735; /* Darker navy for depth */
    --ascend-primary-light: #062858; /* Lighter navy for gradients */
    --ascend-secondary: #B88536;    /* Gold - Accents, achievements */
    --ascend-secondary-light: #c99448; /* Lighter gold */
    --ascend-accent: #31DDE3;       /* Cyan - Interactive elements */
    --ascend-accent-light: #42e8ed; /* Lighter cyan */

    /* Semantic Colors */
    --ascend-success: #10b981;
    --ascend-error: #ef4444;
    --ascend-warning: #f59e0b;
    /* #7543 - pressed/hover shades for the flat status buttons (Button.razor.css).
       They used to be the far stop of each button's gradient. */
    --ascend-success-dark: #059669;
    --ascend-error-dark: #dc2626;
    --ascend-warning-dark: #d97706;
    --ascend-info: #3b82f6;
    /* #7905 - the LIGHT step for the two semantic colors that are used as TEXT
       on a tint of themselves. The semantic tokens above are all the 500 level
       of their hue; against the flat page --ascend-error measures 4.74:1 and
       --ascend-info 4.85:1, so both clear AA with almost no margin, and the
       status-chip idiom this app uses everywhere
       (color: <token> over background: color-mix(in srgb, <token> 12-18%, transparent))
       spends exactly that margin - the tint LIGHTENS the surface the text sits
       on, and the pair lands at 3.98-4.41:1. That was invisible until #7905
       taught CssColorParser to read color-mix, so the sweep had never measured
       a single one of those rules.
       These are the 400 level of the same hue family the 500s came from - the
       next published step up, not a value nudged until a number passed - and
       they follow the --ascend-accent-light / --ascend-secondary-light pattern
       already used for exactly this reason (see .cs-delivered in
       ContactSubmissions.razor.css, which reaches for --ascend-accent-light on
       an accent tint). Ratios are ratcheted by BrandTokenContrastTests - see
       docs/design/brand-tokens.md. */
    --ascend-error-light: #f87171;
    --ascend-info-light: #60a5fa;

    /* Neutral Colors - Modern Palette */
    --ascend-bg-dark: #0f172a;
    --ascend-bg-light: #f8fafc;
    /* #7177: was #ffffff - a token whose NAME said "card surface" in an app whose
       cards are dark. Nothing in the product actually wanted that white. Only two
       rules ever read it: `.card` below, which NO markup applies (the shared
       Components/Shared/Card.razor renders `.ascend-card`, not `.card`), and
       `.profile-card` in css/SharedPages.css, which is applied only ever alongside
       that same shared Card and is out-specified by its scoped `.ascend-card[b-*]`
       rule - so its white never painted either. The white was INVISIBLE, not wanted.
       That is precisely how the Book Drill dropdown shipped white: the default was a
       trap that simply had not been overridden yet, and the next person styling a
       card would have hit it too.
       The value is now the shared Card's own base colour (the start of its gradient,
       and the #0a1628 used across ~29 scoped stylesheets), so a developer reaching
       for the obvious token gets the surface the real cards already render.
       NOT MOVED WITH IT: --ascend-text-primary stays #0f172a, because its pair is
       --ascend-bg-light on the BODY, which is genuinely light. Pure white has no
       background consumer left; --ascend-bg-light is the light-surface token and
       --ascend-text-light is the white one. */
    --ascend-bg-card: #0a1628;
    --ascend-background: #0f172a;
    --ascend-text-primary: #0f172a;
    /* #6091: was #64748b (3.75:1 on --ascend-bg-dark - fails WCAG AA for normal
       text). #8595ab is 5.85:1 on #0f172a and 4.75:1 on the lightest surface it
       actually renders on (the verifier panel), so it clears AA everywhere it is
       used while staying a visible step darker than --ascend-text-muted.
       Ratios are ratcheted by BrandTokenContrastTests - see docs/design/brand-tokens.md. */
    --ascend-text-secondary: #8595ab;
    --ascend-text-muted: #94a3b8;
    --ascend-text-light: #ffffff;
    --ascend-border: #e2e8f0;
    --ascend-border-light: #f1f5f9;

    /* Chess piece colors (#7727) - the two fills a "played as White / as Black"
       indicator needs, so no call site carries a raw #000/#fff again. Black is a
       NEUTRAL near-black, not --ascend-primary: the brand navy is what made the
       old dot read as "blue" on the navy card in the first place. Neither fill
       is legible on its own against a surface of its own shade, so the shared
       PlayerColorDot pairs each one with the other as a ring. */
    --ascend-piece-white: #ffffff;
    --ascend-piece-black: #0b0b0d;

    /* Typography - Adobe Fonts (Condor & Tablet Gothic Oblique).
       BRAND NOTE (#6403): Adobe publishes Tablet Gothic's obliques as ITALIC STYLES of
       the 'tablet-gothic' family slug - a family named 'tablet-gothic' never
       existed in any kit, so that name silently fell back to system fonts since launch.
       The brand's oblique body look = family 'tablet-gothic' + the body font-style rule
       below. Never reintroduce 'tablet-gothic' as a family name. */
    --ascend-font-primary: 'condor', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
    --ascend-font-secondary: 'tablet-gothic', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
    /* Monospace for engine lines / move lists. Previously referenced (as
       var(--font-mono, monospace) in CalculationTrainerDrill) but never
       defined - #6356 defines the missing token. */
    --font-mono: 'SF Mono', SFMono-Regular, ui-monospace, 'Cascadia Mono', Consolas, 'Liberation Mono', Menlo, monospace;

    /* ================================================================
       Responsive breakpoints (2026-04-15 — see docs/RESPONSIVE_STRATEGY.md)
       Named by form factor, not pixel count. Mobile-first — base styles
       target narrow screens; @media (min-width: var(--bp-X)) adds
       capabilities as width grows.
       ---
       xs  360  — tiny phones, foldable cover
       sm  480  — regular phones portrait, foldable folded
       md  768  — foldable unfolded (near-square), small tablets
       lg  1024 — tablet landscape, small laptops
       xl  1440 — laptops, small desktops
       2xl 1920 — full HD and up; ultrawide inherits
       ---
       NOTE: CSS custom properties can't be used inside @media queries
       directly. Keep these in sync with the hardcoded numerics below.
       ================================================================ */
    --bp-xs: 360px;
    --bp-sm: 480px;
    --bp-md: 768px;
    --bp-lg: 1024px;
    --bp-xl: 1440px;
    --bp-2xl: 1920px;

    /* Fluid typography helpers — use clamp() for continuous scaling
       instead of stepped font-size at breakpoints. Scale anchors:
       min = phone portrait readable, max = desktop readable. */
    --fluid-xs:   clamp(0.75rem,  0.70rem + 0.2vw,  0.85rem);
    --fluid-sm:   clamp(0.85rem,  0.80rem + 0.25vw, 0.95rem);
    --fluid-base: clamp(0.95rem,  0.90rem + 0.3vw,  1.05rem);
    --fluid-lg:   clamp(1.05rem,  0.95rem + 0.5vw,  1.25rem);
    --fluid-xl:   clamp(1.25rem,  1.10rem + 0.75vw, 1.6rem);
    --fluid-2xl:  clamp(1.5rem,   1.20rem + 1.5vw,  2.25rem);
    --fluid-3xl:  clamp(2rem,     1.40rem + 3vw,    3.5rem);
    --fluid-hero: clamp(2.2rem,   1.40rem + 4vw,    4rem);

    /* Font Sizes - Fluid Typography */
    --text-xs: 0.75rem;     /* 12px */
    --text-sm: 0.875rem;    /* 14px */
    --text-base: 1rem;      /* 16px */
    --text-lg: 1.125rem;    /* 18px */
    --text-xl: 1.25rem;     /* 20px */
    --text-2xl: 1.5rem;     /* 24px */
    --text-3xl: 1.875rem;   /* 30px */
    --text-4xl: 2.25rem;    /* 36px */
    --text-5xl: 3rem;       /* 48px */

    /* Shadows - Layered Depth System */
    --shadow-xs: 0 1px 2px 0 rgb(4 32 71 / 0.05);
    --shadow-sm: 0 2px 4px 0 rgb(4 32 71 / 0.08);
    --shadow: 0 4px 6px -1px rgb(4 32 71 / 0.1), 0 2px 4px -2px rgb(4 32 71 / 0.08);
    --shadow-md: 0 6px 12px -2px rgb(4 32 71 / 0.12), 0 3px 7px -3px rgb(4 32 71 / 0.1);
    --shadow-lg: 0 10px 20px -5px rgb(4 32 71 / 0.15), 0 4px 8px -4px rgb(4 32 71 / 0.12);
    --shadow-xl: 0 20px 35px -8px rgb(4 32 71 / 0.2), 0 8px 15px -6px rgb(4 32 71 / 0.15);
    --shadow-2xl: 0 30px 50px -12px rgb(4 32 71 / 0.25);

    /* Spacing Scale - Consistent 8px Grid */
    --space-xs: 0.25rem;    /* 4px */
    --space-sm: 0.5rem;     /* 8px */
    --space-md: 1rem;       /* 16px */
    --space-lg: 1.5rem;     /* 24px */
    --space-xl: 2rem;       /* 32px */
    --space-2xl: 3rem;      /* 48px */
    --space-3xl: 4rem;      /* 64px */

    /* Minimum interactive target on a touch-primary device (#6716).
       2.75rem = 44px at the default root size - the iOS HIG minimum and the
       WCAG 2.5.5 (AAA) target size. Apply it inside a
       (hover: none) and (pointer: coarse) query so mouse sizing is untouched;
       a viewport-width query is NOT a substitute - a 1024px tablet is a touch
       device that no max-width: 768px rule ever reaches. */
    --touch-target: 2.75rem;

    /* One height for every control in the page header's action row (search,
       notifications, session timer, sound) on a mouse device. Each control set its
       own padding and line-height, so they rendered 30, 31, 32 and 36px side by side.
       The touch rules still raise each one to --touch-target. */
    --topbar-control-height: 2.25rem;

    /* #8328 - THE SIDE GUTTER EVERY PAGE GETS AT PHONE WIDTH.

       The shells used to drop to var(--space-sm) below 480px, so every page
       had an 8px side edge on a phone: text, card borders and buttons sat
       within a thumb's width of the screen edge, and a card border 8px from
       the bezel reads as a rendering fault rather than a frame. #8073
       (CB-FIX-PHONE) asked for 16px on the Coach Bridge pages and found the
       8px came from the SHARED page padding, so the owner moved it here as a
       sitewide fix rather than a per-page override (2026-10-02).

       It is a token rather than a literal because FOUR shells have to agree -
       MainLayout's .content-wrapper (portrait and both short-landscape
       bands), OnboardingLayout and WalledShell - and the thing that broke
       before was each of them choosing its own number. 1rem matches the
       padding the 768px band already uses, so the gutter no longer SHRINKS as
       the viewport does, which is what made the 480px step look like a bug.

       Horizontal only: the vertical padding stays var(--space-sm) on a phone
       because vertical space is the scarce axis there (the two-row mobile
       header already costs 108px), and the complaint was about the side edge.

       The board is NOT affected: ChessBoard.razor.css caps itself at
       min(100%, calc(100vw - 4.5rem)), which is measured from the viewport
       and already reserves 36px a side, so it keeps its own full-bleed
       budget whatever this gutter does. */
    --page-gutter-phone: 1rem;   /* 16px */

    /* Border Radius - Modern Curves */
    --radius-sm: 0.375rem;  /* 6px */
    --radius: 0.5rem;       /* 8px */
    --radius-md: 0.75rem;   /* 12px */
    --radius-lg: 1rem;      /* 16px */
    --radius-xl: 1.25rem;   /* 20px */
    --radius-2xl: 1.5rem;   /* 24px */
    --radius-full: 9999px;  /* Fully rounded */

    /* Transitions - Professional Easing */
    --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
    --transition-base: 200ms cubic-bezier(0.4, 0, 0.2, 1);
    --transition-smooth: 300ms cubic-bezier(0.4, 0, 0.2, 1);
    --transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1);

    /* Z-Index Scale */
    --z-dropdown: 1000;
    --z-sticky: 1020;
    --z-fixed: 1030;
    --z-modal-backdrop: 1040;
    --z-modal: 1050;
    --z-popover: 1060;
    --z-tooltip: 1070;

    /* Layout - Shared header height for sidebar + main header alignment */
    --header-height: 96px;
    --header-border: 1px solid rgba(49, 221, 227, 0.15);

    /* Rest-state opacity for a control that a pointer device reveals on hover
       (#7758, retuned by #7814). One token decides the whole family instead of
       nine pages each remembering.

       WHY IT IS NO LONGER 0 (#7814) - it was, and a control at opacity 0 is a
       control that does not exist until you already know where to point. The
       product owner used the Game Analysis move list, could not find the
       per-move comment button that had been shipping since #7189, reported it
       as missing, and worked around it by right-clicking. Touch had already
       been rescued (the query below); the keyboard had :focus-within; the
       MOUSE was the one input left with no affordance at all.

       WHY 0.55 AND NOT 1 - the density argument behind the original 0 was
       real: a 90-move list, or a 94-chapter study, carries one of these
       controls per row, and at full strength they compete with the content
       they sit beside. 0.55 is the middle path this bug asks for - present
       enough to be seen and counted without hovering, quiet enough to stay
       subordinate to the move, the eval and the title. Every consumer keeps
       its own `:hover` / `:focus-within` rule at opacity 1, so pointing at one
       still confirms it, and the reveal still means something.

       WHY IT IS AN OPACITY AND NOT A DISPLAY TOGGLE - the layout is identical
       at 0 and at 0.55, so raising it costs no reflow and no row height. What
       changes is ink, not space.

       WHY A TOKEN AND NOT A GLOBAL OVERRIDE RULE - every one of these controls
       is styled from a scoped .razor.css, so its selector carries a [b-xxxxx]
       attribute and outranks any plain class rule written here. A global
       `.card-delete-btn { opacity: 1 }` would simply lose the cascade, and the
       only way to win it from outside would be !important. A custom property
       sidesteps specificity entirely: the scoped rule keeps ownership of its
       own opacity declaration and just reads the value from here.

       WHY THE CONSUMERS STILL WRITE `var(--hover-reveal-rest, 0)` - the
       fallback is deliberately NOT restated as 0.55. Repeating the number in
       eleven scoped files is exactly the nine-copies problem the token was
       introduced to end, and the fallback is unreachable in practice: it
       applies only if app.css itself never loaded, in which case the page has
       no styling at all.

       WHY THE POINTER QUERY AND NOT A WIDTH - same reason as --touch-target.
       A narrow desktop window still has a mouse and reveals on hover; a 1024px
       tablet has no hover at any width and must not depend on one. */
    --hover-reveal-rest: 0.55;
}

/* #7758 - on a device with no hover there is nothing to reveal a control WITH,
   so a hover-only control is invisible and still tappable. That is at best a
   feature the user cannot find and at worst - on /opening-hub, where it was
   found - a DELETE button sitting in what looks like empty space on the card.
   Flipping one token shows every member of the family at rest. The
   `:hover` rules are left exactly as they were: on a pointer device the
   control still fades in, because there the reveal works.

   #7814 left this at 1 rather than following the pointer value down to 0.55.
   A finger has no hover AND no focus, so the rest state is the ONLY state it
   will ever see - there is no second, stronger reading to fall back on the way
   a mouse has. Quiet is a reasonable default only where pointing at the thing
   can still confirm it. */
@media (hover: none) and (pointer: coarse) {
    :root {
        --hover-reveal-rest: 1;
    }
}

/*
 * ============================================================
 * RESPONSIVE BREAKPOINTS — Standard tiers for all components
 * ============================================================
 * mobile:      max-width: 480px   — Phones (iPhone SE, etc.)
 * tablet:      max-width: 768px   — Tablets portrait, large phones landscape
 * desktop-sm:  max-width: 1024px  — Small laptops, tablets landscape
 * desktop:     max-width: 1280px  — Standard desktops
 * desktop-lg:  max-width: 1536px  — Large monitors
 *
 * Key transition: below 768px = mobile layout
 *   - Hamburger nav (slide-out drawer)
 *   - Single-column layouts
 *   - Stacked board + panels
 *
 * Use ONLY these 5 breakpoints. Do NOT introduce 500px, 600px,
 * 640px, 800px, 900px, 1100px, or 1400px breakpoints.
 * ============================================================
 */

/* Bug #8482 - hide Blazor's stock reconnect dialog ONLY while a circuit pause
   that /js/circuit-pause.js asked for is in force (a backgrounded tab). The
   script sets data-ascend-circuit-pause="active" on <html> immediately before
   Blazor.pauseCircuit and removes it when that pause is refused or fails, or
   once the resume settles - so a real connection loss, or a resume that fails,
   still shows the reconnect UI. Blazor's default display is a
   div#components-reconnect-modal on body carrying an INLINE display: block,
   which is why this needs !important. Never hide the modal without the marker;
   BlazorCircuitPauseContractTests fails any rule that does. */
html[data-ascend-circuit-pause="active"] #components-reconnect-modal {
    display: none !important;
}

/* Global Reset & Base Styles */
* {
    box-sizing: border-box;
}

button, select, [role="button"], a[href], summary {
    cursor: pointer;
}

button:disabled, select:disabled {
    cursor: default;
}

html {
    font-size: 16px;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    scroll-behavior: smooth;
    /* Bug #9611 - the browser defaults, set on purpose. App.razor's critical inline style
       paints html dark until this sheet applies (so a page with no stylesheet is not white);
       these hand html back to transparent, so the body's background fills the canvas (what
       an overscroll shows) exactly as it did before that style existed. Remove them and
       every page overscrolls into a dark band. */
    background-color: transparent;
    color: initial;
}

body {
    margin: 0;
    font-family: var(--ascend-font-secondary);
    /* Brand type, judicious form (Joe, 2026-07-09 dev smoke): UI chrome - nav, buttons,
       labels, tables, stat tiles, wordmarks - is STANDARD (upright) Tablet Gothic; the
       brand OBLIQUE face (served by Adobe as the italic styles of 'tablet-gothic') is
       reserved for paragraph-level copy via the p rule below. Sitewide oblique read
       poorly in practice, so BRANDING.md's "always oblique for body" is superseded.
       font-synthesis-style: none keeps slants honest - families WITHOUT a real italic
       face (condor, system fallbacks) render upright instead of browser-faked slanting. */
    font-style: normal;
    font-synthesis-style: none;
    color: var(--ascend-text-primary);
    background-color: var(--ascend-bg-light);
    line-height: 1.6;
}

/* Paragraph-level copy carries the brand oblique; everything else stays upright. */
p {
    font-style: italic;
}

/* Condor headings are upright by brand - explicit so the intent survives inside any
   oblique-styled container even where font-synthesis-style is unsupported. */
h1, h2, h3, h4, h5, h6 {
    font-style: normal;
}

/* Global horizontal-overflow guard. Mobile-first safety net: any stray
   fixed-px element, decorative background, or wide row that escapes its
   container must never make the whole page scroll sideways on a phone.
   Vertical scrolling is unaffected. (Pre-launch mobile sweep 2026-06.) */
html, body {
    max-width: 100%;
    overflow-x: hidden;
}

/* Global scrollbar — always visible, not auto-hiding */
*::-webkit-scrollbar {
    width: 8px;
    height: 8px;
}

*::-webkit-scrollbar-track {
    background: rgba(255, 255, 255, 0.06);
    border-radius: 4px;
}

*::-webkit-scrollbar-thumb {
    background: rgba(49, 221, 227, 0.6);
    border-radius: 4px;
    min-height: 40px;
}

*::-webkit-scrollbar-thumb:hover {
    background: rgba(49, 221, 227, 0.8);
}

/* Firefox scrollbar */
* {
    scrollbar-width: thin;
    scrollbar-color: rgba(49, 221, 227, 0.6) rgba(255, 255, 255, 0.06);
}

/* Global nav sizing — in app.css so it always applies (scoped CSS can cache stale) */
.sidebar-nav .nav-link { font-size: 0.78rem; padding: 0.4rem 0 0.4rem 0.75rem; }
.sidebar-nav .nav-link .nav-icon { font-size: 0.85rem; width: 18px; }
.sidebar-nav .nav-link .nav-text { font-size: inherit; }
.sidebar-nav .nav-pillar-label { padding: 0.5rem 0 0.15rem 0; }

/* #7736 — THE NAV ROW'S TOUCH FLOOR HAS TO LIVE HERE, NOT IN
   MainLayout.razor.css. Read this before "tidying" it into the scoped file.

   The sidebar rows are <NavLink> — a CHILD COMPONENT — so the <a> it renders
   carries NO b-* scope attribute, and every `.nav-link` rule in
   MainLayout.razor.css compiles to `.nav-link[b-dx84giicde]` and matches
   NOTHING. (The <i class="nav-icon"> and <span class="nav-text"> inside it
   ARE MainLayout's own markup and DO get the attribute, which is why that file
   looks like it is styling these links.) Measured in a browser: the rows are
   `display: block`, 35px tall — not the flex rows that file describes.

   ~26 of these appear on every member surface and they were the single
   largest contributor to the small-target count: 30 of the ~35 still standing
   on /dashboard after the rest of the sweep landed.

   The padding does the work and the min-height is the guarantee. The row is a
   block box, so growing it symmetrically keeps the label optically centered,
   where min-height alone would pin the text to the top and leave ~15px of dead
   space beneath it. Deliberately NOT switched to flex here: that would newly
   activate the currently-inert `.nav-text { flex: 1 }` / `.nav-icon
   { flex-shrink: 0 }` rules and change how the nav looks, which is a separate
   defect (recorded in the #7736 handoff), not this ticket's business. */
@media (hover: none) and (pointer: coarse) {
    .sidebar-nav .nav-link {
        padding-top: 0.68rem;
        padding-bottom: 0.68rem;
        min-height: var(--touch-target);
    }

    .sidebar-nav .nav-group-summary {
        min-height: var(--touch-target);
    }
}
.sidebar-nav .nav-pillar-label .pillar-phrase { font-size: 0.95rem; font-weight: 700; }
.sidebar-nav .nav-pillar-label .pillar-technical { font-size: 0.55rem; }
.sidebar-nav .nav-section-label { padding-left: 0; padding-right: 0; }

/* Global accent text classes — used in headings across all pages.
   Brand-guide rule (re-affirmed 2026-04-29): no whitespace AND no margin
   between accent-white and accent-gold spans. Headers render as a single
   compound color-split wordmark — same model as ASCENDCHESS itself,
   where "ASCEND" + "CHESS" reads as one tight word in two colors, not
   two words with a gap. PageContainer omits the HTML space; this CSS
   intentionally adds no margin either. */
.accent-white { color: #ffffff; }
.accent-gold { color: #B88536; }

/* .title-* are the same brand-color split as .accent-* — used inside card
   titles and inline section headings (e.g. <h3 class="section-title">
   <span class="title-white">PRIVACY</span><span class="title-gold">SETTINGS
   </span></h3>). They were originally scoped to Card.razor.css and
   Lookup.razor.css, which meant the *same class names* used in any other
   component's markup rendered with no color rule and the labels went
   invisible against the dark background (Profile page provider names +
   rating-card titles, 2026-05-02). Promoted to globals so the brand
   wordmark pattern applies wherever the spans are written. */
.title-white { color: #ffffff; }
.title-gold { color: #B88536; }

/* Brand wordmark (docs/design/ASCENDCHESS_BRAND_PALETTE.md "Wordmark rule"): the
   two color halves render as ONE word with NO SPACE at the color boundary -
   white half, gold half, the color change alone marks the split (ASCENDCHESS,
   SIMPLEPRICING, YOURFOUNDATION). This has been a RECURRING regression: do NOT add
   margin / gap / letter-spacing between adjacent title-/accent- spans, do NOT put
   an HTML space between them in markup, and never "fix" a jammed wordmark by adding
   a gap - jammed IS correct. This rule is a guard pinning the boundary to zero. */
:is(.title-white, .title-gold) + :is(.title-white, .title-gold),
:is(.accent-white, .accent-gold) + :is(.accent-white, .accent-gold) {
    margin-left: 0;
}

/* #8324 - THE WORDMARK MUST BE ABLE TO BREAK AT ITS OWN SEAM.

   The owner's report was "there are a ton of areas in the profile that
   overflow containers on my phone", and measured at 344 on a coarse pointer
   that is almost entirely THIS, in nine places on one page:

     ACCOUNTINFORMATION   220px of text in a 180px box   40px past
     PERSONALINFORMATION  233                             53
     CHESSINFORMATION     239                             59
     PRIVACYSETTINGS      214                             34
     TRAININGPREFERENCES  277                             97
     EMAILPREFERENCES     237                             57
     LICHESSACCOUNT / CHESS.COMACCOUNT   the same, inside the oauth rows

   and the card they sit in is `overflow-x: hidden`, so two Profile cards were
   clipping their own headings (49px and 13px of card spill at 344). The rule
   above is why: the two halves render with NO space and no margin at the
   boundary, which is correct brand - but it also means the browser sees ONE
   unbreakable nineteen-character word, and an unbreakable word longer than its
   box is the one thing wrapping cannot rescue.

   The fix has to create a break OPPORTUNITY without creating a GAP, because
   the gap is the recurring regression the rule above exists to prevent. Two
   inline boxes with nothing between them offer no soft wrap opportunity; two
   adjacent ATOMIC inlines do. So each half becomes an inline-block and the
   line may break between them - at the color seam, which is exactly where a
   reader would break it: "TRAINING / PREFERENCES".

   Measured on /profile at 344 and 390 with real touch emulation, against the
   two alternatives:

     baseline                     worst heading 97px past, card spill 49 and 13
     overflow-wrap: anywhere      heading 0, but ONE card still spilled 13, and
                                  it breaks mid-word ("TRAININGPREFEREN/CES")
     inline-block halves          heading 0, card spill 0, breaks at the seam

   Nothing moves when the pair fits on one line, which is every wordmark at
   1440: an inline-block's width is its content width, the negative seam margin
   on .brand-white applies to it unchanged, and it still sits on the text
   baseline. .accent-* is deliberately NOT included - those are PageContainer's
   h1 halves, they were not among the measured offenders, and that h1 carries
   the #7862 axe-containment fix which is decided by its line box. */
.title-white,
.title-gold,
.brand-white,
.brand-gold {
    display: inline-block;
}

/* Drill-completion heading shared by every drill that uses CoachingReflection.
 * Pattern Recognize / Pattern Solve / Flash Card session-end / Repertoire Drill
 * all sit under this so the "you finished a drill" moment reads identically
 * across surfaces (PBI 5567 acceptance: "Two-word accent heading style applied
 * uniformly"). RepertoireDrill keeps its own .drill-header wrapper for the
 * back-link + book-name layout but renders the title via .drill-complete-title.
 */
.drill-complete-header {
    margin-bottom: 1.25rem;
    text-align: center;
}

.drill-complete-title {
    font-family: var(--ascend-font-primary);
    font-size: 1.75rem;
    font-weight: 700;
    letter-spacing: 0.02em;
    margin: 0;
    line-height: 1.1;
}

/* Drill-complete title used to add margin-right between the two halves;
   that violated the brand-guide compound-wordmark rule (no gap between
   accent-white and accent-gold). Removed 2026-04-29 so drill-complete
   headers render with the same tight color seam every other page heading
   uses. Spans still inherit color from .accent-white / .accent-gold above. */

@media (max-width: 480px) {
    .drill-complete-title { font-size: 1.45rem; }
}

/* Drill canvas — premium card frame (gradient + cyan border + radial glow +
   heavy shadow) every interactive drill area wears so the look is consistent
   across puzzle drills (via PuzzleDrillSurface) and non-puzzle drills
   (BoardVision / KnightVision / MiniPhase / MoveHabits / TimeManagement / etc).
   Apply to the outer container of the drill's working surface — typically
   .drill-active or .drill-board-area or a sibling wrapper. The puzzle wrapper
   has its own equivalent class on .puzzle-surface-board-card so that callers
   don't double-wrap. */
.drill-canvas {
    background: linear-gradient(135deg, rgba(10, 22, 40, 0.98) 0%, rgba(15, 23, 42, 0.95) 100%);
    border: 2px solid rgba(49, 221, 227, 0.3);
    border-radius: 24px;
    padding: 2rem;
    box-shadow: 0 20px 60px rgba(4, 32, 71, 0.4), 0 0 40px rgba(49, 221, 227, 0.1);
    position: relative;
    overflow: hidden;
}

.drill-canvas::before {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: 24px;
    background: radial-gradient(circle at top right, rgba(49, 221, 227, 0.1), transparent);
    pointer-events: none;
}

/* #8993 - the canvas carries a drill's verdict at the board. A verdict host
   (DrillShell's .drill-shell-board) sets data-verdict and --drill-verdict-color on
   the frame around the canvas for the hold; the canvas's border and glow take that
   color. The border, not anything over the squares (#8710). */
[data-verdict] > .drill-canvas {
    border-color: var(--drill-verdict-color);
    box-shadow: 0 20px 60px rgba(4, 32, 71, 0.4),
                0 0 2.5rem color-mix(in srgb, var(--drill-verdict-color) 45%, transparent);
}

@media (prefers-reduced-motion: no-preference) {
    [data-verdict] > .drill-canvas {
        transition: box-shadow 0.16s ease-out, border-color 0.16s ease-out;
    }
}

/* #9053 part (b) review round 4 - A FOCUSED CONTROL NEVER LANDS UNDER A DOCK THAT
   STAYS UP (WCAG 2.4.11). In DrillShell's band A the verdict row is fixed to the
   bottom of the screen, and a row that waits on Next, Try again or Take back
   (DrillVerdictDock StaysUntilDismissed) stays there while the member keeps working:
   Tab from Next to Pattern Recognition's 'This looks wrong' scrolled it just to the
   viewport's bottom edge, which is under the dock. scroll-padding on the root is what
   focus scrolling and scrollIntoView aim inside, so the bottom 15rem (the band A
   reserve DrillShell budgets for that dock, so at least its height) is not a target
   while such a dock is up. It has to be global: a scoped stylesheet cannot reach html.
   Same band boundaries and the same selector as PageContainer.razor.css's page-end
   room for that dock; a self-clearing dock never changes where focus lands. */
@media (max-width: 768px) and (min-height: 520px) {
    html:has(.drill-advance-row.stays-until-dismissed:not(.is-idle)) {
        scroll-padding-bottom: calc(15rem + env(safe-area-inset-bottom, 0px));
    }
}

/* Mobile: aggressively shrink the canvas padding so the BOARD inside
   gets back the horizontal space. 2rem each side eats ~64px, which on
   a 360px viewport is ~18% of width — and since the board is square,
   every horizontal pixel lost shrinks vertical too. Keep the border
   for visual definition but drop the inner padding to a hairline. */
@media (max-width: 768px) {
    .drill-canvas { padding: 0.5rem; border-radius: 14px; }
    .drill-canvas::before { border-radius: 14px; }
}

@media (max-width: 480px) {
    .drill-canvas { padding: 0.25rem; border-radius: 10px; border-width: 1px; }
    .drill-canvas::before { border-radius: 10px; }
}

/* Global sync button — used in Dashboard + CoachSessionBriefCard */
.sync-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 28px;
    height: 28px;
    padding: 0;
    margin-left: 0.5rem;
    background: transparent;
    border: 1px solid rgba(49, 221, 227, 0.25);
    border-radius: 50%;
    color: rgba(255, 255, 255, 0.5);
    cursor: pointer;
    transition: all 0.2s ease;
    vertical-align: middle;
}
.sync-btn:hover:not(:disabled) {
    background: rgba(49, 221, 227, 0.1);
    border-color: var(--ascend-accent);
    color: var(--ascend-accent);
    box-shadow: 0 0 12px rgba(49, 221, 227, 0.2);
}
.sync-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.sync-btn i {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    line-height: 1;
    font-size: 0.75rem;
}

/* Global card-header link (#6347 dedup) — the small "Full plan" / "See all"
   affordance that sits at the right edge of a card header. Used by Dashboard
   and by the CoachConsole card nested inside it, so it cannot live in either
   component's scoped CSS (scoped CSS can't cross a component boundary — that
   is exactly why the two copies existed and drifted). Follows the same
   convention as .sync-btn above: shared class vocabulary lives here.
   Note: no :hover text-decoration here on purpose — the global
   a:not(...):hover rule below sets text-decoration: none at a higher
   specificity, so any underline declared on this class would be inert. */
.card-header-link {
    font-family: var(--ascend-font-secondary);
    font-size: 0.75rem;
    color: rgba(49, 221, 227, 0.7);
    text-decoration: none;
    margin-left: auto;
}

.card-header-link:hover { color: var(--ascend-accent); }

/* Global hub page styles — shared across all pillar hub pages */
.hub-page { max-width: 1100px; margin: 0 auto; }
/* auto-fit minmax = cards grow to fill width, wrap naturally. Single column
   on narrow screens; 2 columns once there's room for 340px + gap + 340px. */
.hub-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr));
    gap: 0.75rem;
}

.hub-card {
    display: flex; align-items: center; gap: 1rem;
    padding: 1rem 1.25rem; background: rgba(255, 255, 255, 0.03);
    border: 1px solid rgba(49, 221, 227, 0.15);
    border-left: 3px solid rgba(49, 221, 227, 0.4);
    border-radius: 12px; text-decoration: none; color: inherit;
    transition: all 0.18s ease;
}
.hub-card:hover {
    background: rgba(49, 221, 227, 0.06);
    border-color: rgba(49, 221, 227, 0.3);
    border-left-color: #31DDE3;
    transform: translateX(3px);
}
.hub-card-icon {
    flex-shrink: 0; width: 44px; height: 44px;
    display: flex; align-items: center; justify-content: center;
    border-radius: 10px; background: rgba(49, 221, 227, 0.1);
}
.hub-card-icon i { font-size: 22px; color: #31DDE3; }
.hub-card-body { flex: 1; min-width: 0; }
.hub-card-title {
    font-family: var(--ascend-font-primary); font-size: 1rem;
    font-weight: 600; color: rgba(255, 255, 255, 0.95); margin: 0 0 0.2rem;
}
.hub-card-desc {
    font-family: var(--ascend-font-secondary); font-size: 0.8rem;
    color: rgba(255, 255, 255, 0.55); margin: 0; line-height: 1.4;
}
.hub-card-arrow {
    flex-shrink: 0; color: rgba(255, 255, 255, 0.25);
    font-size: 1rem; transition: all 0.18s;
}
.hub-card:hover .hub-card-arrow { color: #31DDE3; transform: translateX(3px); }
.hub-card-disabled { opacity: 0.55; cursor: default; border-left-color: rgba(184, 133, 54, 0.4); }
.hub-card-disabled:hover { background: rgba(255, 255, 255, 0.03); border-color: rgba(49, 221, 227, 0.15); border-left-color: rgba(184, 133, 54, 0.4); transform: none; }
/* Shared "Next position" button used on drill feedback overlays (MoveHabits,
   TimeManagement, ThemeDrill). Keeps pacing in the user's hands instead of
   auto-advancing on a timer. */
.drill-next-btn {
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    margin-left: auto;
    padding: 0.5rem 1rem;
    background: rgba(10, 22, 40, 0.85);
    border: 1px solid rgba(49, 221, 227, 0.55);
    border-radius: 50px;
    color: #31DDE3;
    font-family: var(--ascend-font-secondary);
    font-size: 0.85rem;
    font-weight: 600;
    cursor: pointer;
    transition: all 0.15s ease;
    flex-shrink: 0;
}
.drill-next-btn:hover {
    background: rgba(49, 221, 227, 0.15);
    border-color: #31DDE3;
    color: #ffffff;
    transform: translateX(2px);
}
.drill-next-btn:focus-visible {
    outline: 2px solid #31DDE3;
    outline-offset: 2px;
}
.drill-next-btn i { font-size: 1rem; }

.hub-card-badge {
    display: inline-block; margin-left: 0.4rem; padding: 0.1rem 0.5rem;
    background: rgba(184, 133, 54, 0.15); border: 1px solid rgba(184, 133, 54, 0.35);
    border-radius: 999px; font-family: var(--ascend-font-secondary); font-size: 0.6rem;
    font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; color: #B88536;
    vertical-align: middle;
}
.hub-coming-soon {
    text-align: center; padding: 2rem 1rem;
    color: rgba(255, 255, 255, 0.4);
    font-family: var(--ascend-font-secondary); font-size: 0.85rem;
}
.hub-coming-soon i { font-size: 1.5rem; display: block; margin-bottom: 0.5rem; color: rgba(255, 255, 255, 0.25); }
/* Per-surface coach launcher row (#6199) — centers the Ask-your-coach button
   under a hub / surface. Renders nothing when the launcher self-gates off. */
.hub-coach-ask,
.dashboard-coach-ask,
.insights-coach-ask,
.reflections-coach-ask {
    display: flex;
    justify-content: center;
    margin-top: 1.5rem;
}
/* Phone portrait + foldable folded (aligns with --bp-md cutoff) */
@media (max-width: 768px) {
    .hub-card { padding: 0.75rem 1rem; gap: 0.75rem; }
    .hub-card-title { font-size: 0.9rem; }
    .hub-card-desc { font-size: 0.75rem; }
}

/* Professional Text Selection */
::selection {
    background-color: rgba(49, 221, 227, 0.3);
    color: var(--ascend-primary);
}

::-moz-selection {
    background-color: rgba(49, 221, 227, 0.3);
    color: var(--ascend-primary);
}

/* Typography - Modern Hierarchy */
h1, h2, h3, h4, h5, h6 {
    font-family: var(--ascend-font-primary);
    color: var(--ascend-text-primary);
    font-weight: 600;
    line-height: 1.2;
    margin-top: 0;
    margin-bottom: var(--space-md);
}

h1 {
    font-size: 2.5rem;
    letter-spacing: -0.025em;
}

h2 {
    font-size: 2rem;
    letter-spacing: -0.02em;
}

h3 {
    font-size: 1.5rem;
}

h4 {
    font-size: 1.25rem;
}

h5 {
    font-size: 1.125rem;
}

h6 {
    font-size: 1rem;
}

p {
    margin-top: 0;
    margin-bottom: var(--space-md);
}

h1:focus, h2:focus, h3:focus, h4:focus, h5:focus, h6:focus {
    outline: none;
}

/* Bug #7890 - A BARE HEADING INSIDE THE SHARED CARD'S BODY MUST BE READABLE.

   Measured on /tactics-hub/checkmate/patterns/legal (the "Positions coming"
   empty state), axe-core 4.13.0, at 344, 390 and 1440, signed in:

     color-contrast VIOLATION on <h3>Positions coming</h3>
     1.01:1   foreground #0f172a   background #0a1628   24px

   Not an axe "incomplete": #7543 already made .ascend-card opaque and flat, so
   the background resolves and the failure is decidable. The heading rendered;
   it was simply the page background color, which is why it reads as an empty
   gap rather than as a heading.

   WHY IT HAPPENS, and why it is the Card's to fix. PageContainer's .page-shell
   sets `color: var(--ascend-text-light)`, so every text node inside a page
   INHERITS white - which is why the paragraph beside this heading was always
   visible. Headings do not inherit: the global h1-h6 rule above paints them
   --ascend-text-primary (#0f172a), a LIGHT-theme token whose pair is
   --ascend-bg-light. Card.razor.css declares `color: white` on .card-header
   but says nothing about .card-body, so a heading written straight into
   BodyContent lands on the card's dark --ascend-bg-card with the light-theme
   color still on it. The same cause has been patched locally at least four
   times (AccessEnded .be-headline, RichTextEditor .ql-editor headings,
   StudyPlan .chat-text headings, CoachPage/CoachWalkthroughPanel); this closes
   it where the dark surface is actually declared.

   SPECIFICITY IS THE SAFETY ARGUMENT, so do not "tidy" the :where(). Each
   selector below is 0-0-1 - type-only, because :where() contributes nothing -
   and it sits AFTER the global h1-h6 rule, which is also 0-0-1. So it wins the
   tie on source order against that rule and LOSES to anything carrying a class
   or an id, which is every heading in a card body that already states its own
   color. The only headings this can move are the ones with no color rule at
   all, and on this background every one of those is currently 1.01:1 - i.e.
   invisible. It cannot darken a heading that was readable.

   Scoped to the Card rather than to .page-shell on purpose: the Card is the
   component that OWNS this background and already declares the matching color
   for its header, so this closes an asymmetry inside one component. Doing it
   at .page-shell would move ~266 bare headings across every page that uses
   PageContainer, including ones on light surfaces, and that needs its own
   measured sweep. */
:where(.ascend-card .card-body) h1,
:where(.ascend-card .card-body) h2,
:where(.ascend-card .card-body) h3,
:where(.ascend-card .card-body) h4,
:where(.ascend-card .card-body) h5,
:where(.ascend-card .card-body) h6 {
    color: var(--ascend-text-light);
}

/* Form Validation */
.valid.modified:not([type=checkbox]) {
    outline: 1px solid var(--ascend-success);
    border-color: var(--ascend-success);
}

.invalid {
    outline: 1px solid var(--ascend-error);
    border-color: var(--ascend-error);
}

.validation-message {
    color: var(--ascend-error);
    font-size: 0.875rem;
}

/* Error Boundary */
.blazor-error-boundary {
    background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, var(--ascend-error);
    padding: 1rem 1rem 1rem 3.7rem;
    color: var(--ascend-text-light);
    border-left: 4px solid var(--ascend-secondary);
}

.blazor-error-boundary::after {
    content: "An error has occurred.";
}

/* Form Controls */
.darker-border-checkbox.form-check-input {
    border-color: #929292;
}

.form-control:focus {
    border-color: var(--ascend-accent);
    box-shadow: 0 0 0 0.2rem rgba(49, 221, 227, 0.25);
}

.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
    color: var(--bs-secondary-color);
    text-align: end;
}

.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
    text-align: start;
}

/* Modern Button System */
.btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-sm);
    padding: 0.625rem 1.25rem;
    font-family: var(--ascend-font-primary);
    font-size: 0.875rem;
    font-weight: 500;
    line-height: 1.5;
    text-decoration: none;
    border: 1px solid transparent;
    border-radius: var(--radius);
    transition: all var(--transition-base);
    cursor: pointer;
    white-space: nowrap;
}

.btn:focus-visible {
    outline: 2px solid var(--ascend-accent);
    outline-offset: 2px;
}

/* <FocusOnNavigate Selector="h1"> (Routes.razor) moves focus to the page heading
   on navigation for screen readers. The focus is programmatic, not a user tab, so
   the visible focus ring around the h1 just reads as a distracting box. h1 is not
   otherwise focusable (tabindex=-1 set only by FocusOnNavigate), so suppressing it
   here is safe and the standard Blazor-template fix. */
h1:focus {
    outline: none;
}

.btn-primary {
    background: linear-gradient(135deg, var(--ascend-primary) 0%, #062858 100%);
    color: var(--ascend-text-light);
    box-shadow: var(--shadow);
}

.btn-primary:hover {
    background: linear-gradient(135deg, #062858 0%, var(--ascend-primary) 100%);
    transform: translateY(-1px);
    box-shadow: var(--shadow-md);
}

.btn-primary:active {
    transform: translateY(0);
    box-shadow: var(--shadow-sm);
}

.btn-secondary {
    background: linear-gradient(135deg, var(--ascend-secondary) 0%, #c99448 100%);
    color: var(--ascend-text-light);
    box-shadow: var(--shadow);
}

.btn-secondary:hover {
    /* #8705 - restated: on an <a>, the global a:hover (0-1-1) would repaint it. */
    color: var(--ascend-text-light);
    background: linear-gradient(135deg, #c99448 0%, var(--ascend-secondary) 100%);
    transform: translateY(-1px);
    box-shadow: var(--shadow-md);
}

.btn-accent {
    background: linear-gradient(135deg, var(--ascend-accent) 0%, #42e8ed 100%);
    color: var(--ascend-primary);
    box-shadow: var(--shadow);
}

.btn-accent:hover {
    background: linear-gradient(135deg, #42e8ed 0%, var(--ascend-accent) 100%);
    transform: translateY(-1px);
    box-shadow: var(--shadow-md);
}

.btn-outline {
    background: transparent;
    border-color: var(--ascend-border);
    color: var(--ascend-text-primary);
}

.btn-outline:hover {
    background: var(--ascend-bg-light);
    border-color: var(--ascend-primary);
    color: var(--ascend-primary);
}

.btn-lg {
    padding: 0.875rem 1.75rem;
    font-size: 1rem;
}

.btn-sm {
    padding: 0.5rem 1rem;
    font-size: 0.8125rem;
}

/* Professional Links
 *
 * #8705 - the link color is the brand LINK token, --ascend-accent
 * (docs/design/brand-tokens.md: "links, focus rings"; 10.70:1 on
 * --ascend-bg-dark). It was --ascend-primary, the brand navy, which is the
 * color of this app's own dark surfaces, so every link that inherited it
 * vanished: axe-core read 1.00:1 for the coach reply links (#8794). A 63-route
 * axe crawl (40 signed in, 23 signed out) found that only links in rendered
 * text still inherited it. Every anchor that wants navy text on a cyan fill
 * (the shared Button's .btn-primary, .skip-to-main, .add-game-btn, .btn-home,
 * the avatar) states its own color, so this rule does not reach them at rest.
 * On HOVER the bare `a:hover` below is 0-1-1 and outranks a single-class
 * app.css rule (0-1-0), so a filled control styled here must restate its text
 * color on :hover (.skip-to-main and .btn-secondary do) unless its :hover
 * flips the fill so the hovered cyan stays readable (.btn-primary: the
 * :hover below turns the fill navy); scoped rules are 0-2-0 or higher and
 * already win. Pinned by LinkContrastContractTests.
 */
a {
    color: var(--ascend-accent);
    text-decoration: none;
    transition: all var(--transition-base);
    position: relative;
}

a:hover {
    color: var(--ascend-accent-light);
}

/* #8705 - WCAG 1.4.1: inside running text a link needs a cue that is not
 * color. Cyan against the white or muted body text around it is under 3:1, so
 * color alone does not mark it; the underline does. Only CLASSLESS anchors (a
 * link that is part of a sentence, including raw Markdig output): an anchor
 * with a class is a designed control (a card, chip, button or nav item) that
 * owns its decoration. :where() keeps this at 0-1-1, so any component rule
 * that styles its own links still wins. The gradient hover bar below replaces
 * the underline on hover (its rule sets text-decoration: none at 0-5-1).
 * A sentence that lives elsewhere (a <span> in a callout, text directly in a
 * <div>) is not reached here, and widening this to span/div would underline
 * designed controls, so those pages state the underline in their own rule.
 * InSentenceLinkCueRatchetTests finds every in-sentence link in the markup and
 * fails if it has no non-color cue, including a page rule that strips it.
 */
:where(p, li, dd, td, blockquote, figcaption) a:not([class]) {
    text-decoration: underline;
    text-underline-offset: 0.15em;
}

/* Gradient hover bar — applies globally to non-button anchors.
 *
 * The bar lives INSIDE the link box (bottom: 0) so that block-level styled
 * anchors (dashboard cards, tiles, recommendation rows) clip it to their
 * own rounded corners via the overflow: hidden on the parent rule. The
 * earlier version positioned the bar at bottom: -2px (just below the link)
 * which painted a square line under every rounded card on the dashboard.
 *
 * For inline text links, overflow: hidden is effectively a no-op (inline
 * elements don't establish a block-formatting context), so descenders
 * still render normally.
 */
a:not([class*="btn"]):not([class*="button"]):not(.nav-link):not(.logo-link) {
    overflow: hidden;
}

a:not([class*="btn"]):not([class*="button"]):not(.nav-link):not(.logo-link):hover {
    text-decoration: none;
}

a:not([class*="btn"]):not([class*="button"]):not(.nav-link):not(.logo-link)::after {
    content: '';
    position: absolute;
    width: 0;
    height: 2px;
    bottom: 0;
    left: 0;
    background: linear-gradient(90deg, var(--ascend-accent), var(--ascend-secondary));
    transition: width var(--transition-smooth);
}

a:not([class*="btn"]):not([class*="button"]):not(.nav-link):not(.logo-link):hover::after {
    width: 100%;
}

a:focus-visible {
    outline: 2px solid var(--ascend-accent);
    outline-offset: 2px;
    border-radius: var(--radius-sm);
}

/* Modern Card System */
.card {
    background: var(--ascend-bg-card);
    /* #7177: was `1px solid var(--ascend-border)` (#e2e8f0). That border was chosen
       for a white card; left as-is over the now-dark surface it would draw a bright
       ring. Matches the shared Card's cyan hairline instead. */
    border: 1px solid rgba(49, 221, 227, 0.3);
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-sm);
    transition: all var(--transition-base);
    overflow: hidden;
}

.card:hover {
    box-shadow: var(--shadow-md);
    transform: translateY(-2px);
}

.card-header {
    background: transparent;
    color: var(--ascend-text-light);
    font-family: var(--ascend-font-primary);
    font-weight: 600;
    padding: var(--space-lg);
    border-bottom: none;
}

.card-body {
    padding: var(--space-lg);
}

/* #7172: these two were the last light-theme fossils in this block. #7177
   repointed --ascend-bg-card and fixed the border on `.card` above, but it
   swept only the rules that READ that token - these two carry the same
   light-card assumption through --ascend-bg-light (#f8fafc) and
   --ascend-border / --ascend-border-light (#e2e8f0 / #f1f5f9), so they were
   left behind. Neither paints today: `.card-footer` is rendered only by the
   shared Components/Shared/Card.razor, whose scoped `.card-footer[b-*]` rule
   (0-2-0) already declares `background: transparent` and beats this one
   (0-1-0), and `.card-flat` is likewise out-specified by its scoped
   border-color. That invisibility is exactly the hazard the ticket names:
   14 files already hand-roll `card-header` / `card-body` OUTSIDE the shared
   Card, where these global rules DO paint, so the first hand-rolled
   `card-footer` would have drawn a near-white band across a dark card.
   Values now match the shared Card's own treatment. No rendered pixel
   changes; the trap is what is being removed. */
.card-footer {
    background: transparent;
    border-top: 1px solid rgba(49, 221, 227, 0.15);
    padding: var(--space-lg);
}

/* Card Variants */
.card-flat {
    box-shadow: none;
    border: 1px solid rgba(49, 221, 227, 0.15);
}

.card-elevated {
    box-shadow: var(--shadow-lg);
}

/* Badges */
.badge-gold {
    background-color: var(--ascend-secondary);
    color: var(--ascend-text-light);
}

.badge-cyan {
    background-color: var(--ascend-accent);
    color: var(--ascend-primary);
}

/* Progress Bars */
.progress-bar {
    background-color: var(--ascend-accent);
}

/* Alerts */
.alert-success {
    background-color: rgba(49, 221, 227, 0.1);
    border-color: var(--ascend-accent);
    color: var(--ascend-primary);
}

.alert-warning {
    background-color: rgba(184, 133, 54, 0.1);
    border-color: var(--ascend-secondary);
    color: var(--ascend-primary);
}

/* ========================================
   Dashboard Cards - Shared Component Styles
   Used by: Dashboard, Games, and other authenticated pages
   ======================================== */

.dashboard-page {
    width: 100%;
}

/* GRID BLOWOUT GUARD (#7296) - read before changing the track sizing below.

   A grid track written as a bare `1fr` is `minmax(auto, 1fr)`, and that `auto`
   minimum is the ITEM'S MIN-CONTENT, not zero. So a single card whose content
   refuses to get narrower makes the track - and the page - as wide as that
   content, no matter how narrow the viewport is.

   That is the whole of the customer's report. On /games at 412px the phone
   game list carries `.game-card-opening`, a `white-space: nowrap` line holding
   the full opening name ("Caro-Kann Defense, Advance Variation, Botvinnik-Carls
   Defense"). Its min-content measured 338px, which propagated up through
   .game-card -> .games-card-list -> .card-body -> .dashboard-card as 368 -> 416
   -> 420px and sized this grid's single track to 420px inside a 372px
   container. document width 440px against a 412px viewport.

   The card's own `overflow: hidden` normally zeroes that automatic minimum, but
   .full-width-card is deliberately `overflow: visible` so the analyze drop-up
   can escape the card - which hands the min-content minimum straight back.

   And because `html, body { overflow-x: hidden }` (see the top of this file),
   the 28px that fell off the right edge were not merely off-screen: they were
   unscrollable AND unzoomable, which is what the customer meant by "cut off on
   the right and I can't zoom out." Same failure mode as #6780; the accessible
   floor for that combination is WCAG 1.4.4, so the track must never be allowed
   to exceed its container.

   `minmax(0, 1fr)` (and `minmax(min(400px, 100%), 1fr)` for the multi-column
   case) makes the track's minimum ZERO instead of min-content, so the definite
   width flows back down the chain and .game-card-opening's own
   `text-overflow: ellipsis` finally has something to truncate against - which
   is what it was written to do all along. The `> *` rule is the same guard one
   level down: an item that is itself a flex/grid container defaults to
   `min-width: auto` and would re-introduce the blowout inside the card.
   A `display: contents` wrapper has no box, so the `> *` rule stops at the
   wrapper and never reaches the card inside it, which is the real grid item.
   Repeat the guard for what such a wrapper holds (the Dashboard's #8286
   .pt-phone-only / .pt-desktop-only slots do, in Dashboard.razor.css). */
.dashboard-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(400px, 100%), 1fr));
    gap: var(--space-xl);
    width: 100%;
}

.dashboard-grid > * {
    min-width: 0;
}

/* Dashboard Cards - Dark on Light Professional Style.
   #7543 - this global block also reaches every shared <Card Variant="Dashboard">
   (the variant adds this class), and it used to paint a full-card radial glow
   on ::before and a 3px gradient line on ::after. axe-core treats an absolutely
   positioned, background-bearing pseudo-element as an overlay and declines to
   measure any text whose own area is under 4x the pseudo's, so both rules
   made every small text node in every dashboard card 'undecidable' (Bug #7543,
   register #7312). The glow is gone - a fill cannot be drawn without a
   background. The line is drawn with border-image, which has no background and
   is ignored by that check. The surface is the flat card token: the old
   0.98 -> 0.95 gradient over near-identical navy rendered as exactly that. */
.dashboard-card {
    background: var(--ascend-bg-card);
    border: 2px solid rgba(49, 221, 227, 0.3);
    border-radius: var(--radius-2xl);
    box-shadow: 0 20px 60px rgba(4, 32, 71, 0.4), 0 0 40px rgba(49, 221, 227, 0.1);
    overflow: hidden;
    transition: all var(--transition-smooth);
    position: relative;
}

.dashboard-card::after {
    content: '';
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    height: 0;
    border-top: 3px solid transparent;
    border-image: linear-gradient(90deg, var(--ascend-accent) 0%, var(--ascend-secondary) 100%) 1;
}

.dashboard-card:hover {
    box-shadow: 0 25px 70px rgba(4, 32, 71, 0.5), 0 0 50px rgba(49, 221, 227, 0.15);
    transform: translateY(-2px);
    border-color: rgba(49, 221, 227, 0.5);
}

/* Dashboard Card Header */
.dashboard-card .card-header {
    padding: var(--space-xl);
    border-bottom: none;
    background: transparent;
    position: relative;
    z-index: 1;
}

.dashboard-card .card-title {
    font-size: 1.5rem;
    font-weight: 600;
    margin: 0;
    color: var(--ascend-text-light);
    font-family: var(--ascend-font-primary);
}

/* #9393 - a hand-rolled dashboard card whose title is an h2 (DubiousLineReviewCard, on the
   Insights tab under the page h1) must look exactly like the h3 one: the global h2 rule adds
   a negative letter-spacing the h3 rule does not. The shared Card has the same pin (#8721). */
.dashboard-card h2.card-title {
    letter-spacing: inherit;
}

.dashboard-card .card-subtitle {
    font-size: 0.9375rem;
    color: rgba(255, 255, 255, 0.7);
    margin: var(--space-xs) 0 0 0;
}

/* Dashboard Card Body */
.dashboard-card .card-body {
    padding: var(--space-xl);
    position: relative;
    z-index: 1;
}

/* Welcome Card - Full Width */
.welcome-card {
    grid-column: 1 / -1;
}

/* Stats Row */
/* minmax(min(150px, 100%), ...) + the min-width guard: same grid blowout
   reasoning as .dashboard-grid above (#7296). A 150px floor is under every
   phone width we support, but the floor is only half the hazard - the `1fr`
   maximum still carries an `auto` MINIMUM, so a nowrap stat label would widen
   the track past the viewport exactly the way the opening name did on /games. */
.stats-row {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(150px, 100%), 1fr));
    gap: var(--space-lg);
}

.stats-row > * {
    min-width: 0;
}

/* Canonical .stat-item — matches Components/Shared/StatItem.razor.css.
 * Pages using a plain <div class="stat-item"> (Games, Dashboard items not yet
 * migrated to the shared component) inherit this. The 1px tinted border is
 * what gives stat blocks their visual weight; without it, half the cards on
 * the dashboard had bordered stat blocks and the other half did not. */
.stat-item {
    display: flex;
    align-items: center;
    gap: 1rem;
    padding: 1rem;
    background: rgba(255, 255, 255, 0.03);
    border: 1px solid rgba(49, 221, 227, 0.15);
    border-radius: 16px;
    transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
}

.stat-item:hover {
    background: rgba(255, 255, 255, 0.05);
    border-color: rgba(49, 221, 227, 0.3);
    transform: translateY(-2px);
}

.stat-icon {
    width: 48px;
    height: 48px;
    border-radius: var(--radius-lg);
    background: linear-gradient(135deg, var(--ascend-accent), var(--ascend-secondary));
    display: flex;
    align-items: center;
    justify-content: center;
    /* #7232: was `white` - 1.67:1 on the cyan end of the gradient, below even
       the 3:1 large-glyph bar. Deep navy is what app.css's .btn-accent already
       puts on this same cyan, and it reads 9.66:1 on the cyan stop and 4.95:1
       on the gold stop. */
    color: var(--ascend-primary);
    font-size: 1.5rem;
    flex-shrink: 0;
    box-shadow: var(--shadow-sm);
}

.stat-content {
    flex: 1;
    min-width: 0;
}

.stat-label {
    font-size: 0.8125rem;
    color: rgba(255, 255, 255, 0.6);
    margin-bottom: var(--space-xs);
    text-transform: uppercase;
    letter-spacing: 0.05em;
    font-weight: 500;
}

.stat-value {
    font-size: 1.75rem;
    font-weight: 700;
    color: var(--ascend-text-light);
    font-family: var(--ascend-font-primary);
    line-height: 1;
}

/* ========================================
   Shared Utility Styles
   ======================================== */

/* Spinner */
.spinner {
    width: 48px;
    height: 48px;
    border: 4px solid rgba(49, 221, 227, 0.2);
    border-top-color: #31DDE3;
    border-radius: 50%;
    animation: spin 1s linear infinite;
}

@keyframes spin {
    to { transform: rotate(360deg); }
}

/* #7700 - Bootstrap Icons ships no spin utility (Font Awesome's `fa-spin`
   left with its SVG engine), and a Bootstrap glyph is an INLINE element, which
   CSS transforms do not apply to - so a rotation needs `display: inline-block`
   on the <i> itself or nothing turns. Scoped `.spin` / `.spin-icon` rules in
   individual components own WHEN their icon spins; this block only makes the
   element transformable, plus a site-wide `.spin` default so a spinner needs
   no per-component rule.

   The glyph IS the element, so it rotates in place. Under Font Awesome's
   autoReplaceSvg 'nest' mode the <i> stayed a wrapper with the <svg> nested
   inside it, and the same animation swung the glyph AROUND the wrapper box -
   the wait icon that ORBITED instead of spinning. */
.bi.spin,
.bi.spin-icon,
.bi.framing-ready-spinner {
    display: inline-block;
}

.bi.spin {
    animation: spin 1s linear infinite;
}

/* Loading State */
.loading-state {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    padding: var(--space-3xl) var(--space-xl);
    gap: var(--space-md);
    text-align: center;
    color: rgba(255, 255, 255, 0.6);
}

/* Empty State */
.empty-state {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    padding: var(--space-3xl) var(--space-xl);
    text-align: center;
    color: rgba(255, 255, 255, 0.6);
}

/* Hero icon only — direct child of .empty-state. Stops bleeding into
   icons rendered by nested components (Button, etc.) which manage
   their own size + color. */
.empty-state > i {
    font-size: 3rem;
    color: rgba(49, 221, 227, 0.4);
    margin-bottom: var(--space-lg);
}

/* #8087 - EmptyState HeadingLevel="2" renders its title as an h2, painted the same as the h3. */
.empty-state h2,
.empty-state h3 {
    color: rgba(255, 255, 255, 0.8);
    margin: 0 0 var(--space-sm) 0;
    font-family: var(--ascend-font-primary);
}

.empty-state p {
    margin: var(--space-xs) 0;
    font-weight: 500;
    color: rgba(255, 255, 255, 0.9);
}

.empty-state-subtitle {
    font-size: 0.875rem;
    color: rgba(255, 255, 255, 0.6);
    max-width: 300px;
}

/* Accent Line */
.accent-line {
    height: 4px;
    background: linear-gradient(90deg, #31DDE3, #B88536);
    border-radius: 2px;
}

/* Icon Circle */
.icon-circle {
    width: 80px;
    height: 80px;
    background: linear-gradient(135deg, rgba(49, 221, 227, 0.2), rgba(184, 133, 54, 0.2));
    border-radius: 50%;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    border: 2px solid rgba(49, 221, 227, 0.4);
    animation: scaleIn 0.5s ease;
}

.icon-circle i {
    font-size: 40px;
    color: #31DDE3;
}

.icon-circle.success {
    background: linear-gradient(135deg, rgba(49, 221, 227, 0.2), rgba(16, 185, 129, 0.2));
    border-color: rgba(49, 221, 227, 0.4);
}

.icon-circle.success i {
    color: #10b981;
}

.icon-circle.error {
    background: linear-gradient(135deg, rgba(239, 68, 68, 0.2), rgba(220, 38, 38, 0.2));
    border-color: rgba(239, 68, 68, 0.4);
}

.icon-circle.error i {
    color: #ef4444;
}

@keyframes scaleIn {
    0% {
        transform: scale(0);
        opacity: 0;
    }
    100% {
        transform: scale(1);
        opacity: 1;
    }
}

/* Responsive Design */
@media (max-width: 1024px) {
    /* minmax(0, 1fr), never a bare `1fr` - see the grid blowout guard on
       .dashboard-grid above (#7296). This is the rule that was actually in
       force on the reporter's phone, and the bare `1fr` here is what let a
       420px card size a 372px track. */
    .dashboard-grid {
        grid-template-columns: minmax(0, 1fr);
    }

    .stats-row {
        grid-template-columns: minmax(0, 1fr);
    }
}

@media (max-width: 768px) {
    .dashboard-card .card-header,
    .dashboard-card .card-body {
        padding: var(--space-lg);
    }

    .dashboard-card .card-title {
        font-size: 1.25rem;
    }

    .stat-value {
        font-size: 1.5rem;
    }
}

@media (max-width: 480px) {
    .dashboard-grid {
        gap: var(--space-lg);
    }

    .stats-row {
        gap: var(--space-md);
    }

    .stat-item {
        padding: var(--space-md);
    }
}

/* ============================================================
   Dark-theme native form controls
   ------------------------------------------------------------
   Browsers default <select> + <option> to OS / white styling.
   Force the brand dark theme so dropdowns don't flash white
   anywhere on the site. Works in Chrome/Firefox; Safari ignores
   <option> styling but falls back to a sensible OS dropdown.
   ============================================================ */
select {
    background: rgba(15, 23, 42, 0.95);
    color: #ffffff;
    border: 1px solid rgba(49, 221, 227, 0.25);
    border-radius: 12px;
    padding: 0.6rem 2.25rem 0.6rem 0.875rem;
    font-family: var(--ascend-font-secondary, inherit);
    font-size: 0.9rem;
    appearance: none;
    -webkit-appearance: none;
    -moz-appearance: none;
    cursor: pointer;
    background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2331DDE3' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>");
    background-repeat: no-repeat;
    background-position: right 0.75rem center;
    background-size: 14px 14px;
    transition: border-color 0.2s ease, box-shadow 0.2s ease;
}

select:focus {
    outline: none;
    border-color: var(--ascend-accent, #31DDE3);
    box-shadow: 0 0 0 3px rgba(49, 221, 227, 0.1);
}

select:hover:not(:disabled) {
    border-color: rgba(49, 221, 227, 0.4);
}

select:disabled {
    opacity: 0.55;
    cursor: not-allowed;
}

select option {
    background-color: #0a1628;
    color: #ffffff;
    padding: 0.4rem;
}

/* #7266: <optgroup> headers inside a dark <select>. This was living privately
   in StudyDetail.razor.css, which is the only place that groups its options
   today - it belongs with the rest of the native-control theme so the next
   grouped select does not have to rediscover it. */
select optgroup {
    background-color: #0a1628;
    color: color-mix(in srgb, var(--ascend-accent) 70%, transparent);
    font-style: normal;
    font-weight: 600;
    font-size: 0.75rem;
}

select option:hover,
select option:checked {
    background-color: rgba(49, 221, 227, 0.18);
    color: #ffffff;
}

/* Multiple-select listbox — when the dropdown becomes a static list
   (e.g. <select multiple>), keep the dark fill. */
select[multiple] {
    background-image: none;
    padding: 0.5rem;
    min-height: 8rem;
}

select[multiple] option {
    padding: 0.5rem 0.6rem;
    border-radius: 6px;
}

/* ============================================================
   .form-input / .form-textarea - the shared form control (#7258)
   ------------------------------------------------------------
   Owner's walk of the social composer, 2026-08-27: the TEXT and
   FIRST COMMENT textareas, the Campaign input and the TAGGED
   LINK input all rendered as solid WHITE boxes on the dark
   theme.

   The class was not missing - it was defined LIGHT. The only
   global .form-input lived in wwwroot/css/SharedPages.css and
   read `background: var(--ascend-bg-card)` (#ffffff) with
   `color: var(--ascend-text-primary)` (#0f172a): a light-theme
   input definition sitting inside a dark app. SharedPages.css
   also loads AFTER this file, so it won out over anything
   written here.

   Every page that NOTICED compensated by restating the dark
   treatment in its own .razor.css - roughly 28 scoped files
   carried a private copy, which is why Home, Profile and the
   Opening Hub looked right. The eight pages that never noticed
   (the /marketing/social family, Book Drill, Alert Triage, AI
   Models, the Lichess study import) got the white box. The
   same rot had already been patched twice at the symptom -
   see the select.form-input notes that used to sit in
   SharedPages.css - once per page it was spotted on.

   So the definition moves HERE, dark, in tokens, and the light
   one in SharedPages.css is deleted rather than overridden.
   Pages own SIZING and LAYOUT for their inputs; they must not
   restate color, background or border.

   NOTE on background-color vs the `background` shorthand: the
   shorthand would reset background-image and blow away the
   caret drawn by the `select` rule above (0-1-0 beats 0-0-1),
   so the fill is set as a longhand and the caret survives.
   ============================================================ */
.form-input,
.form-textarea {
    padding: 0.75rem 1rem;
    font-family: var(--ascend-font-secondary);
    font-size: 0.9rem;
    color: var(--ascend-text-light);
    /* #7977 - OPAQUE, deliberately. This was rgba(255, 255, 255, 0.05), and a
       5% white wash resolves to nothing axe-core can measure when the surface
       under it is painted with a gradient: dev CI build 10973 reported
       `.form-input` on "/" as an undecidable color-contrast node, a kind the
       frozen AxeUndecidableRegister does not list, so the gate fails on it. The
       register only shrinks, so the fix is to make the control measurable
       rather than to admit a line. color-mix resolves to a concrete opaque
       color, which is what the 5% wash already rendered as on the dark page, so
       nothing moves visually: a field on a card (--ascend-bg-card) lands within
       two units of a field on the page (--ascend-bg-dark). */
    background-color: color-mix(in srgb, #ffffff 5%, var(--ascend-bg-dark));
    border: 1px solid color-mix(in srgb, var(--ascend-accent) 20%, transparent);
    border-radius: var(--radius-md);
    transition: border-color var(--transition-base), box-shadow var(--transition-base), background-color var(--transition-base);
    /* Native date / time / color pickers render their own chrome from the
       color scheme, not from our CSS. Without this the calendar indicator on
       the Social Calendar date filters and the composer's datetime-local is
       drawn dark-on-dark and effectively invisible. */
    color-scheme: dark;
}

/* The focus ring is the border going full cyan plus the glow - the UA outline
   is suppressed here exactly as it was in the definition this replaces, so
   focus behaviour does not change. Keyboard users additionally keep the
   app-wide :focus-visible ring further down this file. */
.form-input:focus,
.form-textarea:focus {
    outline: none;
    border-color: var(--ascend-accent);
    /* #7977 - the focus step, opaque for the same reason. */
    background-color: color-mix(in srgb, #ffffff 8%, var(--ascend-bg-dark));
    box-shadow: 0 0 0 3px color-mix(in srgb, var(--ascend-accent) 25%, transparent);
}

/* opacity: 1 because Firefox dims placeholders by default, which would drop
   this below the contrast it was chosen for. */
.form-input::placeholder,
.form-textarea::placeholder {
    color: rgba(255, 255, 255, 0.5);
    opacity: 1;
}

.form-input:disabled,
.form-textarea:disabled {
    opacity: 0.55;
    cursor: not-allowed;
}

/* Readonly is not disabled - the pre-tagged native-posting links on the
   composer are readonly precisely so they can be selected and copied. Quieter
   fill, still selectable. (The rule this replaces reached for an
   --ascend-bg-secondary token that has never been defined, so the declaration
   was invalid at computed-value time and readonly inputs fell back to a
   transparent background.) */
.form-input:read-only:not(select),
.form-textarea:read-only {
    background-color: rgba(255, 255, 255, 0.03);
}

/* A <select> wearing the class keeps the caret from the `select` rule above,
   so it needs the right-hand padding back after .form-input's padding wins. */
select.form-input {
    cursor: pointer;
    padding-right: 2.5rem;
}

select.form-input:disabled {
    cursor: not-allowed;
}

/* Drag-to-scroll affordance (PBI #318). Containers tagged with
   [data-drag-scroll] get a grab cursor; once the user mouses-down the
   .drag-scroll-active class flips it to grabbing so the cursor mirrors
   the gesture. The actual scroll work happens in /js/drag-scroll.js. */
[data-drag-scroll] {
    cursor: grab;
    user-select: none;
}

[data-drag-scroll].drag-scroll-active {
    cursor: grabbing;
}

[data-drag-scroll] a,
[data-drag-scroll] button {
    cursor: pointer;
}


/* Hub footnote — small note below the hub card grid, with link styling
   that survives the dark background. Scoped to .hub-footnote so it
   doesn't leak to other anchor contexts. Promoted to global from
   TacticsMotifHub's scoped CSS so every hub (Tactics, Strategy,
   Psychology, Thinking, etc.) gets readable links without redefining.
   Joe-flagged 2026-04-29: Foundation link in TacticsHub was invisible. */
.hub-footnote {
    max-width: 56rem;
    margin: 2rem auto 0;
    padding: 1rem 1.25rem;
    background: rgba(255, 255, 255, 0.03);
    border: 1px solid rgba(255, 255, 255, 0.08);
    border-radius: 0.625rem;
    font-family: var(--ascend-font-secondary);
    font-size: 0.85rem;
    color: rgba(255, 255, 255, 0.65);
    line-height: 1.5;
    text-align: center;
}

.hub-footnote a {
    color: var(--ascend-accent);
    text-decoration: underline;
    text-underline-offset: 0.15em;
}

.hub-footnote a:hover {
    text-decoration: underline;
}

/* ─────────────────────────────────────────────────────────────────────
   Auth-shell chrome (PBI #5848 — dedup'd 2026-05-24)

   Shared dark-theme shell for sign-in / sign-up / email-confirm / OAuth-
   callback / coach-bridge invite-style pages. Previously each page's
   scoped .razor.css duplicated ~120 lines of chrome (page wrapper,
   gradient card, accent line, logo block, brand wordmark). This block
   collapses the truly-identical selectors into one source of truth via
   comma-separated selectors that match every existing page wrapper class.

   What stays per-page (legitimately varies):
   - .container max-width (550px default; Register 650px; some OAuth 500px)
   - .card padding-bottom (some pages add 2-3rem at the bottom)
   - .{page}-card h1 (margins/padding/text-align differ)
   - .subtitle (margin/padding differ)
   - .icon-circle base + color variants (per-page tint conventions)
   - .scaleIn keyframe (per-page; tiny, low-value to dedup)
   - All page-specific extras (form-row, alerts, scope chips, etc.)

   When adding a new auth-style page: use one of the existing class names
   (.callback-page / .confirm-page / .register-page / .success-page /
   .login-page) and inherit this chrome automatically. No need to copy.
   ───────────────────────────────────────────────────────────────────── */

.callback-page,
.confirm-page,
.register-page,
.success-page,
.login-page {
    min-height: 100vh;
    display: flex;
    align-items: center;
    justify-content: center;
    /* #7543 - the centre glow used to be an absolutely positioned ::before
       painted over the whole page. axe-core treats a content-bearing
       pseudo-element with a background as an overlay and declines to measure
       any text beneath it, which is why every node on /account/login and
       /account/register was 'undecidable' (Bug #7543). The glow is a layer of
       the page's own background now - same colour, same place, behind the card
       instead of over it. The card is opaque (below), so text inside it is
       measured against the card and never reaches this gradient. */
    background:
        radial-gradient(circle at 50% 50%, rgba(49, 221, 227, 0.1) 0%, transparent 50%),
        var(--ascend-bg-dark);
    padding: 2rem 1rem;
    position: relative;
    overflow: hidden;
}

.callback-container,
.confirm-container,
.register-container,
.success-container,
.login-container {
    width: 100%;
    max-width: 550px;
    position: relative;
    z-index: 1;
}

.callback-card,
.confirm-card,
.register-card,
.success-card,
.login-card {
    /* #7543 - opaque and flat, so axe can measure the text inside it. This was
       a 0.95 -> 0.9 navy gradient with a backdrop blur, over a navy page: the
       flat token is what that rendered as, minus the blur (which has nothing
       left to blur through an opaque surface). */
    background: var(--ascend-bg-dark);
    border-radius: 24px;
    border: 1px solid rgba(49, 221, 227, 0.3);
    box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(49, 221, 227, 0.1) inset;
    overflow: hidden;
}

.accent-line {
    height: 4px;
    background: linear-gradient(90deg, #31DDE3 0%, #B88536 100%);
}

.logo-section {
    text-align: center;
    padding: 3rem 2rem 2rem;
}

.logo {
    width: 80px;
    height: 80px;
    margin-bottom: 1rem;
    display: block;
    margin-left: auto;
    margin-right: auto;
}

.brand-name {
    font-family: 'condor', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
    font-size: 28px;
    font-weight: 700;
    letter-spacing: -0.03em;
}

.brand-white { color: #ffffff; }
.brand-gold  { color: #B88536; }

@media (max-width: 768px) {
    .logo {
        width: 60px;
        height: 60px;
    }
    .brand-name {
        font-size: 24px;
    }
}

/* ================================================================
   Accessibility utilities (PBI #5853 — WCAG 2.2 AA)
   ================================================================ */

/* Visually-hidden: removed from sight but still announced by screen
   readers. Used for skip-link copy, icon-only button labels, and
   spinner status text. */
.visually-hidden,
.sr-only {
    position: absolute !important;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}

/* Skip-to-main-content link: visually hidden until it receives keyboard
   focus, then it slides into view at the top-left so a keyboard user can
   jump past the navigation. Placed first in the layout body. */
.skip-to-main {
    position: absolute;
    left: 0.5rem;
    top: -3rem;
    z-index: 2000;
    padding: 0.6rem 1rem;
    background: var(--ascend-accent, #31DDE3);
    color: #042047;
    font-weight: 700;
    border-radius: 6px;
    text-decoration: none;
    transition: top 0.15s ease;
}

.skip-to-main:hover {
    /* #8705 - the global a:hover (0-1-1) outranks .skip-to-main (0-1-0) and painted
       the hovered skip link cyan on its own cyan fill. */
    color: #042047;
}

.skip-to-main:focus {
    top: 0.5rem;
    outline: 3px solid #ffffff;
    outline-offset: 2px;
}

/* Baseline visible focus indicator for keyboard users across interactive
   elements that don't define their own. :focus-visible only fires for
   keyboard/AT focus, so mouse clicks don't get the ring. */
a:focus-visible,
button:focus-visible,
[role="button"]:focus-visible,
[tabindex]:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
summary:focus-visible {
    outline: 3px solid var(--ascend-accent, #31DDE3);
    outline-offset: 2px;
}

/* <FocusOnNavigate Selector="h1"> (Routes.razor) sets tabindex=-1 on the page
   heading and programmatically focuses it on every navigation for screen-reader
   landing. That makes the generic [tabindex]:focus-visible ring above paint a
   distracting cyan box around the h1. The h1 is not user-tabbable (tabindex=-1),
   so suppressing its ring is the standard, a11y-safe fix. The h1[tabindex]
   selectors out-specify the [tabindex] rule above (0,2,1 > 0,2,0). */
h1[tabindex]:focus,
h1[tabindex]:focus-visible {
    outline: none;
}

/* ================================================================
   Reduced motion (PBI #5853 — WCAG 2.2.2 / 2.3.3)
   One global guard so EVERY animation + transition + smooth-scroll in
   the app is neutralized for users who ask for reduced motion. Scoped
   .razor.css can still add finer rules, but this covers everything by
   default (confetti, scaleIn, hover bounces, modal slide-ins, spinners).
   ================================================================ */
@media (prefers-reduced-motion: reduce) {
    *,
    *::before,
    *::after {
        animation-duration: 0.001ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.001ms !important;
        /* #7418 - zero the WAIT too: an entry fade with a delay and fill-mode: both
           stays invisible for the whole delay even when its duration is ~0. */
        animation-delay: 0s !important;
        transition-delay: 0s !important;
        scroll-behavior: auto !important;
    }
}

/* ================================================================
   Mobile-first shared primitives (#1114 - Sweep B2 remediation)
   See docs/research/ASCENDCHESS_PRELAUNCH_SWEEP_B2_MOBILE_FIRST.md.
   Base styles target the narrow phone; @media (min-width) enhances UP.
   Phase-2 page work adopts these in place of hand-rolled equivalents.
   ================================================================ */

/* ---- .action-row -------------------------------------------------
   Shared button/action row. Replaces hand-rolled .form-actions /
   .action-buttons / .action-section flex rows across pages.

   Contract:
     - BASE (phone): buttons STACK full-width in a column. This is the
       safe default for a 320-390px viewport where two nowrap buttons
       (the shared Button sets white-space:nowrap) would otherwise
       overflow.
     - min-width 480px (--bp-sm): becomes a horizontal row that WRAPS
       (flex-wrap:wrap), right-aligned. Wrapping is the accommodation
       for the Button component's white-space:nowrap - a nowrap button
       that no longer fits drops to the next line instead of forcing a
       horizontal scroll. Button behavior itself is untouched.

   Modifiers (optional, additive):
     .action-row--start   left-align the row (default is flex-end)
     .action-row--center  center the row
     .action-row--between space-between (e.g. back | next)

   Usage:
     <div class="action-row"> <Button/> <Button/> </div>
   ------------------------------------------------------------------ */
.action-row {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: var(--space-sm, 0.75rem);
    width: 100%;
}

/* Base (phone): direct children go full-width so neither button clips. */
.action-row > * {
    width: 100%;
}

@media (min-width: 480px) {
    .action-row {
        flex-direction: row;
        flex-wrap: wrap;
        align-items: center;
        justify-content: flex-end;
    }

    /* Restore intrinsic width once there is room to sit side-by-side;
       wrapping (above) handles the nowrap-button overflow case. */
    .action-row > * {
        width: auto;
    }
}

.action-row--start { justify-content: flex-start; }
.action-row--center { justify-content: center; }
.action-row--between { justify-content: space-between; }

/* ---- .action-stack (#8837) -----------------------------------------
   Shared COLUMN of actions: buttons stacked one above another share ONE
   width at every viewport. The owner's report: "where buttons are stacked
   vertically they are varying widths, making it look unpolished". A
   hand-rolled flex column with align-items: flex-start (or a wrapping row
   that wrapped) sizes every button to its own label, so "Download my
   data" sat at 211px over "Request full export by email" at 277px.

   Contract:
     - The stack fills its column (width: 100%) up to --action-stack-max
       (default 22rem), and every direct child is width: 100% of it.
       Width comes from the COLUMN, not from the labels, so two stacks in
       the same column (each button wrapped by its own block, with prose
       between them) still line up at the same width.
     - Desktop decision: the 22rem cap keeps a stack in a wide card from
       stretching buttons across 600px+. On a phone the column is narrower
       than the cap, so the stack is simply full width, like .action-row.
       Override the cap per surface with --action-stack-max.
     - Icon-only buttons do not belong in a stack: keep them square and lay
       them out beside the stack (or in the card corner, like a dismiss X).
       Named exception: a transport GRID of equal cells (ExampleLinePlayer's
       narrow 2 x 2 Start | Back / Forward | End) lets its icon cells fill
       their tracks, because every cell sharing one width is the point.
     - A layout that deliberately pairs a primary action with a quieter
       one (a text link under a button) is not a stack; leave the quiet
       one out of the .action-stack and say so where it is written.

   Modifiers (optional, additive):
     .action-stack--center   center the stack in its column

   Usage:
     <div class="action-stack"> <Button/> <Button/> </div>

   Pinned by ColumnButtonContainerRatchetTests: a scoped .razor.css rule
   that hand-rolls a column of buttons fails the build.
   ------------------------------------------------------------------ */
.action-stack {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: var(--space-sm, 0.75rem);
    width: 100%;
    max-width: var(--action-stack-max, 22rem);
}

.action-stack > * {
    width: 100%;
    min-width: 0;
}

.action-stack--center { margin-inline: auto; }

/* ---- .board-controls (#6780) -------------------------------------
   The control row that sits directly under a chessboard. Seven pages
   declare this class in their own scoped CSS - EndgameLessonDetail,
   GameAnalysis, StudyBoard, StudySession, SelfReview, MasterGameView,
   RepertoireEditor - and all seven wrote the same body:
   `display: flex; gap: 0.5rem;` with NO flex-wrap. No component owns
   the class, so there is no shared .razor.css to fix it in; this is the
   one place a single rule reaches all seven.

   Why it matters more here than on an ordinary button row: this row is a
   SIBLING OF THE BOARD inside the board's column. On a phone that column
   is the page's single grid/flex track, and a track sized `1fr` takes its
   minimum from its content's min-content. The shared Button sets
   white-space: nowrap, so two buttons that will not wrap have a
   min-content equal to their sum - measured at 366px on the endgame
   lesson, on a 320px viewport - and that number becomes the width of the
   whole column, the board, and the document. With html/body at
   overflow-x: hidden the overflow is not even scrollable: the customer
   could see content was missing and could not reach it (#6780).

   Wrapping is the accommodation for nowrap buttons, exactly as documented
   for .action-row above: a button that no longer fits drops to the next
   line instead of widening the page. Phone-only, so desktop rows keep
   their single-line shape. A page that genuinely must not wrap can still
   say so - its scoped `.board-controls[b-xxxxx]` out-specifies this rule.

   app.css is a PLAIN global stylesheet: no ::deep, no :global().
   ------------------------------------------------------------------ */
@media (max-width: 768px) {
    .board-controls {
        flex-wrap: wrap;
    }
}

/* ---- .responsive-table (table-to-card) ---------------------------
   Shared wrapper for wide QuickGrid/<table> surfaces. Phase-2 applies
   this to the 11 overflow-x:auto grids so they reflow on phones
   instead of forcing a horizontal scrollbar.

   Two-tier behavior:

   1) DEFAULT (.responsive-table): below --bp-md (768px) the wrapper
      becomes a horizontal-scroll container with sane affordances
      (momentum scroll, a min-width so columns keep their shape, a
      subtle gradient hint that more is off-screen). Safe drop-in for
      any table where stacking is impractical.

   2) OPT-IN (.responsive-table.table-cards): below --bp-sm (480px)
      the <table> collapses to stacked label:value CARDS - thead is
      hidden and each <td> shows its column name via a data-label.
      Markup contract for the card mode:
        <td data-label="Rating">1820</td>
      (set data-label on each cell to the column header text.)

   Usage:
     <div class="responsive-table table-cards">
       <table> ... <td data-label="When">...</td> ... </table>
     </div>
   ------------------------------------------------------------------ */
.responsive-table {
    width: 100%;
}

/* ---- ascend-scroll-edge: the house "there is more to the right" cue -------
   Shared by the horizontal scrollers that carry a wide grid
   (.games-table-wrapper, .played-table-wrapper). Two rules make it what it is,
   and both were paid for:

   1. IT IS A SHADOW, NOT A GRADIENT. A background-image on a scroll wrapper
      makes every piece of text inside it UNDECIDABLE for axe-core - the
      wrapper is an ancestor, axe walks the text's background stack into it,
      finds a background-image and returns 'incomplete' instead of a pass or a
      failure. That is what took /games' scroll hint out on develop build
      10882: 146 nodes across 10 element kinds, which the frozen register
      (#7312) correctly refused to absorb. Position and size do not help; ANY
      background-image on the wrapper does it. axe does not read box-shadow.

   2. IT TELLS THE TRUTH. Driven by the wrapper's own inline scroll timeline:
      full at the start, gone at the end of the scroll. A scroll timeline on a
      box with nothing to scroll is INACTIVE, so where the grid already fits
      the animation does not apply and the base style - no shadow - renders.
      An always-on edge fade would be a standing lie at exactly the widths
      that are already fine. Unsupported timelines land on the same base
      style, so the failure mode is "no cue", never a false one.

   Keyframes are global (Blazor scoped CSS does not rewrite their names), so
   this lives here and the scoped stylesheets reference it. The scrollbar is
   the other half of the affordance: give the wrapper `scrollbar-width: thin`
   and a brand `scrollbar-color` so the rail reads as one. */
@keyframes ascend-scroll-edge {
    from {
        box-shadow: inset -1.5rem 0 1rem -1rem rgba(49, 221, 227, 0.55);
    }

    to {
        box-shadow: inset 0 0 0 0 rgba(49, 221, 227, 0);
    }
}

/* The same cue for a VERTICAL scroller, "there is more below" (#9060 chunk 2 review round 1):
   DrillVerdictDock's reading half, where a long correction scrolls on a phone. Same two rules
   as above - a shadow, never a gradient, and driven by the box's own `scroll(self block)`
   timeline so it is absent wherever nothing overflows or timelines are unsupported. */
@keyframes ascend-scroll-edge-block {
    from {
        box-shadow: inset 0 -1.5rem 1rem -1rem rgba(49, 221, 227, 0.55);
    }

    to {
        box-shadow: inset 0 0 0 0 rgba(49, 221, 227, 0);
    }
}

/* Tier 1: horizontal-scroll container with affordances (phone base).
   NOTE: app.css is a PLAIN global stylesheet (not run through the scoped
   .razor.css pipeline), so descendant selectors target the page's <table>
   markup directly - do NOT use :global() here, it would be emitted verbatim
   and dropped by the browser as an invalid selector. */
@media (max-width: 768px) {
    .responsive-table {
        overflow-x: auto;
        -webkit-overflow-scrolling: touch;
        scrollbar-width: thin;
        /* Hint that content continues off the right edge. */
        background:
            linear-gradient(to left, rgba(49, 221, 227, 0.12), rgba(49, 221, 227, 0)) right / 1.5rem 100% no-repeat;
    }

    .responsive-table > table {
        min-width: max-content;
    }
}

/* Tier 2 (opt-in): collapse to stacked cards on small phones. */
@media (max-width: 480px) {
    .responsive-table.table-cards {
        overflow-x: visible;
        background: none;
    }

    .responsive-table.table-cards table {
        min-width: 0;
        display: block;
        width: 100%;
    }

    .responsive-table.table-cards thead {
        /* Visually hide headers but keep them for screen readers. */
        position: absolute;
        width: 1px;
        height: 1px;
        overflow: hidden;
        clip: rect(0, 0, 0, 0);
        white-space: nowrap;
    }

    .responsive-table.table-cards tbody,
    .responsive-table.table-cards tr {
        display: block;
        width: 100%;
    }

    .responsive-table.table-cards tr {
        margin-bottom: var(--space-md, 1rem);
        padding: var(--space-sm, 0.75rem) var(--space-md, 1rem);
        border: 1px solid rgba(49, 221, 227, 0.2);
        border-radius: var(--radius-md, 0.75rem);
        background: rgba(255, 255, 255, 0.03);
    }

    .responsive-table.table-cards td {
        display: flex;
        justify-content: space-between;
        align-items: baseline;
        gap: var(--space-md, 1rem);
        padding: 0.35rem 0;
        border: none;
        text-align: right;
        white-space: normal;
        word-break: break-word;
    }

    /* Render the column name from data-label as the row's left key. */
    .responsive-table.table-cards td::before {
        content: attr(data-label);
        font-size: var(--fluid-xs, 0.8rem);
        font-weight: 600;
        color: rgba(255, 255, 255, 0.6);
        text-align: left;
        margin-right: auto;
        /* #9570 - the cell's word-break reached the label too, so beside a long value (an email at 344) flex
           squeezed it and "Member" wrapped mid-word. The label keeps its width; the value side still breaks. */
        white-space: nowrap;
        flex-shrink: 0;
    }

    /* A cell with no data-label (e.g. an actions cell) spans full width. */
    .responsive-table.table-cards td:not([data-label]) {
        justify-content: stretch;
    }
}

/* Tier 3 (opt-in, #8089): a QuickGrid as stacked cards on a phone.
   .table-cards cannot serve a QuickGrid: a TemplateColumn cannot put a
   data-label on its <td>. So each cell template carries its own column
   name in a <span class="grid-card-label">, hidden off the phone, where the
   header row names the columns. (Its own name, not .cell-label: five
   pages paint a private .cell-label for their own card modes, and one
   class has one owner, SharedClassOwnershipTests.) Below --bp-md (768px) the header row
   goes, each row is a card, and each cell is a label: value line. A
   cell with no label (an Edit / Delete cell) takes .grid-cards-actions
   on its TemplateColumn and lays its buttons out from the left.
   Measured before it: the SME pay report held 527px of columns in a
   306px scroller and the SME time log 579px in 260px, with the note,
   Edit and Delete off screen.

   Usage:
     <div class="responsive-table grid-cards">
       <QuickGrid ...>
         <TemplateColumn Title="Hours">
           <span class="grid-card-label">Hours</span>@context.Hours
         </TemplateColumn>
         <TemplateColumn Title="" Class="grid-cards-actions">...</TemplateColumn>
     </div> */
.responsive-table.grid-cards .grid-card-label {
    display: none;
}

@media (max-width: 768px) {
    .responsive-table.grid-cards {
        overflow-x: visible;
        background: none;
    }

    .responsive-table.grid-cards table {
        display: block;
        width: 100%;
        min-width: 0;
    }

    .responsive-table.grid-cards thead {
        display: none;
    }

    .responsive-table.grid-cards tbody {
        display: block;
    }

    .responsive-table.grid-cards tbody tr {
        display: flex;
        flex-direction: column;
        gap: 0.4rem;
        margin-bottom: 0.75rem;
        padding: 0.75rem;
        border: 1px solid rgba(49, 221, 227, 0.2);
        border-radius: 12px;
        background: rgba(255, 255, 255, 0.03);
    }

    .responsive-table.grid-cards tbody td {
        display: flex;
        flex-wrap: wrap;
        align-items: baseline;
        justify-content: space-between;
        gap: 0.5rem;
        width: 100%;
        max-width: none;
        min-width: 0;
        padding: 0;
        border-bottom: none;
        text-align: right;
        white-space: normal;
        overflow-wrap: anywhere;
    }

    .responsive-table.grid-cards .grid-card-label {
        display: inline-block;
        color: var(--ascend-accent);
        font-family: var(--ascend-font-secondary);
        font-size: 0.7rem;
        text-transform: uppercase;
        letter-spacing: 0.05em;
        text-align: left;
    }

    .responsive-table.grid-cards tbody td.grid-cards-actions {
        justify-content: flex-start;
        gap: 0.25rem 0.75rem;
    }
}

/* ---- Fluid typography utilities ----------------------------------
   Thin wrappers over the --fluid-* clamp() ramp defined in :root.
   The audit found the ramp defined but used by ~1 file. These give
   Phase-2 a no-CSS path to convert px headings/body to fluid type:
   add the class instead of writing a clamp() or a px font-size.

     .text-fluid-xs   ... --fluid-xs    (fine print, meta)
     .text-fluid-sm   ... --fluid-sm    (secondary text)
     .text-fluid-base ... --fluid-base  (body copy)
     .text-fluid-lg   ... --fluid-lg    (lead text)
     .text-fluid-xl   ... --fluid-xl    (h4 / card titles)
     .text-fluid-2xl  ... --fluid-2xl   (h3 / section heads)
     .text-fluid-3xl  ... --fluid-3xl   (h2 / page titles)
     .text-fluid-hero ... --fluid-hero  (h1 / hero)

   Prefer rem over px when a fixed size is genuinely needed; reach for
   these when the size should scale with viewport.
   ------------------------------------------------------------------ */
.text-fluid-xs   { font-size: var(--fluid-xs); }
.text-fluid-sm   { font-size: var(--fluid-sm); }
.text-fluid-base { font-size: var(--fluid-base); }
.text-fluid-lg   { font-size: var(--fluid-lg); }
.text-fluid-xl   { font-size: var(--fluid-xl); }
.text-fluid-2xl  { font-size: var(--fluid-2xl); }
.text-fluid-3xl  { font-size: var(--fluid-3xl); }
.text-fluid-hero { font-size: var(--fluid-hero); }


/* ======================================================================
   #7736 — TOUCH TARGET FLOOR (policy; per-surface rules live beside the
   controls they name, in the scoped stylesheet that already owns them).

   THE BUG. A width-matrix walk of the member surfaces measured 63 controls
   under 36px on /games at 390px, 50 on /pricing, 45 on /study-plan and
   /opening-hub, 42 on /dashboard, 34 on /account/billing — and the counts
   barely moved between 320px and 1920px. Nothing here is a breakpoint
   defect. The controls are simply small at every width, and a phone is
   where that stops being a density choice and becomes a miss: button.tag-add
   at 48x20, button.btn-close at 24x24, a.commentary-signal at 43x27,
   the /games row actions 32px tall and 39px wide, a.nav-link 33-35px,
   roughly 26 of those on every page.

   TWO FLOORS, AND THEY ARE DIFFERENT RULES.

   1. 24px, EVERY POINTER, NOT NEGOTIABLE. WCAG 2.2 AA 2.5.8 (Target Size
      Minimum) is a conformance line, not a guideline, and a 20px-tall
      button fails it on a desktop mouse too. Controls under it are raised
      unconditionally.

   2. 44px ON A FINGER. The iOS HIG minimum and WCAG 2.5.5, carried by the
      --touch-target token (2.75rem), applied through
      `@media (hover: none) and (pointer: coarse)`.

   WHY POINTER MEDIA AND NOT A WIDTH QUERY — this is #6681's finding and it
   is re-learned here every time someone reaches for max-width. A width
   query misses the same phone turned landscape (844px wide) and misses a
   1024px tablet entirely, while a coarse pointer is true on every touch
   device at every width and false for every mouse. So the touch floor
   cannot regress desktop density, and desktop keeps the compact controls
   it is designed around.

   TWO MECHANISMS, AND THE CHOICE BETWEEN THEM IS ABOUT NEIGHBOURS.

   * GROW THE BOX (min-height / min-width) is the default and is what
     almost everything below uses. It is honest: what you can see is what
     you can hit.
   * A PSEUDO-ELEMENT HIT AREA (::after, absolutely positioned, larger than
     the visual) is used ONLY where growing the box would visibly wreck the
     design AND the control has clear space around it — the x inside a tag
     chip, the inline commentary chip. It is NOT used for rows of adjacent
     small buttons: invisible 44px areas on controls sitting 4px apart
     OVERLAP, and an overlap means the top one silently eats its
     neighbour's taps. On the /games row that neighbour is DELETE. Where
     controls are adjacent, they are grown and the row is allowed to wrap.

   RECORDED EXCEPTIONS — inline text links inside a sentence or paragraph
   (.app-footer-link, .card-header-link, .scv2-lead-link, .cc-empty-cta,
   .cc-plan-cta) are left at their text height. WCAG 2.5.8 exempts a target
   "in a sentence or block of text" precisely because padding it out to 44px
   would break the line box it lives in. They are listed in the ratchet's
   register with that reason rather than being silently skipped.
   ====================================================================== */

/* Floor 1 — under the WCAG 2.5.8 line on ANY pointer. Form controls are the
   global case: a 32-35px select is a 2.5.8 pass but a poor finger target,
   so it also takes the touch floor below. */
select,
.form-input,
.form-select {
    min-height: 1.5rem;
}

@media (hover: none) and (pointer: coarse) {
    select,
    .form-input,
    .form-select,
    .form-textarea {
        min-height: var(--touch-target);
    }
}

/* #8315 - THE LANDING TARGET FOR A PAGINATED LIST.
   /js/paged-list.js scrolls this element to the top of the viewport and gives
   it focus whenever its pager's page indicator changes. Global rather than
   per-page because five surfaces share it and the thing that keeps going wrong
   with this app's phone layout is each page choosing its own number.

   scroll-margin-top is what keeps the first row out from under the chrome:
   block: "start" aligns to the scrollport's top edge, and the mobile header is
   FIXED over that edge (60px, or 108px once it wraps to two rows). MainLayout
   publishes exactly that height as --app-content-top for every band, so the
   offset tracks the header instead of restating it. The fallback matters for a
   paginated list rendered outside MainLayout (the walled shell), where nothing
   is over the top edge and a small gap is all that is wanted.

   No `outline: none`. Focus here is programmatic, and Chrome does not match
   :focus-visible on a programmatic focus of a div - so a member who clicked
   Next sees nothing, while a member who pressed Enter on Next (keyboard, so
   :focus-visible DOES match) gets a ring telling them where focus went. That
   is the behavior to keep, not to suppress. */
.paged-list {
    scroll-margin-top: var(--app-content-top, var(--space-md));
}

.paged-list:focus-visible {
    outline: 2px solid var(--ascend-accent);
    outline-offset: 2px;
}

/* #8523 - printing /terms or /privacy prints the document, not the site. The
   chrome lives in other components' scopes (MarketingHeader, MarketingFooter,
   ConsentNotice, and MainLayout's sidebar, phone header and app footer for a
   signed-in reader), so it is hidden here, keyed on the document being on the
   page. Backgrounds are left alone: browsers do not print them by default. */
@media print {
    body:has(.legal-document) :is(
        .marketing-header,
        .footer,
        .consent-notice,
        .sidebar,
        .mobile-header,
        .app-footer,
        .skip-to-main,
        #blazor-error-ui
    ) {
        display: none !important;
    }

    /* html and body clip horizontal overflow on screen (#7296); on paper that
       clip sits on the page edge and shaves the overhang of an italic glyph that
       ends a full-width line. */
    html:has(.legal-document),
    body:has(.legal-document) {
        overflow: visible !important;
    }

    body:has(.legal-document) .public-container {
        padding: 0 var(--space-xs) 0 0 !important;
        max-width: none !important;
    }

    /* Browsers drop backgrounds when printing, so the document's light-on-dark
       text would print light on white paper. */
    .legal-document,
    .legal-document * {
        color: var(--ascend-text-primary) !important;
        box-shadow: none !important;
    }

    .legal-document blockquote {
        border-left-color: var(--ascend-text-primary) !important;
    }
}
