
/* ─── Reset ──────────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; margin: 0; }
body {
    background: var(--color-paper);
    color: var(--color-ink);
    font-family: 'Hanken Grotesk', ui-sans-serif, system-ui, sans-serif;
    /* 0.9375rem == 15px at the default 16px root, so this renders identically
       to the old `font-size: 15px` — but it now SCALES.
       The px value was a real accessibility bug: everything else in the app is
       sized in rem, which tracks the browser's text-size setting, while body
       stayed pinned at 15. A learner who raised their default text size got
       larger labels and captions and body copy that never moved, so the
       hierarchy inverted at exactly the setting meant to help them read. */
    font-size: 0.9375rem;
    font-weight: 500;
    line-height: 1.55;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    text-rendering: optimizeLegibility;
}
a { color: inherit; text-decoration: none; }
button, input, select, textarea { font: inherit; }
button:disabled { cursor: not-allowed; opacity: .55; }
p { text-wrap: pretty; }
svg { display: block; }

/* ─── Chrome is not text ──────────────────────────────────────────────────
   Interface furniture must never be selectable. Holding any of it for half a
   second otherwise makes the browser start a text selection — the same class
   of bug that put a blue rectangle across the transcript while recording a
   voice note, and that broke the Discover level filter before it.

   Deliberately NOT applied to message bubbles, inputs, or body prose: copying
   a partner's sentence out of a chat is something learners actually do, and
   a mouse drag on desktop must still select normally. Only the furniture. */
button, .btn, .chip, .nav-link, .segmented__item, .cefr-badge, .avatar,
.iconbtn, .kebab-btn, .notif-bell, .bubble-menu-btn, .rail__gear-btn,
.toggle-switch, .theme-toggle, .install-sheet__grip, .match-ring,
.bubble__meta, .message-row__time, .chat-date-divider, .eyebrow,
.onboard-eyebrow, .profile-section__action, [role="button"], [role="link"] {
    -webkit-touch-callout: none;
    -webkit-user-select: none;
    user-select: none;
}


/* ─── Typography ─────────────────────────────────────────────────────────── */
.font-serif  { font-family: 'Fraunces', Georgia, serif; }
.font-display{ font-family: 'Fraunces', Georgia, serif; font-weight: 500; }
h1, h2, h3  { font-family: 'Fraunces', Georgia, serif; font-weight: 500; }
.font-hand  { font-family: 'Caveat', 'Segoe Script', cursive; font-weight: 600; }

/* ─── Postcard-motif animations (from the redesign prototype) ────────────── */
@keyframes pu-float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-7px); } }
@keyframes pu-stamp-in {
    0% { transform: scale(2.2) rotate(-18deg); opacity: 0; }
    55% { transform: scale(.9) rotate(4deg); opacity: 1; }
    75% { transform: scale(1.06) rotate(-3deg); }
    100% { transform: scale(1) rotate(var(--stamp-rot, 0deg)); }
}
@keyframes pu-pin-in {
    0% { opacity: 0; transform: translateY(-6px) rotate(0deg) scale(.6); }
    60% { opacity: 1; }
    100% { opacity: 1; transform: translateY(0) rotate(-1.2deg) scale(1); }
}
@keyframes pu-dot-ping { 0% { transform: scale(1); opacity: .65; } 75%, 100% { transform: scale(2.1); opacity: 0; } }
@keyframes pu-bell-ring {
    0%, 88%, 100% { transform: rotate(0deg); }
    89.5% { transform: rotate(-13deg); }
    91% { transform: rotate(11deg); }
    92.5% { transform: rotate(-8deg); }
    94% { transform: rotate(5deg); }
    95.5% { transform: rotate(-2deg); }
    97% { transform: rotate(0deg); }
}
@keyframes pu-lvl-flash { 0%, 100% { opacity: .3; transform: scale(1); } 50% { opacity: 1; transform: scale(1.22); } }
@keyframes pu-lvl-land {
    0% { transform: scale(.4) rotate(-6deg); opacity: 0; }
    55% { transform: scale(1.18) rotate(2deg); opacity: 1; }
    75% { transform: scale(.94) rotate(-1deg); }
    100% { transform: scale(1) rotate(0deg); opacity: 1; }
}
@keyframes pu-letter-in { 0% { opacity: 0; transform: translateY(10px) scale(.85); } 100% { opacity: 1; transform: translateY(0) scale(1); } }

/* Welcome hero */
.welcome-hero__mark { animation: pu-float 4.5s ease-in-out infinite; }

/* Welcome-hero feature callouts: tilted like customs stamps on a passport */
.chip--stamp {
    background: var(--color-primary-tint);
    border: 1.5px solid var(--color-primary-border);
    color: var(--color-primary-text);
    cursor: default;
    font-weight: 600;
}

/* ─── App Shell ──────────────────────────────────────────────────────────── */
.app-shell {
    display: grid;
    grid-template-columns: var(--rail-width) minmax(0,1fr);
    height: 100vh;
    height: 100dvh; /* dvh tracks the iOS toolbar so the sticky header never scrolls off */
    overflow: hidden;
}

/* The onboarding wizard renders no nav rail (see layout.blade.php), so <main>
   became the FIRST grid child and landed in the 236px rail column while the
   1fr column sat empty — the whole wizard was squashed against the left edge
   on desktop. Mobile was unaffected because the shell is already single-column
   there. With no rail there is only ever one column. */
.app-shell--onboarding { grid-template-columns: minmax(0, 1fr); }

.auth-shell {
    display: grid;
    min-height: 100vh;
    min-height: 100dvh;
    place-items: center;
    padding: 1.5rem;
    background: var(--color-paper);
}

/* ─── Left Rail ──────────────────────────────────────────────────────────── */
.rail {
    background: var(--color-surface);
    border-right: 1px solid var(--color-hairline);
    display: flex;
    flex-direction: column;
    gap: 0;
    height: 100vh;
    height: 100dvh;
    overflow-y: auto;
    padding: 1.5rem 1rem;
    position: sticky;
    top: 0;
    width: var(--rail-width);
}

.rail__logo {
    align-items: center;
    display: flex;
    gap: .6rem;
    padding: .25rem .5rem .75rem;
    margin-bottom: .25rem;
}

/* ─── The brand badge (x-shared::logo-mark) ───────────────────────────────
   Finished artwork on a transparent field: the tile's 22.5% corner and the
   circle's edge are cut into the PNG itself. So nothing here may add a
   border-radius, a background or a filter — doing so double-rounds the corner
   and tints artwork that ships in two fixed inks. Size comes from the
   component's own width/height; these rules only place it and choose an ink.

   ink="themed" renders both inks and lets the theme pick. shared::layout's
   boot script resolves data-theme to a literal light|dark on <html> before
   first paint, so a single attribute selector is enough — no media query, no
   flash of the wrong badge. Order matters: the variants sit after .logo-mark
   and carry equal specificity. */
.logo-mark {
    display: block;
    flex-shrink: 0;
    /* An <img> is inline by default and would sit on the text baseline,
       leaving a descender gap under it inside the rail's flex row. */
    object-fit: contain;
}

.logo-mark--on-dark { display: none; }

:root[data-theme="dark"] .logo-mark--on-light { display: none; }
:root[data-theme="dark"] .logo-mark--on-dark { display: block; }

.rail__logo-name {
    font-family: 'Fraunces', Georgia, serif;
    font-size: 1.2rem;
    color: var(--color-ink);
}

/* ─── Nav ────────────────────────────────────────────────────────────────── */
.nav { display: flex; flex-direction: column; gap: 2px; }

.nav-link {
    align-items: center;
    border-radius: var(--radius-nav);
    color: var(--color-muted);
    display: flex;
    font-size: .875rem;
    font-weight: 500;
    gap: .65rem;
    min-height: 42px;
    padding: .55rem .75rem;
    position: relative;
    transition: background var(--dur-quick), color var(--dur-quick);
}

.nav-link:hover { background: var(--color-chip-bg); color: var(--color-ink-2); }

/* Inline box around the icon on both layouts, so the count badge can anchor to
   the icon's corner (see .nav-link__pill .nav-badge) rather than floating at the
   end of the row. The mobile bar restyles it further as the tab pill. */
.nav-link__pill {
    align-items: center;
    display: inline-flex;
    flex-shrink: 0;
    justify-content: center;
    position: relative;
}

/* Avatar-as-icon on the Profile tab. The self-coloured 24px circle; when the
   tab is active it takes a pine ring so "you are here" reads without a filled
   glyph. */
.nav-avatar { display: inline-flex; border-radius: 50%; box-shadow: 0 0 0 1.5px var(--color-input-border); }
/* Images only. This used to target .avatar and so also hit the component's
   initial-letter fallback, whose centring depends on inline-flex — the letter
   ended up in the corner on the mobile Profile tab. The <img> branch is what
   needs `block` (it kills the inline baseline gap); the <span> branch must keep
   its own display. */
.nav-avatar img.avatar { display: block; }
.nav-avatar--active { box-shadow: 0 0 0 2px var(--color-primary); }

/* Appearance segmented control (theme toggle) — auto / light / dark. */
.theme-toggle { display: inline-flex; gap: 2px; padding: 3px; background: var(--color-chip-bg); border: 1px solid var(--color-chip-border); border-radius: var(--radius-pill); }
.theme-toggle button { display: inline-flex; align-items: center; gap: 6px; padding: 7px 13px; border: 0; background: transparent; border-radius: var(--radius-pill); color: var(--color-muted); font: inherit; font-size: .8125rem; font-weight: 500; cursor: pointer; transition: color var(--dur-fast) var(--ease-standard), background var(--dur-fast) var(--ease-standard); }
.theme-toggle button i, .theme-toggle button svg { display: inline-block; width: 16px; height: 16px; }
.theme-toggle button:hover { color: var(--color-ink-2); }

/* The pill's 34px height is the design; the tap target underneath it is not.
   .hit-target grows the hit area to 68x108 via ::after without moving a pixel,
   which is the same trick the rest of the app's small controls use. Needs the
   button positioned for the pseudo-element to anchor to. */
.theme-toggle button { position: relative; }

.theme-toggle button::after {
    content: '';
    position: absolute;
    inset: -6px -2px;
}
.theme-toggle button[aria-pressed="true"] { background: var(--color-surface); color: var(--color-primary-text); box-shadow: var(--shadow-card); }

/* Same per-tab hue treatment as the mobile bar, applied to the whole row. */
.nav-link.active {
    background: var(--color-nav-discover-tint);
    color: var(--color-nav-discover);
    font-weight: 600;
}

.nav-link.active[data-nav="sessions"] {
    background: var(--color-nav-sessions-tint);
    color: var(--color-nav-sessions);
}

.nav-link.active[data-nav="profile"] {
    background: var(--color-nav-profile-tint);
    color: var(--color-nav-profile);
}

.nav-link svg { width: 18px; height: 18px; flex-shrink: 0; }

/* The filled (active) glyph is a Phosphor path on a 256 viewBox, which reads
   optically heavier than the 24-unit Lucide outline at the same box size.
   Trimming it slightly keeps the two visually matched as the tab flips. */
.nav-icon--filled { transform: scale(.92); }

.nav-badge {
    align-items: center;
    background: var(--color-primary);
    border-radius: var(--radius-pill);
    color: var(--color-on-accent);
    display: inline-flex;
    font-size: .7rem;
    font-weight: 700;
    height: 18px;
    justify-content: center;
    margin-left: auto;
    min-width: 18px;
    padding: 0 5px;
}

.nav-badge--amber { background: var(--color-amber); }

/* Count on a nav tab: pinned to the icon's top-right corner with a white ring
   so it floats above the glyph on both layouts. Overrides the base inline
   margin-left:auto used by the requests-page reuse of .nav-badge. */
.nav-link__pill .nav-badge {
    border: 1.5px solid var(--color-surface);
    font-size: .625rem;
    height: 17px;
    left: calc(100% - 9px);
    margin-left: 0;
    min-width: 17px;
    padding: 0 4px;
    position: absolute;
    top: -6px;
}

/* Profile is a first-class tab on mobile only — the desktop rail reaches it
   via the footer identity row instead. */
.nav-link--mobile-only { display: none; }

/* ─── Mobile top bar ─────────────────────────────────────────────────────── */
.mobile-topbar {
    display: none; /* hidden on desktop */
}

.mobile-topbar__user {
    display: flex;
    align-items: center;
    gap: .5rem;
    background: var(--color-card);
    border: 1px solid var(--color-card-border);
    border-radius: var(--radius-pill);
    padding: .35rem .5rem .35rem .75rem;
    text-decoration: none;
    color: inherit;
    transition: background var(--dur-quick);
}
.mobile-topbar__user:hover { background: var(--color-chip-bg); }

.mobile-topbar__user-text {
    display: flex;
    flex-direction: column;
    align-items: flex-end;
    line-height: 1.2;
}
.mobile-topbar__user-name  { font-size: .8rem; font-weight: 600; color: var(--color-ink); }
.mobile-topbar__user-level { font-size: .7rem; color: var(--color-muted); }

.avatar--34 { width: 34px; height: 34px; font-size: .8rem; }

/* ─── Streak Card (rail) ─────────────────────────────────────────────────── */
/* A plain card, not a clay-filled block: the flame icon carries the streak
   accent, so filling the whole card made it the only warm surface on screen. */
.streak-card {
    background: var(--color-card);
    border: 1px solid var(--color-card-border);
    border-radius: var(--radius-card);
    margin: .75rem 0;
    padding: .9rem 1rem;
}
.streak-card__header {
    align-items: center;
    display: flex;
    gap: .45rem;
    margin-bottom: .5rem;
}
.streak-card__header span:last-child {
    color: var(--color-muted);
    font-size: .6875rem;
    font-weight: 700;
    letter-spacing: .14em;
    text-transform: uppercase;
}
.streak-card__days { display: flex; gap: .25rem; margin-bottom: .55rem; }
.streak-card__day {
    border: 1px solid var(--color-hairline);
    border-radius: var(--radius-tile-sm);
    flex: 1;
    height: 20px;
}
.streak-card__day--done { background: var(--color-primary); border-color: var(--color-primary); }
.streak-card__text { color: var(--color-ink); font-size: .8rem; line-height: 1.45; }
.streak-card__text strong { font-weight: 700; }

/* Streak flame — a real icon rather than an emoji, so it takes a token colour
   and renders identically everywhere. */
.streak-card__flame {
    color: var(--acc-streak);
    height: 15px;
    width: 15px;
}

/* ─── Rail Footer (user row) ─────────────────────────────────────────────── */
.rail__footer {
    align-items: center;
    border-top: 1px solid var(--color-hairline);
    display: flex;
    gap: 0;
    margin-top: auto;
    padding: .75rem .35rem .75rem 0;
    border-radius: var(--radius-nav);
    transition: background var(--dur-quick);
}
.rail__footer:hover { background: var(--color-chip-bg); }

.rail__footer-name {
    font-size: .85rem;
    font-weight: 600;
    color: var(--color-ink);
    flex: 1;
    min-width: 0;
}

.rail__footer-level { font-size: .72rem; }
.rail__footer-cta   { font-size: .72rem; color: var(--color-primary-text); font-weight: 600; }
.rail__footer-link {
    display: flex;
    align-items: center;
    gap: .65rem;
    flex: 1;
    min-width: 0;
    padding: .35rem .4rem;
    text-decoration: none;
    color: inherit;
}

.rail__gear-btn {
    background: none;
    border: none;
    color: var(--color-muted);
    cursor: pointer;
    padding: .35rem;
    border-radius: 8px;
    display: flex;
    align-items: center;
    justify-content: center;
    transition: color var(--dur-fast), background var(--dur-fast);
    flex-shrink: 0;
}
.rail__gear-btn:hover { color: var(--color-danger); background: var(--color-danger-tint); }
.rail__gear-btn svg  { width: 17px; height: 17px; }
.rail__gear-btn--neutral:hover { color: var(--color-primary-text); background: var(--color-primary-tint); }

/* Logout button inside mobile topbar — bordered pill to match the profile pill */
.mobile-topbar .rail__gear-btn {
    background: var(--color-card);
    border: 1px solid var(--color-card-border);
    border-radius: 10px;
    width: 36px;
    height: 36px;
    padding: 0;
}
.mobile-topbar .rail__gear-btn:hover { background: var(--color-danger-tint); border-color: var(--color-danger); color: var(--color-danger); }
.mobile-topbar .rail__gear-btn--neutral:hover { background: var(--color-primary-tint); border-color: var(--color-primary-border); color: var(--color-primary-text); }

[x-cloak] { display: none !important; }

/* ─── Avatar ─────────────────────────────────────────────────────────────── */
.avatar {
    align-items: center;
    border-radius: 50%;
    color: var(--color-on-accent);
    display: inline-flex;
    flex-shrink: 0;
    font-weight: 700;
    justify-content: center;
}
.avatar--36 { height: 36px; width: 36px; font-size: .85rem; }
.avatar--38 { height: 38px; width: 38px; font-size: .9rem; }
.avatar--40 { height: 40px; width: 40px; font-size: .95rem; }
.avatar--46 { height: 46px; width: 46px; font-size: 1.05rem; }
.avatar--50 { height: 50px; width: 50px; font-size: 1.1rem; }
.avatar--52 { height: 52px; width: 52px; font-size: 1.15rem; }
.avatar--76 { height: 76px; width: 76px; font-size: 1.6rem; }
.avatar--84 { height: 84px; width: 84px; font-size: 1.8rem; }

/* ─── Main Area ──────────────────────────────────────────────────────────── */
.page {
    display: flex;
    flex-direction: column;
    height: 100vh;
    height: 100dvh;
    overflow-x: hidden; /* guard: never let a wide child scroll the whole page sideways */
    overflow-x: clip;   /* unlike hidden, clip also forbids programmatic sideways scroll
                           (opening the notification panel used to pan the page) */
    overflow-y: auto;
    padding-bottom: 2rem;
}

/* The level check is the one screen where a reload costs real work, so the
   pull-to-refresh gesture is contained here and nowhere else. `.page` is the
   scroller itself, so at scrollTop 0 a downward pull chains to the document
   and refreshes it — which is exactly how a learner lost 22 answered
   questions. Belt and braces: the answers are also saved locally, because
   this only stops one of the ways a page can reload. */
.page--quiz {
    overscroll-behavior-y: contain;
}

/* Visually hidden but still read aloud — for headings the design drops but
   assistive navigation still needs. */
.sr-only {
    border: 0;
    clip: rect(0 0 0 0);
    clip-path: inset(50%);
    height: 1px;
    overflow: hidden;
    position: absolute;
    white-space: nowrap;
    width: 1px;
}

/* No rule under the header: the design reads as one continuous page, with the
   title sitting on the same white as the content rather than in a banded bar. */
.page-header {
    align-items: center;
    background: var(--color-surface);
    display: flex;
    gap: 1rem;
    padding: 1.375rem 2.25rem;
    position: sticky;
    top: 0;
    z-index: 10;
}

/* Title-less header (Profile): the eyebrow has no title to anchor it, so
   centring it against the 40px icon buttons dropped it ~13px lower than the
   eyebrow on every other page. Top-align so it matches, and trim the bottom
   padding since there's no title filling the row. */
.page-header--bare { align-items: flex-start; padding-bottom: .6rem; }

.page-header__lead    { min-width: 0; }
.page-header__title   { font-size: 1.875rem; line-height: 1.2; }
.page-header__sub     { color: var(--color-muted); font-size: .82rem; margin-top: .2rem; display: flex; align-items: center; gap: .3rem; }
.page-header__actions { margin-left: auto; display: flex; gap: .5rem; align-items: center; }

/* ─── Profile stat cards ─────────────────────────────────────────────────── */
/* Passport-style CEFR mini-stamp on the profile avatar */
.profile-identity__stamp {
    align-items: center;
    border: 2px solid;
    border-radius: 50%;
    bottom: -8px;
    display: flex;
    font-family: 'Fraunces', Georgia, serif;
    font-size: .8rem;
    font-weight: 600;
    height: 38px;
    justify-content: center;
    position: absolute;
    right: -8px;
    transform: rotate(-8deg);
    width: 38px;
}

/* Profile "This week" postmark strip */
/* Two rows: "This week" + the streak count share the first line (count pushed
   right), then the seven day cells take a full-width line of their own. The
   days' flex-basis:100% is what forces that wrap — without it all three
   children collapse onto one line and the day labels drift off their dots. */
/* Streak card (§5.6): count and "THIS WEEK" share the top row, then the day
   strip, then the next milestone. */
.streak-week-card {
    display: flex;
    flex-direction: column;
    gap: .7rem;
    margin-bottom: 1rem;
    padding: 1.1rem 1.35rem;
}

.streak-week-card__head { align-items: baseline; display: flex; justify-content: space-between; gap: 1rem; }
.streak-week-card__count { align-items: baseline; display: flex; gap: .4rem; }
.streak-week-card__num  { color: var(--color-primary-text); font-size: 2rem; line-height: 1; }
.streak-week-card__unit { color: var(--color-ink); font-size: 1rem; font-weight: 600; }
.streak-week-card__next { color: var(--color-muted); font-size: .82rem; }
.streak-week-card__next strong { color: var(--color-ink); font-weight: 600; }

.streak-week-card__days { display: flex; gap: .4rem; justify-content: space-between; }
/* The day initial sits inside its own square — one element per day, rather
   than a square with a caption beneath it. Done = pine fill, today = pine
   outline, future = hairline outline, so state is carried by fill and border
   rather than by a glyph. */
.streak-week-day {
    align-items: center;
    border: 1.5px solid var(--color-hairline);
    border-radius: var(--radius-tile-sm);
    /* --color-faint is documented in tokens.css as 4.26:1, for placeholders and
       disabled text — which WCAG exempts. A day initial is neither: you have to
       read S M T W to know which day the streak broke. --color-muted clears 4.5
       and the days stay quieter than the done ones, which carry a filled tile. */
    color: var(--color-muted);
    display: flex;
    flex: 1;
    font-size: .78rem;
    font-weight: 600;
    height: 38px;
    justify-content: center;
}

.streak-week-day--done {
    background: var(--color-primary);
    border-color: var(--color-primary);
    color: var(--color-on-accent);
}

/* Today gets the pine outline, but only takes the pine TEXT when it isn't
   also done — otherwise it would paint pine-on-pine and the initial vanishes,
   which is exactly what happens on the day you practise. */
.streak-week-day--today { border-color: var(--color-primary); border-width: 2px; }
.streak-week-day--today:not(.streak-week-day--done) { color: var(--color-primary-text); }

/* ─── Achievements (§5.6) ────────────────────────────────────────────────────
   A tinted circular badge per achievement. Earned ones carry their accent
   colour; locked ones are a faint outline with a lock glyph, so earned/locked
   is legible by fill as well as by colour. Accent groups come from tokens.css
   via [data-accent], keeping the palette re-skinnable. */
/* Profile sections below the identity block are hairline-separated bands, not
   cards — the design keeps the card treatment for the two interactive blocks
   (open-to-chat, streak) and lets everything else sit on the page. */
.profile-section { border-top: 1px solid var(--color-hairline); padding: 1.15rem 0; }

.profile-section__head {
    align-items: center;
    display: flex;
    gap: .5rem;
    justify-content: space-between;
    margin-bottom: .9rem;
}

.profile-section__action {
    color: var(--color-primary-text);
    font-size: .82rem;
    font-weight: 600;
    text-decoration: none;
}

.profile-section__aside { color: var(--color-muted); font-size: .82rem; }
.profile-section__body  { color: var(--color-ink-2); font-size: .9rem; line-height: 1.6; }
.profile-section__body--empty { color: var(--color-muted); }

/* English level: numeral, progress copy, and the retake link on one line. */
.level-line { align-items: baseline; display: flex; gap: .7rem; margin-bottom: .7rem; }
.level-line__num  { color: var(--color-primary-text); font-size: 2.5rem; line-height: 1; }
.level-line__meta { color: var(--color-muted); font-size: .85rem; flex: 1; }
.level-line__retake { flex-shrink: 0; }

/* Settings rows at the page foot — same hairline band as the sections above,
   with a tinted icon tile. Sign out takes the danger pair so the destructive
   one is distinguishable without being loud. */
.settings-row {
    align-items: center;
    background: none;
    border: 0;
    border-top: 1px solid var(--color-hairline);
    color: inherit;
    cursor: pointer;
    display: flex;
    font: inherit;
    gap: .85rem;
    padding: 1.05rem 0;
    text-align: left;
    text-decoration: none;
    width: 100%;
}

.settings-row__icon {
    align-items: center;
    background: var(--color-chip-bg);
    border-radius: var(--radius-tile);
    color: var(--color-ink-2);
    display: flex;
    flex-shrink: 0;
    height: 38px;
    justify-content: center;
    width: 38px;
}

.settings-row__icon--danger { background: var(--color-danger-tint); color: var(--color-danger); }

.settings-row__text  { flex: 1; min-width: 0; }
.settings-row__title { display: block; font-size: .95rem; font-weight: 600; }
.settings-row__sub   { color: var(--color-muted); display: block; font-size: .8rem; margin-top: .1rem; }

.settings-row--danger .settings-row__title { color: var(--color-danger); }

/* Small ink pill marking a section as new. The text colour must be --color-paper,
   NOT --color-on-accent: on-accent is white in both themes, while ink flips to
   near-white in dark — so the pill rendered white-on-white (1.12:1, invisible).
   paper flips together with ink, so the pair holds in both themes. */
.pill-new {
    background: var(--color-ink);
    border-radius: var(--radius-pill);
    color: var(--color-paper);
    font-size: .62rem;
    font-weight: 700;
    letter-spacing: .1em;
    margin-right: auto;
    padding: .2rem .5rem;
    text-transform: uppercase;
}

.achievements {
    display: flex;
    gap: .5rem;
    justify-content: space-between;
    list-style: none;
}

.achievement { display: flex; flex-direction: column; align-items: center; text-align: center; gap: .4rem; flex: 1; }

.achievement__badge {
    align-items: center;
    background: var(--color-chip-bg);
    border: 1.5px solid transparent;
    border-radius: 50%;
    color: var(--color-faint);
    display: flex;
    height: 44px;
    justify-content: center;
    width: 44px;
}

.achievement__badge svg { height: 20px; width: 20px; }

.achievement__name { color: var(--color-muted-2); font-size: .72rem; line-height: 1.3; }

/* Locked stays neutral; earned takes its accent pair. */
.achievement--earned .achievement__name { color: var(--color-muted); }

.achievement--earned[data-accent="streak"]   .achievement__badge { background: var(--acc-streak-tint);   color: var(--acc-streak); }
.achievement--earned[data-accent="message"]  .achievement__badge { background: var(--acc-message-tint);  color: var(--acc-message); }
.achievement--earned[data-accent="partners"] .achievement__badge { background: var(--acc-partners-tint); color: var(--acc-partners); }
.achievement--earned[data-accent="success"]  .achievement__badge { background: var(--acc-success-tint);  color: var(--acc-success); }

/* Locked badges get a dashed edge so the state reads without relying on the
   glyph alone at small sizes. */
.achievement:not(.achievement--earned) .achievement__badge {
    background: none;
    border-color: var(--color-input-border);
    border-style: dashed;
}

.profile-stats { display: flex; gap: 1rem; margin-bottom: 1rem; }

/* About + Interests pair: side by side on desktop, stacked on narrow screens
   (two columns at 375px squeezed both cards into wrapping slivers). */
.profile-two-col { align-items: stretch; display: grid; gap: 1rem; grid-template-columns: 1fr 1fr; margin-bottom: 1rem; }
@media (max-width: 560px) {
    .profile-two-col { grid-template-columns: 1fr; }
}

/* "Who can message you" row: on narrow screens the dropdown drops to its own
   line instead of squeezing the text into a one-word-per-line column. */
.who-row__text { min-width: 0; }
@media (max-width: 560px) {
    .who-row { flex-wrap: wrap; }
    .who-row__text { min-width: 200px; }
    .who-row .filter-dropdown { margin-left: auto; }
}

/* Design-v2 stat cards: big Fraunces numeral, centered, no icon */
.stat-card--center { flex-direction: column; gap: .1rem; justify-content: center; text-align: center; }
.stat-card--center .stat-card__num { font-size: 1.65rem; font-weight: 500; }
.stat-card {
    flex: 1 1 0;
    min-width: 0; /* allow cards to shrink below content width instead of overflowing */
    padding: 1rem 1.25rem;
    display: flex;
    align-items: center;
    gap: .85rem;
}
.stat-card__icon  { width: 22px; height: 22px; flex-shrink: 0; }
.stat-card__text  { min-width: 0; }
.stat-card__num   { font-size: 1.5rem; font-weight: 700; line-height: 1.1; }
.stat-card__label { font-size: .75rem; color: var(--color-muted); margin-top: .1rem; }

.page-body {
    --page-gutter: 2.25rem;
    /* Exposed so a full-bleed child can cancel the vertical padding the same
       way --page-gutter lets it cancel the horizontal one. */
    --page-gutter-y: 1.75rem;
    flex: 1;
    padding: var(--page-gutter-y) var(--page-gutter);
    max-width: var(--page-column);
}

/* Full-width variant for discover grid and chat */
.page--wide .page-body { max-width: none; }
.page--chat .page-body  { max-width: none; padding: 0; }

/* ─── Cards ──────────────────────────────────────────────────────────────── */
.card {
    background: var(--color-card);
    border: 1px solid var(--color-card-border);
    border-radius: var(--radius-card);
    padding: 1.375rem;
}

.card--hover {
    cursor: pointer;
    transition: box-shadow var(--dur-fast) ease, transform var(--dur-fast) ease;
}

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

/* ─── CEFR Badge ─────────────────────────────────────────────────────────── */
.cefr-badge {
    border: 1px solid;
    border-radius: var(--radius-pill);
    font-size: .7rem;
    font-weight: 700;
    letter-spacing: .02em;
    padding: .15rem .55rem;
    white-space: nowrap;
}

/* ─── Buttons ────────────────────────────────────────────────────────────── */
.btn {
    align-items: center;
    border: none;
    border-radius: var(--radius-pill);
    cursor: pointer;
    display: inline-flex;
    font-size: .875rem;
    font-weight: 600;
    gap: .4rem;
    justify-content: center;
    padding: .6rem 1.25rem;
    transition: background var(--dur-quick), box-shadow var(--dur-quick), transform var(--dur-quick);
    white-space: nowrap;
}

.btn--primary {
    background: var(--color-primary);
    color: var(--color-on-accent);
}
.btn--primary:hover { background: var(--color-primary-hover); transform: translateY(-1px); }

.btn--outline {
    background: transparent;
    border: 1.5px solid var(--color-card-border);
    color: var(--color-ink-2);
}
.btn--outline:hover { background: var(--color-chip-bg); }

.btn--tint {
    background: var(--color-primary-tint);
    border: 1.5px solid var(--color-primary-border);
    color: var(--color-primary-text);
}
.btn--tint:hover { background: var(--color-primary-tint); }

.btn--ghost {
    background: transparent;
    color: var(--color-muted);
    padding: .4rem .7rem;
}
.btn--ghost:hover { background: var(--color-chip-bg); color: var(--color-ink-2); }

.btn--danger {
    background: var(--color-danger);
    color: var(--color-on-accent);
}
.btn--danger:hover { background: var(--color-danger-hover); }

.btn--danger:disabled { background: var(--color-disabled-bg); color: var(--color-disabled-text); }

.btn--muted {
    background: var(--color-chip-bg);
    color: var(--color-faint);
}
.btn--muted:hover { background: var(--color-chip-border); }

.btn--sm  { font-size: .8rem; padding: .45rem .9rem; }
.btn--lg  { font-size: 1rem;  padding: .75rem 1.75rem; }
.btn--full { width: 100%; }

.btn--sent {
    align-items: center;
    background: var(--color-primary-tint);
    border-radius: var(--radius-pill);
    color: var(--color-primary-text);
    display: inline-flex;
    font-size: .8rem;
    font-weight: 600;
    gap: .35rem;
    padding: .45rem .9rem;
}

/* ─── Chips ──────────────────────────────────────────────────────────────── */
.chip {
    align-items: center;
    background: var(--color-chip-bg);
    border: 1.5px solid transparent;
    border-radius: var(--radius-pill);
    cursor: pointer;
    display: inline-flex;
    font-size: .78rem;
    font-weight: 500;
    gap: .3rem;
    padding: .35rem .75rem;
    transition: background var(--dur-quick), border-color var(--dur-quick), color var(--dur-quick);
    user-select: none;
}

.chip--active, .chip.active {
    background: var(--color-primary-tint);
    border-color: var(--color-primary-border);
    color: var(--color-primary-text);
    font-weight: 600;
}

.chip--static { cursor: default; }

/* Onboarding interest picker (§5.1): unselected is a white outline chip,
   selected is a solid pine fill with a check — a tint read too close to the
   unselected state to make a 12-chip grid scannable. */
.chip--pick {
    background: var(--color-card);
    border-color: var(--color-input-border);
    color: var(--color-ink-2);
    padding: .5rem 1rem;
}

.chip--pick:hover { border-color: var(--color-primary-border); }

.chip--picked {
    background: var(--color-primary);
    border-color: var(--color-primary);
    color: var(--color-on-accent);
    font-weight: 600;
}

/* Outline chip — white fill, thin border. The profile's interest chips in the
   design are outlined rather than filled, which keeps that band mostly white. */
.chip--outline {
    background: var(--color-card);
    border-color: var(--color-input-border);
    color: var(--color-ink-2);
    cursor: default;
}

.chip--add {
    background: none;
    border-color: var(--color-input-border);
    border-style: dashed;
    color: var(--color-muted);
    text-decoration: none;
}

.chip__icon, .chip__check { width: 13px; height: 13px; }

/* ─── Open to chat badge ─────────────────────────────────────────────────── */
.open-to-chat-badge {
    align-items: center;
    background: var(--color-primary-tint);
    border: 1px solid var(--color-primary-border);
    border-radius: var(--radius-pill);
    color: var(--color-primary-text);
    display: inline-flex;
    font-size: .72rem;
    font-weight: 600;
    gap: .3rem;
    padding: .2rem .6rem;
    white-space: nowrap;
}
.open-to-chat-badge .icon-xs { width: 12px; height: 12px; }

/* ─── Toggle switch ──────────────────────────────────────────────────────── */
.toggle-switch {
    cursor: pointer;
    display: inline-block;
    flex-shrink: 0;
    height: 26px;
    position: relative;
    width: 46px;
}
.toggle-switch__input {
    height: 0;
    opacity: 0;
    position: absolute;
    width: 0;
}
.toggle-switch__track {
    background: var(--color-toggle-track);
    border-radius: var(--radius-pill);
    inset: 0;
    position: absolute;
    transition: background var(--dur-fast) ease;
}
.toggle-switch__knob {
    background: var(--color-on-accent);
    border-radius: 50%;
    box-shadow: 0 1px 3px rgba(0,0,0,.2);
    height: 20px;
    left: 3px;
    position: absolute;
    top: 3px;
    /* translateX, not `left` — same 20px of travel, but on the compositor. */
    transition: transform var(--dur-fast) var(--ease-standard);
    width: 20px;
}
.toggle-switch__input:checked ~ .toggle-switch__track { background: var(--color-primary); }
.toggle-switch__input:checked ~ .toggle-switch__knob { transform: translateX(20px); }
.toggle-switch__input:focus-visible ~ .toggle-switch__track { outline: 2px solid var(--color-primary); outline-offset: 2px; }

/* ─── Toggle card (e.g. "Open to chat", Privacy & Safety switches) ───────── */
.toggle-card {
    align-items: center;
    background: var(--color-card);
    border: 1px solid var(--color-card-border);
    border-radius: 16px;
    cursor: pointer;
    display: flex;
    gap: 1rem;
    justify-content: space-between;
    padding: 18px 20px;
}
/* Open-to-chat card (§4.1). Deliberately NOT .toggle-card: a global handler
   forwards any click inside a .toggle-card to its switch, which would flip the
   toggle off (collapsing this card's dropdown) on every menu interaction. Only
   the row is clickable here; the dropdown panel below it is not. */
.o2c-card {
    background: var(--color-card);
    border: 1px solid var(--color-card-border);
    border-radius: 16px;
}

.o2c-row {
    align-items: center;
    cursor: pointer;
    display: flex;
    gap: 1rem;
    justify-content: space-between;
    padding: 18px 20px;
}

.o2c-panel {
    border-top: 1px solid var(--color-hairline);
    padding: 12px 20px 14px;
}

/* Open-to-chat auto-turn-off dropdown (§4.1, profile page) */
.o2c-select {
    align-items: center;
    background: var(--color-paper);
    border: 1px solid var(--color-hairline);
    border-radius: 11px;
    /* A <button> does not inherit the page's colour — without this it fell back
       to the UA default black on a dark surface (1.18:1, unreadable). Exactly
       the same footgun as the dark-mode composer textarea. */
    color: var(--color-ink);
    cursor: pointer;
    display: flex;
    gap: .5rem;
    justify-content: space-between;
    padding: .55rem .8rem;
    width: 100%;
}

.o2c-select:hover { border-color: var(--color-card-border); }

.o2c-option {
    align-items: center;
    background: transparent;
    border: none;
    border-radius: 9px;
    color: var(--color-ink-2);
    cursor: pointer;
    display: flex;
    font-size: .85rem;
    justify-content: space-between;
    padding: .5rem .8rem;
    text-align: left;
    width: 100%;
}

.o2c-option:hover { background: var(--color-chip-bg); color: var(--color-ink); }

.o2c-hint {
    align-items: flex-start;
    color: var(--color-muted);
    display: flex;
    font-size: .78rem;
    gap: .4rem;
    line-height: 1.45;
    margin: .6rem 0 0;
}

.toggle-card__icon {
    align-items: center;
    background: var(--color-primary-tint);
    border-radius: 11px;
    color: var(--color-primary-text);
    display: flex;
    flex-shrink: 0;
    height: 40px;
    justify-content: center;
    width: 40px;
}

.toggle-card__icon--clay {
    background: var(--color-clay-tint);
    color: var(--color-clay);
}

/* Round transparent icon button (page-header actions, e.g. edit profile) */
.iconbtn {
    align-items: center;
    background: transparent;
    border: none;
    border-radius: 50%;
    color: var(--color-muted);
    cursor: pointer;
    display: inline-flex;
    height: 40px;
    justify-content: center;
    width: 40px;
    transition: background var(--dur-quick), color var(--dur-quick);
}

.iconbtn:hover {
    background: var(--color-chip-bg);
    color: var(--color-primary-text);
}
.chip-group { display: flex; flex-wrap: wrap; gap: 7px; }

/* ─── Search box ─────────────────────────────────────────────────────────── */
.search-box {
    position: relative;
    display: flex;
    align-items: center;
}
.search-box__icon {
    position: absolute;
    left: .75rem;
    width: 15px;
    height: 15px;
    color: var(--color-faint);
    pointer-events: none;
    flex-shrink: 0;
}
.search-box__input {
    background: var(--color-card);
    border: 1.5px solid var(--color-card-border);
    border-radius: var(--radius-pill);
    color: var(--color-ink);
    font-size: .84rem;
    padding: .5rem .9rem .5rem 2.25rem;
    transition: border-color var(--dur-quick);
    width: 220px;
}
.search-box__input::placeholder { color: var(--color-faint); }
.search-box__input:focus { border-color: var(--color-primary); outline: none; }

/* ─── Filter bar ─────────────────────────────────────────────────────────── */
/* One scrollable line — the filters must never wrap onto a second row. The
   negative margin plus matching padding lets chips scroll edge to edge while
   staying aligned with the page gutter at rest. */
.filter-bar {
    align-items: center;
    display: flex;
    flex-wrap: nowrap;
    gap: 8px;
    margin-bottom: 1.25rem;
    margin-inline: calc(var(--page-gutter, 1rem) * -1);
    overflow-x: auto;
    padding-inline: var(--page-gutter, 1rem);
    scrollbar-width: none;
    -webkit-overflow-scrolling: touch;
}

.filter-bar::-webkit-scrollbar { display: none; }
.filter-bar > * { flex-shrink: 0; }

/* Filter dropdown (level selector) */
.filter-dropdown { position: relative; }
.chip--dropdown { display: flex; align-items: center; gap: 4px; cursor: pointer; border: none; background: var(--color-chip-bg); }
.chip--dropdown:hover { background: var(--color-primary-tint); }
.chip__chevron { transition: transform var(--dur-fast); }
.filter-dropdown__menu {
    display: none;
    position: absolute;
    top: calc(100% + 6px);
    left: 0;
    background: var(--color-card);
    border: 1px solid var(--color-card-border);
    border-radius: var(--radius-inner);
    box-shadow: var(--shadow-dropdown);
    min-width: 120px;
    padding: 6px;
    z-index: 50;
}
.filter-dropdown__menu.open { display: block; }

/* Set only on a menu that has been moved to <body> to escape the filter bar
   (see create.blade.php). Its top/left come from the chip's rect. The privacy
   page's copy is never moved and stays absolutely positioned inside its own
   wrapper. z-index clears the bottom nav (100) but stays under modals (200). */
.filter-dropdown__menu--floating.open {
    position: fixed;
    z-index: 150;
}
.filter-dropdown__item {
    display: block;
    padding: 7px 12px;
    border-radius: 8px;
    font-size: .84rem;
    color: var(--color-ink-2);
    text-decoration: none;
    transition: background var(--dur-quick);
}
.filter-dropdown__item:hover { background: var(--color-primary-tint); color: var(--color-primary-text); }
.filter-dropdown__item--active { background: var(--color-primary-tint); color: var(--color-primary-text); font-weight: 600; }

/* Who-can-message trigger button — brand-tint pill (matches the prototype's
   whoMsgBtnStyle exactly, not the tan .chip--dropdown default). */
.who-trigger {
    align-items: center;
    background: var(--color-primary-tint);
    border: 1px solid var(--color-primary-border);
    border-radius: var(--radius-pill);
    color: var(--color-primary-text);
    cursor: pointer;
    display: inline-flex;
    font-size: .84rem;
    font-weight: 600;
    gap: 6px;
    padding: 8px 13px;
    white-space: nowrap;
}
.who-trigger--open { border-color: var(--color-primary); }

/* Who-can-message menu — a wider variant of .filter-dropdown__menu */
.who-menu { left: auto; right: 0; padding: 8px; width: 300px; }
.who-menu__item {
    align-items: flex-start;
    background: transparent;
    border: none;
    border-radius: 10px;
    cursor: pointer;
    display: flex;
    gap: .6rem;
    padding: .6rem .65rem;
    text-align: left;
    width: 100%;
}
.who-menu__item:hover { background: var(--color-chip-bg); }
.who-menu__item--selected { background: var(--color-callout-tint); }
.who-menu__item-check { color: var(--color-faint); flex-shrink: 0; margin-top: 2px; width: 15px; height: 15px; }
.who-menu__item--selected .who-menu__item-check { color: var(--color-online-text); }
.who-menu__item-label { color: var(--color-ink); display: block; font-size: .85rem; font-weight: 600; }
.who-menu__item-desc { color: var(--color-muted); display: block; font-size: .76rem; line-height: 1.4; margin-top: .1rem; }

/* ─── Partner Grid ───────────────────────────────────────────────────────── */
/* ─── Discover partner list ──────────────────────────────────────────────────
   Hairline-separated rows rather than bordered cards: the design fits roughly
   twice as many partners per screen, and on a browsing surface density is the
   point. The bio and interest-chip row are gone with the card — the meta line
   carries location and two interests instead. */
.partner-list { display: flex; flex-direction: column; }

.partner-row {
    align-items: center;
    border-top: 1px solid var(--color-hairline);
    display: flex;
    gap: .8rem;
    padding: .85rem 0;
}

.partner-row:first-child { border-top: 0; }

.partner-row__avatar-link { display: inline-flex; flex-shrink: 0; }
.partner-row__main { flex: 1; min-width: 0; }

/* Name, then the level badge, then presence — all packed to the left. */
.partner-row__top {
    align-items: center;
    display: flex;
    flex-wrap: wrap;
    gap: .4rem;
}

.partner-row__name {
    color: var(--color-ink);
    font-size: .95rem;
    font-weight: 600;
    line-height: 1.25;
    /* A long unhyphenated surname pushed the row 12px past its own box on a
       320px screen — the parent already had min-width:0, but the name itself
       had no way to break. Wrapping rather than truncating: a partner's name is
       the one thing on this row you must not hide behind an ellipsis. */
    min-width: 0;
    overflow-wrap: anywhere;
    text-decoration: none;
}

.partner-row__cefr,
.message-row__cefr { color: var(--color-muted); font-size: .78rem; font-weight: 400; }

.partner-row__meta {
    color: var(--color-muted);
    font-size: .8rem;
    margin-top: .1rem;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.partner-row__match { font-size: .78rem; margin-top: .15rem; }
.partner-row__match-pct { color: var(--color-primary-text); font-weight: 600; }
.partner-row__away { color: var(--color-muted); }

/* Row actions are quiet outline pills — on a list this dense, a filled button
   per row would turn the page into a wall of pine. The one filled CTA on
   Discover stays on the top-match card. */
.partner-row__action { flex-shrink: 0; }

.partner-row__action .btn {
    background: var(--color-card);
    border: 1px solid var(--color-input-border);
    border-radius: var(--radius-pill);
    color: var(--color-primary-text);
    font-size: .82rem;
    font-weight: 600;
    padding: .5rem 1.05rem;
}

.partner-row__action .btn:hover { background: var(--color-primary-tint2); border-color: var(--color-primary-border); }

/* Already-sent reads as state, not an action. */
.partner-row__action [aria-disabled="true"] {
    color: var(--color-muted);
    cursor: default;
    font-weight: 500;
}

.partner-row__action [aria-disabled="true"]:hover { background: var(--color-card); }

.partner-grid {
    display: grid;
    gap: 1.125rem;
    grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
}

/* Compact since Phase 2 — tighter padding + 46px avatar + 2-line bio clamp
   so ~4–5 cards fit per screen; the postcard look itself is unchanged. */
.partner-card {
    background: var(--color-card);
    border: 1.5px solid var(--color-card-border);
    border-radius: 18px;
    display: flex;
    flex-direction: column;
    gap: .55rem;
    overflow: visible;
    padding: .95rem 1.1rem;
    position: relative;
    transition: box-shadow var(--dur-fast) ease, transform var(--dur-fast) ease;
}

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

/* Wax-seal CEFR stamp — overlaps the card's top-right corner at a slight
   tilt, the way a postmark sits crooked on an envelope. Stamps land with a
   press-in animation and alternate tilts so a grid looks hand-pressed. */
.cefr-stamp {
    --stamp-rot: -6deg;
    align-items: center;
    animation: pu-stamp-in .6s cubic-bezier(.2, .8, .3, 1.1) both;
    border: 2px solid;
    border-radius: 50%;
    box-shadow: 0 3px 8px rgba(0, 0, 0, .08);
    display: flex;
    font-family: 'Fraunces', Georgia, serif;
    font-size: .95rem;
    font-weight: 600;
    height: 48px;
    justify-content: center;
    position: absolute;
    right: 14px;
    top: -14px;
    width: 48px;
    z-index: 1;
}

.partner-grid > :nth-child(2n) .cefr-stamp { --stamp-rot: 5deg; }
.partner-grid > :nth-child(3n) .cefr-stamp { --stamp-rot: -4deg; }

/* Online dot with a soft expanding ping */
.status-dot--ping { position: relative; }
.partner-card__status--online .status-dot--ping::after,
.profile-identity__meta .status-dot--ping::after {
    animation: pu-dot-ping 1.8s cubic-bezier(.2, .6, .4, 1) infinite;
    border: 1.3px solid currentColor;
    border-radius: 50%;
    content: '';
    inset: 0;
    position: absolute;
}

.partner-card__header {
    align-items: flex-start;
    display: flex;
    gap: .85rem;
}

.partner-card__avatar {
    align-items: center;
    border-radius: 50%;
    display: inline-flex;
    flex-shrink: 0;
    font-size: 1.05rem;
    font-weight: 700;
    height: 46px;
    justify-content: center;
    width: 46px;
}

.partner-card__identity { flex: 1; min-width: 0; }

.partner-card__name {
    font-size: 1rem;
    font-weight: 600;
    color: var(--color-ink);
}

.partner-card__meta {
    align-items: center;
    color: var(--color-muted);
    display: flex;
    font-size: .78rem;
    gap: .25rem;
    margin-top: .15rem;
}

.partner-card__level-col {
    align-items: flex-end;
    display: flex;
    flex-direction: column;
    gap: .3rem;
    flex-shrink: 0;
    /* clear the wax stamp overlapping the card's top-right corner */
    padding-top: 2.1rem;
}

.partner-card__status {
    align-items: center;
    color: var(--color-muted);
    display: flex;
    font-size: .72rem;
    gap: .3rem;
}

.partner-card__status--online { color: var(--color-online); }

.status-dot {
    background: currentColor;
    border-radius: 50%;
    display: inline-block;
    height: 7px;
    width: 7px;
}

.partner-card__bio {
    color: var(--color-ink-2);
    display: -webkit-box;
    font-size: .82rem;
    line-height: 1.45;
    overflow: hidden;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
}

.partner-card__interests { display: flex; flex-wrap: wrap; gap: 5px; }
.partner-card__interests .chip { font-size: .74rem; padding: .25rem .65rem; }

.partner-card__footer {
    align-items: center;
    border-top: 1px solid var(--color-hairline);
    display: flex;
    justify-content: space-between;
    margin-top: auto;
    padding-top: .6rem;
}


/* ─── Level-check result certificate ─────────────────────────────────────── */
.result-cert {
    background: var(--color-card);
    border: 2px solid var(--color-primary);
    border-radius: 20px;
    box-shadow: var(--shadow-cert);
    margin: 0 auto;
    max-width: 380px;
    padding: 2.2rem 2rem 1.8rem;
    position: relative;
}

/* Inner dashed rule — the certificate's second border */
.result-cert::before {
    border: 1px solid var(--color-primary-border);
    border-radius: 14px;
    content: '';
    inset: 8px;
    pointer-events: none;
    position: absolute;
}

/* Corner wax stamp, handwritten */
.result-cert__stamp {
    align-items: center;
    background: var(--color-clay-tint);
    border: 2px solid var(--color-clay);
    border-radius: 50%;
    box-shadow: 0 4px 10px rgba(0, 0, 0, .1);
    color: var(--color-clay-text);
    display: flex;
    font-family: 'Caveat', 'Segoe Script', cursive;
    font-size: .85rem;
    font-weight: 600;
    height: 58px;
    justify-content: center;
    line-height: 1.15;
    position: absolute;
    right: -16px;
    text-align: center;
    top: -16px;
    transform: rotate(12deg);
    width: 58px;
    z-index: 1;
}

/* Staged reveal: levels flash in sequence, the earned level stamps in with a
   bounce, then the big letter lifts in a beat later. */
.result-cert__flash { animation: pu-lvl-flash .5s ease both; }
.result-cert__current { animation: pu-lvl-land .55s cubic-bezier(.34, 1.56, .64, 1) both; }
.result-cert__letter { animation: pu-letter-in .5s ease both .9s; font-family: 'Fraunces', Georgia, serif; font-weight: 500; }

/* ─── Unrated Banner ─────────────────────────────────────────────────────── */
.unrated-banner {
    align-items: center;
    background: var(--color-primary-tint);
    border: 1px solid var(--color-primary-border);
    border-radius: 16px;
    display: flex;
    gap: 1rem;
    margin-bottom: 1.25rem;
    padding: 1rem 1.25rem;
}

.unrated-banner__icon {
    align-items: center;
    background: var(--color-primary);
    border-radius: 50%;
    color: var(--color-on-accent);
    display: flex;
    flex-shrink: 0;
    height: 36px;
    justify-content: center;
    width: 36px;
}

.unrated-banner__text { flex: 1; font-size: .875rem; color: var(--color-ink-2); }
.unrated-banner__title { font-weight: 600; color: var(--color-ink); }

/* ─── First-run card ─────────────────────────────────────────────────────────
   Shown on Discover to a learner who has never invited anyone. Deliberately
   flat and inline — it points at the next step, it does not interrupt. */
.first-run {
    align-items: center;
    background: var(--color-primary-tint);
    border: 1px solid var(--color-primary-border);
    border-radius: 16px;
    display: flex;
    flex-wrap: wrap;
    gap: 1rem;
    margin-bottom: 1.25rem;
    padding: 1rem 1.25rem;
}

.first-run__icon {
    align-items: center;
    background: var(--color-primary);
    border-radius: 50%;
    color: var(--color-on-accent);
    display: flex;
    flex-shrink: 0;
    height: 36px;
    justify-content: center;
    width: 36px;
}

.first-run__text { flex: 1; min-width: 12rem; }
.first-run__title { color: var(--color-ink); font-size: .95rem; font-weight: 600; margin: 0; }
.first-run__sub { color: var(--color-ink-2); font-size: .875rem; margin: .15rem 0 0; }
.first-run__sub strong { color: var(--color-ink); font-weight: 600; word-break: break-all; }

/* ─── Forms ──────────────────────────────────────────────────────────────── */
.form-field { display: flex; flex-direction: column; gap: .4rem; }

.form-label {
    color: var(--color-ink-2);
    font-size: .82rem;
    font-weight: 600;
}

.form-input,
.form-select,
.form-textarea {
    background: var(--color-card);
    border: 1.5px solid var(--color-card-border);
    border-radius: var(--radius-input);
    color: var(--color-ink);
    padding: .65rem .85rem;
    transition: border-color var(--dur-quick);
    width: 100%;
}

.form-input:focus,
.form-select:focus,
.form-textarea:focus {
    border-color: var(--color-primary);
    outline: none;
    box-shadow: var(--shadow-focus); /* visible focus ring (a11y, per handoff) */
}

/* Keyboard focus ring for all interactive controls the prototype omitted */
a:focus-visible,
button:focus-visible,
.btn:focus-visible,
.nav-link:focus-visible,
.chip:focus-visible,
[role="button"]:focus-visible {
    outline: 2px solid var(--color-primary);
    outline-offset: 2px;
    border-radius: var(--radius-pill);
}

.form-textarea { min-height: 120px; resize: vertical; }

.form-error { color: var(--color-danger); font-size: .78rem; }

.form-stack { display: flex; flex-direction: column; gap: 1rem; }

/* ─── Photo uploader (profile setup + edit) ─────────────────────────────── */
.photo-uploader {
    align-items: center;
    background: var(--color-chip-bg);
    border: 2px dashed var(--color-input-border);
    border-radius: 50%;
    display: flex;
    height: 84px;
    justify-content: center;
    margin: 0 auto;
    position: relative;
    width: 84px;
}
/* The circle clip lives on the img, NOT the container (overflow: hidden
   there would shave the corner badge down to a sliver). */
.photo-uploader img { border-radius: 50%; height: 100%; object-fit: cover; width: 100%; }
.photo-uploader__placeholder { color: var(--color-muted); }

/* Onboarding variant — the prototype's larger dashed "Add a photo" circle */
.photo-uploader--lg {
    background: var(--color-card);
    border-color: var(--color-input-border);
    height: 110px;
    width: 110px;
}
.photo-uploader--lg .photo-uploader__placeholder {
    font-size: .8rem;
    line-height: 1.3;
    text-align: center;
}
.photo-uploader__badge {
    align-items: center;
    background: var(--color-primary);
    border: 2px solid var(--color-surface);
    border-radius: 50%;
    bottom: -1px;
    color: var(--color-on-accent);
    display: flex;
    height: 26px;
    justify-content: center;
    position: absolute;
    right: -1px;
    width: 26px;
}
/* Sit the badge on the larger circle's 45° edge — the bounding-box corner
   of a 110px circle is well outside the circle itself. */
.photo-uploader--lg .photo-uploader__badge {
    bottom: 3px;
    right: 3px;
}

/* ─── Auth / Onboarding containers ──────────────────────────────────────── */
.onboard-wrap {
    display: flex;
    flex-direction: column;
    margin: 0 auto;
    max-width: 420px;
    padding: 2rem 1rem;
    width: 100%;
}

.onboard-wrap--md { max-width: 520px; }
.onboard-wrap--lg { max-width: 560px; }

.onboard-back {
    align-items: center;
    color: var(--color-muted);
    display: inline-flex;
    font-size: .82rem;
    gap: .35rem;
    margin-bottom: 1.5rem;
}

.onboard-back:hover { color: var(--color-ink); }

.onboard-progress {
    align-items: center;
    display: flex;
    gap: .5rem;
    margin-bottom: 1.5rem;
}

.progress-dot {
    background: var(--color-card-border);
    border-radius: 50%;
    height: 8px;
    transition: background var(--dur-fast);
    width: 8px;
}

.progress-dot--active { background: var(--color-primary); }

/* Onboarding eyebrow — pine, and on the same 11px/700/.14em scale as the
   section eyebrows elsewhere (§1) rather than its own near-miss values.
   Pine because in the level-check flow it names the section being tested
   (Grammar / Vocabulary / Reading), which is the one bit of colour on
   an otherwise white question screen. */
.onboard-eyebrow {
    color: var(--color-primary-text);
    font-size: .6875rem;
    font-weight: 700;
    letter-spacing: .14em;
    text-transform: uppercase;
    margin-bottom: .5rem;
}

.onboard-title {
    font-size: 2.125rem; /* 34px — handoff form-title size */
    line-height: 1.15;
    margin-bottom: .5rem;
}

.onboard-sub {
    color: var(--color-muted);
    font-size: .95rem;
    margin-bottom: 1.5rem;
}

/* Logo lockup on the auth/onboarding screens */
.onboard-logo {
    display: inline-flex;
    align-items: center;
    gap: .6rem;
    margin-bottom: 1rem;
}
.onboard-logo__mark {
    background: var(--color-primary);
    border-radius: 8px;
    width: 34px;
    height: 34px;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
}
.onboard-logo__mark svg { width: 19px; height: 19px; color: var(--color-on-accent); }
.onboard-logo__name { font-family: 'Fraunces', Georgia, serif; font-size: 1.375rem; color: var(--color-ink); }

.onboard-footer {
    display: flex;
    flex-direction: column;
    gap: .65rem;
    margin-top: 1.5rem;
}

/* Sticky variant for a wizard step long enough that Continue starts below the
   fold — six categories of chips put it ~200px past the first screen, so the
   way forward was only findable by scrolling past everything. Sticks to the
   bottom with the live count beside it, so "how many more do I need" and "how
   do I go on" are both answered without leaving the chips. */
.onboard-footer--sticky {
    /* --color-paper is the page behind it: the bar has to be opaque or the
       chips scroll through the button. */
    background: var(--color-paper);
    border-top: 1px solid var(--color-hairline);
    bottom: 0;
    margin-inline: -1rem;
    /* Clears the iOS home indicator on an installed PWA. */
    padding: .75rem 1rem calc(.75rem + env(safe-area-inset-bottom));
    position: sticky;
    z-index: 1;
}

.onboard-footer__count {
    color: var(--color-ink-2);
    font-size: .8rem;
    font-weight: 600;
    text-align: center;
}

.onboard-footer__count--ready { color: var(--color-primary-text); }

.onboard-terms {
    color: var(--color-muted-2);
    font-size: .72rem;
    text-align: center;
}

/* ─── Level Check ────────────────────────────────────────────────────────── */
.quiz-header {
    background: var(--color-surface);
    border-bottom: 1px solid var(--color-hairline);
    padding: 1.25rem 1.5rem;
}

.quiz-progress-bar {
    background: var(--color-hairline);
    border-radius: var(--radius-pill);
    height: 5px;
    margin-top: .75rem;
    overflow: hidden;
}

.quiz-progress-fill {
    background: var(--color-primary);
    border-radius: var(--radius-pill);
    height: 100%;
    /* Always full width; the inline transform: scaleX() sets how much shows.
       See quiz.blade.php — this used to animate `width`, which relaid out the
       bar on every frame. */
    width: 100%;
    transform-origin: left center;
    transition: transform var(--dur-slow) var(--ease-standard);
}

.quiz-body { margin: 0 auto; max-width: 560px; padding: 2rem 1rem; }

.quiz-question {
    font-family: 'Fraunces', Georgia, serif;
    font-size: 1.6rem;
    line-height: 1.3;
    margin-bottom: 1.5rem;
}

.quiz-options { display: flex; flex-direction: column; gap: .65rem; }

.quiz-option {
    align-items: center;
    background: var(--color-card);
    border: 1.5px solid var(--color-card-border);
    border-radius: var(--radius-inner);
    cursor: pointer;
    display: flex;
    justify-content: space-between;
    padding: .85rem 1rem;
    transition: background var(--dur-quick), border-color var(--dur-quick);
    text-align: left;
    width: 100%;
    font-size: .9rem;
    color: var(--color-ink);
}

/* Hover must exclude the selected option. This selector is specificity (0,3,0)
   and --selected is (0,1,0), so without the :not() the hover state wins and a
   selected option renders as unselected — and on touch the hover sticks after
   the tap, so the choice appears not to have registered. */
.quiz-option:hover:not(:disabled):not(.quiz-option--selected) {
    background: var(--color-chip-bg);
    border-color: var(--color-card-border);
}

.quiz-option--selected {
    background: var(--color-primary-tint2);
    border-color: var(--color-primary);
    color: var(--color-primary-text);
    font-weight: 600;
}

.quiz-option:disabled { cursor: default; }

.quiz-option__check { width: 16px; height: 16px; flex-shrink: 0; }

.quiz-prompt {
    align-items: flex-start;
    background: var(--color-primary-tint2);
    border: 1px solid var(--color-primary-border);
    border-radius: var(--radius-inner);
    display: flex;
    font-size: .875rem;
    gap: .65rem;
    margin-bottom: 1rem;
    padding: .85rem 1rem;
}

.quiz-prompt svg { flex-shrink: 0; color: var(--color-primary-text); width: 18px; height: 18px; margin-top: 1px; }

.quiz-footer {
    align-items: center;
    display: flex;
    gap: .75rem;
    justify-content: space-between;
    margin-top: 1.5rem;
}

/* ─── Result Screen ──────────────────────────────────────────────────────── */
.result-level {
    font-family: 'Fraunces', Georgia, serif;
    font-size: 7.5rem;
    color: var(--color-primary-text);
    line-height: 1;
    text-align: center;
}

.cefr-scale {
    align-items: center;
    display: flex;
    gap: .4rem;
    justify-content: center;
    margin: 1rem 0;
}

.cefr-scale__item {
    border-radius: var(--radius-pill);
    font-size: .72rem;
    font-weight: 600;
    padding: .2rem .55rem;
    color: var(--color-muted-2);
}

.cefr-scale__item--active {
    background: var(--color-primary-tint);
    border: 1.5px solid var(--color-primary-border);
    color: var(--color-primary-text);
}

/* ─── Messages List ──────────────────────────────────────────────────────── */
/* The list is one stitched "postcard": a single dashed frame with the rows
   divided inside it. overflow:hidden clips the first/last row's hover fill to
   the rounded corners, so rows must not carry their own radius. */
/* Chats are hairline-separated rows, not a framed card — same treatment as the
   Discover list, so the two browse surfaces read the same way. */
.sessions-list { background: none; border: 0; border-radius: 0; }

.message-row {
    align-items: center;
    border-top: 1px solid var(--color-hairline);
    cursor: pointer;
    display: flex;
    gap: .85rem;
    padding: .85rem 0;
    transition: background var(--dur-quick);
}

.sessions-list .message-row:first-child { border-top: 0; }

.message-row:hover { background: var(--color-chip-bg); }

.message-row__body { flex: 1; min-width: 0; }
.message-row__name { align-items: center; display: flex; font-weight: 600; gap: .4rem; font-size: .9rem; overflow: hidden; white-space: nowrap; }
.message-row__name > * { flex-shrink: 0; } /* badges keep their size; the name text truncates instead of wrapping */
@media (max-width: 480px) {
    /* Secondary info on a narrow inbox row — hide rather than hard-clip mid-word */
    .message-row__name .open-to-chat-badge { display: none; }
}
.message-row__preview { color: var(--color-muted); font-size: .8rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.message-row__time { color: var(--color-muted-2); font-size: .72rem; flex-shrink: 0; }

.unread-badge {
    align-items: center;
    background: var(--color-primary);
    border-radius: var(--radius-pill);
    color: var(--color-on-accent);
    display: inline-flex;
    font-size: .68rem;
    font-weight: 700;
    height: 18px;
    justify-content: center;
    min-width: 18px;
    padding: 0 5px;
}

/* ─── Chat ───────────────────────────────────────────────────────────────── */
.chat-shell {
    display: flex;
    flex-direction: column;
    height: 100vh;
    height: 100dvh; /* dvh tracks the mobile keyboard/toolbar; bare vh leaves the composer under the keyboard */
}

.chat-header {
    align-items: center;
    background: var(--color-surface);
    border-bottom: 1px solid var(--color-hairline);
    display: flex;
    gap: .85rem;
    padding: .85rem 1.25rem;
    position: sticky;
    top: 0;
    z-index: 10;
}

.chat-header__info { flex: 1; min-width: 0; }
/* Truncate instead of wrapping: long names/locations were stacking the mobile
   header into a multi-line tower that ate a third of the screen. */
.chat-header__name { font-size: .95rem; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chat-header__meta { align-items: center; color: var(--color-muted); display: flex; font-size: .75rem; gap: .4rem; min-width: 0; }
.chat-header__meta #presence-meta { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@media (max-width: 480px) {
    .chat-header__loc { display: none; }
    /* Icon-only coach pill + tighter gaps: otherwise the pill starves the
       partner-name column down to ~74px and everything truncates. */
    .ai-coach-pill__label { display: none; }
    .chat-header { gap: .5rem; padding: .85rem 1rem; }
}
.ai-coach-pill { flex-shrink: 0; white-space: nowrap; }

.ai-coach-pill {
    align-items: center;
    background: var(--color-primary-tint);
    border: 1px solid var(--color-primary-border);
    border-radius: var(--radius-pill);
    color: var(--color-primary-text);
    display: inline-flex;
    font-size: .75rem;
    font-weight: 600;
    gap: .3rem;
    padding: .3rem .7rem;
}

.ai-coach-pill svg { width: 13px; height: 13px; }

.kebab-btn {
    align-items: center;
    background: transparent;
    border: none;
    border-radius: var(--radius-pill);
    color: var(--color-muted);
    cursor: pointer;
    display: flex;
    height: 34px;
    justify-content: center;
    padding: 0;
    width: 34px;
}

.kebab-btn:hover { background: var(--color-chip-bg); color: var(--color-ink); }

.dropdown {
    background: var(--color-card);
    border: 1px solid var(--color-card-border);
    border-radius: var(--radius-inner);
    box-shadow: var(--shadow-dropdown);
    min-width: 210px;
    overflow: hidden;
    position: absolute;
    right: 1rem;
    top: 3.5rem;
    z-index: 100;
}

.dropdown__item {
    align-items: center;
    background: transparent;
    border: none;
    border-radius: 0;
    box-shadow: none;
    color: var(--color-ink-2);
    cursor: pointer;
    display: flex;
    font-size: .875rem;
    font-weight: 500;
    gap: .65rem;
    padding: .7rem 1rem;
    text-align: left;
    transition: background var(--dur-quick);
    width: 100%;
}

.dropdown__item:hover { background: var(--color-chip-bg); }
.dropdown__item--danger { color: var(--color-danger); }
.dropdown__item--danger:hover { background: var(--color-danger-tint); }
.dropdown__item svg { width: 16px; height: 16px; }

.chat-canvas {
    background: var(--color-paper);
    flex: 1;
    overflow-y: auto;
    padding: 1.25rem 1.5rem;
}

.chat-date-divider {
    align-items: center;
    color: var(--color-muted-2);
    display: flex;
    font-size: .72rem;
    gap: .65rem;
    margin: 1rem 0;
}

.chat-date-divider::before,
.chat-date-divider::after {
    background: var(--color-hairline);
    content: '';
    flex: 1;
    height: 1px;
}

/* 18px between one speaker's turn and the next. Was 12px, which read as a
   single undifferentiated column on a phone — the gap is the only thing
   separating two messages, so it has to be big enough to be seen as a gap.
   Paired with the 8px inside a run below, that gives a 1 : 2.25 rhythm: close
   enough to group, far enough to separate. */
.bubble-wrap { display: flex; flex-direction: column; gap: .1rem; margin-bottom: 1.125rem; }
.bubble-wrap--out { align-items: flex-end; }
.bubble-wrap--in  { align-items: flex-start; }

/* Lucide's corner-up-left, inlined so the arrow needs no network and no
   element. Stroke colour inside is irrelevant — a mask reads alpha only —
   which is also why there is no hex literal here for the token guard to
   count. */
:root {
    --reply-arrow: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 20v-7a4 4 0 0 0-4-4H4'/%3E%3Cpath d='M9 14 4 9l5-5'/%3E%3C/svg%3E");
}

/* ═══ The three touch gestures ═════════════════════════════════════════════
   Right on a bubble replies, left anywhere shows the clocks, a hold opens the
   actions. Only the visuals live here; the axis lock that keeps them from
   fighting each other (and from fighting vertical scroll) is in
   conversation_detail.blade.php. */

/* The clock, revealed by dragging the thread left. Drawn from the row's own
   data-time rather than a real element: the meta span is the thing being
   hidden on touch, and an extra node per message would have to be kept in step
   across both bubble renderers. */
.bubble-wrap::after {
    color: var(--color-muted-2);
    content: attr(data-time);
    font-size: .68rem;
    font-variant-numeric: tabular-nums;
    opacity: 0;
    position: absolute;
    right: -3.4rem;
    top: 50%;
    transform: translateY(-50%);
    white-space: nowrap;
}

.bubble-wrap--showing-time::after { opacity: var(--time-reveal, 1); }

/* The reply arrow, riding in from the left as the bubble is pulled right.
   A masked shape rather than the '↩' character: a text glyph carries its own
   font metrics and sat in a visible little box against the thread. The mask
   paints in currentColor, so the arrow is pure stroke on the background with
   nothing around it — which is what makes it read as part of the gesture
   rather than as an element that appeared. */
.bubble-wrap::before {
    background: currentColor;
    color: var(--color-primary-text);
    content: '';
    height: 1.15rem;
    left: -2.1rem;
    -webkit-mask: var(--reply-arrow) center / contain no-repeat;
    mask: var(--reply-arrow) center / contain no-repeat;
    opacity: var(--reply-pull, 0);
    position: absolute;
    top: 50%;
    transform: translateY(-50%) scale(calc(.72 + .28 * var(--reply-pull, 0)));
    width: 1.15rem;
}

/* Lifted under a finger that is holding it, the moment before the menu opens.
   Nothing else about the bubble changes — the menu is the answer, this is only
   the acknowledgement that the hold registered. */
.bubble-wrap--held .bubble {
    transform: scale(1.04);
    transition: transform var(--dur-quick, .18s) var(--ease-standard, ease-out);
}

/* The rows have to be positioned for ::before/::after to hang off them, and
   the canvas has to clip so a revealed clock cannot widen the page. */
.bubble-wrap { position: relative; }

.chat-canvas {
    overflow-x: hidden;
    /* Vertical panning stays the browser's; horizontal is ours. Without this
       declaration the browser arbitrates the direction itself and can take a
       drag away part-way through, cancelling the pointer stream — which read
       as the timestamp gesture letting go at random. The recorder needed the
       same declaration for the same reason. */
    touch-action: pan-y;
}

/* Text selection is what a long press means to the browser; here it means the
   actions menu. Only on touch — a mouse still selects a message to copy it. */
@media (pointer: coarse) {
    .chat-canvas .bubble { -webkit-user-select: none; user-select: none; }
}

@media (prefers-reduced-motion: reduce) {
    .bubble-wrap--held .bubble { transform: none; }
}

/* A run from one speaker closes up. The gap between messages is what says
   "someone else is talking now", so inside a run it is carrying no meaning
   and can shrink — which is where a good part of the reclaimed height comes
   from on a phone.

   ⚠️ The tight margin belongs on the row BEFORE the grouped one, not on the
   grouped row itself. A gap is made by the preceding row's margin-bottom, so
   `.bubble-wrap--grouped { margin-bottom }` shrank the gap *below* a message
   that continues a run — which is the gap to whoever speaks next. The result
   was backwards: a run opened with a wide gap and then closed up hard against
   the other person's reply.

   Scoped to a finger deliberately. On a pointer the meta row is still on
   screen under every message, and a hairline between that row and the next
   bubble reads as broken rather than grouped.

   8px, not the 3.2px this started at. Every bubble is itself a touch target —
   press-and-hold opens the reactions, a swipe answers it — and adjacent touch
   targets need 8px between them or the wrong one takes the press. At 3.2px a
   run of short messages also lost the grouping it was meant to express and
   simply read as one block, which is what Saleh saw on his phone. */
@media (pointer: coarse) {
    .bubble-wrap:has(+ .bubble-wrap--grouped) { margin-bottom: .5rem; }
}

.bubble {
    font-size: .875rem;
    line-height: 1.55;
    max-width: 72%;
    padding: .65rem .9rem;
}

/* Incoming: neutral grey fill, no border — the fill alone separates it from
   the white page, so the list stays quiet. Radius 16 16 16 4 per spec. */
.bubble--in  {
    background: var(--color-bubble-in);
    border-radius: 16px 16px 16px 4px;
    color: var(--color-ink);
}

/* Outgoing: solid pine with white text (6.0:1). This previously used
   --color-primary-tint with white text, which rendered at 1.15:1 — the
   message was effectively invisible. Radius 16 16 4 16 per spec. */
.bubble--out {
    background: var(--color-bubble-out);
    border-radius: 16px 16px 4px 16px;
    color: var(--color-bubble-out-text);
}

/* ═══ Touch: the quiet thread ══════════════════════════════════════════════
   On a finger the meta row under every message is 21px — 22% of an average
   message — spent on a clock nobody asked for and a trigger a finger reaches
   by holding the bubble instead. A phone reclaims it; a mouse keeps it,
   because a mouse has no swipe and no long-press, and a desktop has height to
   spare.

   The mode is chosen by the HAND, not the screen: a 1024px tablet is a touch
   device and a 1280px laptop is not, so width would answer the wrong question.
   `pointer: coarse` is already the convention here — it is what gives .btn its
   44px floor further down this file.

   ⚠️ Hidden with opacity and taken out of flow, NEVER display:none. The ⋮ is
   the only route to reactions, replies and AI coaching, and display:none would
   drop it out of the accessibility tree and the tab order along with it. This
   way a keyboard or a screen reader still lands on the button and brings the
   whole row back. */
@media (pointer: coarse) {
    .bubble-wrap .bubble__meta {
        margin-top: 0;
        opacity: 0;
        pointer-events: none;
        position: absolute;
    }

    .bubble-wrap .bubble__meta:focus-within {
        margin-top: .25rem;
        opacity: 1;
        pointer-events: auto;
        position: static;
    }
}

.bubble__meta {
    align-items: center;
    /* muted-2, not faint: this is timestamp/status meta, which is what muted-2
       is documented for. faint measures 4.26:1 on white and fails AA here.
       .bubble__status-label inherits from this rule. */
    color: var(--color-muted-2);
    display: flex;
    font-size: .68rem;
    gap: .25rem;
    margin-top: .25rem;
    padding: 0 .25rem;
}

.bubble__status { align-items: center; display: inline-flex; gap: .2rem; }
.bubble__status-icon { color: var(--color-faint); display: none; height: 13px; width: 13px; }
.bubble__status[data-status="sent"] .bubble__status-icon--single { display: inline-block; }
.bubble__status[data-status="delivered"] .bubble__status-icon--double,
.bubble__status[data-status="read"] .bubble__status-icon--double { display: inline-block; }
.bubble__status[data-status="read"] .bubble__status-icon--double { color: var(--color-primary-text); }

/* ─── AI Feedback toggle ─────────────────────────────────────────────────── */
/* Per-message ask: a dashed brand pill that flips to a filled "shown" state
   once coaching has been fetched for that message. */
.ai-toggle-chip--shown {
    background: var(--color-primary) !important;
    border-style: solid !important;
    color: var(--color-on-accent) !important;
}

.ai-toggle-chip {
    align-items: center;
    background: transparent;
    border: 1px dashed var(--color-primary-border);
    border-radius: var(--radius-pill);
    color: var(--color-primary-text);
    cursor: pointer;
    display: inline-flex;
    font-size: .7rem;
    font-weight: 600;
    gap: .3rem;
    margin-top: .3rem;
    padding: .2rem .55rem;
    transition: background var(--dur-quick);
}

.ai-toggle-chip:hover { background: var(--color-primary-tint); }
.ai-toggle-chip svg { width: 12px; height: 12px; }

/* Coaching panel. Was a clay "margin note" pinned to the bubble with a wax dot
   and set in a handwriting face — postcard-era. Pine x Ink asks for a quiet
   pine panel that reads as part of the interface, so the wax pin, the rotation
   and the pu-pin-in animation are gone (§4 retires those motions) and the note
   is set in the body face. */
.ai-feedback-card {
    background: var(--color-primary-faint);
    border: 1px solid var(--color-primary-border);
    border-radius: 14px;
    font-size: .8rem;
    margin-top: .6rem;
    max-width: 72%;
    padding: .7rem .85rem;
    position: relative;
}

/* Pine "AI FEEDBACK" eyebrow with a sparkles glyph, injected by CSS so every
   render path (initial Blade, live append, re-fetch) gets it without each one
   having to remember the markup. */
.ai-feedback-card::before {
    color: var(--color-primary-text);
    content: 'AI FEEDBACK';
    display: block;
    font-size: .6875rem;
    font-weight: 700;
    letter-spacing: .14em;
    margin-bottom: .4rem;
}

/* ─── Typing indicator ───────────────────────────────────────────────────────
   Three dots in an incoming bubble, shown while the partner is typing (§4.1).
   Driven by the Reverb whisper, same signal as the header status. */
.typing-bubble[hidden] { display: none; }

.typing-bubble__dots {
    display: flex;
    gap: 4px;
    padding: .8rem .9rem;
}

.typing-bubble__dots span {
    animation: tdot 1.2s infinite;
    background: var(--color-faint);
    border-radius: 50%;
    height: 6px;
    width: 6px;
}

.typing-bubble__dots span:nth-child(2) { animation-delay: .2s; }
.typing-bubble__dots span:nth-child(3) { animation-delay: .4s; }

/* The global reduced-motion rule collapses the animation to its final frame,
   which for tdot is 30% opacity — the dots would read as broken rather than
   still. Pin them solid instead. */
@media (prefers-reduced-motion: reduce) {
    .typing-bubble__dots span { animation: none !important; opacity: 1; }
}

/* Failure notice rather than coaching: drop the eyebrow, switch to danger. */
.ai-feedback-card--error {
    background: var(--color-danger-tint);
    border-color: var(--color-danger);
}
.ai-feedback-card--error::before { content: none; }
.ai-feedback-card--error .ai-note { color: var(--color-danger); }

/* Note text — body face, ink, with the correction carrying the colour. */
.ai-note {
    color: var(--color-ink-2);
    font-size: .82rem;
    line-height: 1.5;
}
.ai-note__quote { color: var(--color-muted); text-decoration: line-through; text-decoration-thickness: 1.5px; }
.ai-note__fix { color: var(--color-primary-text); font-weight: 600; }
.ai-note__why { color: var(--color-muted); margin-top: .25rem; }

/* On wide screens the note narrows and hugs the bubble's edge, reading as a
   margin annotation rather than a full-width card. */
@media (min-width: 1024px) {
    .ai-feedback-card { max-width: 300px; }
    .bubble-wrap--out .ai-feedback-card { align-self: flex-end; }
    .bubble-wrap--in .ai-feedback-card { align-self: flex-start; }
}

.ai-feedback-card__line {
    align-items: flex-start;
    display: flex;
    gap: .5rem;
    line-height: 1.45;
    margin-bottom: .4rem;
}

.ai-feedback-card__line svg { flex-shrink: 0; margin-top: 1px; width: 14px; height: 14px; }

/* ─── Composer ───────────────────────────────────────────────────────────── */
.composer {
    align-items: flex-end;
    background: var(--color-surface);
    border-top: 1px solid var(--color-hairline);
    display: flex;
    gap: .65rem;
    /* Taller than it was, and the bottom also clears the iPhone home
       indicator — which used to crowd the row on a device with no bezel. */
    padding: 1.05rem 1.25rem calc(1.05rem + env(safe-area-inset-bottom));
}

.composer__input {
    background: var(--color-paper);
    border: 1.5px solid var(--color-card-border);
    border-radius: var(--radius-input);
    /* A textarea does not inherit the page's colour — without this it fell
       back to the UA default black on a dark background, so in dark mode you
       typed and saw nothing. Every other field already sets this. */
    color: var(--color-ink);
    flex: 1;
    /* Height is driven by autoGrowComposer(), which caps it at 40% of the
       viewport. The old fixed 120px meant a long message had to be scrolled
       inside a tiny window to re-read before sending. overflow starts hidden
       so no scrollbar flashes on a one-line message. */
    overflow-y: hidden;
    padding: .6rem .85rem;
    resize: none;
}

.composer__input::placeholder { color: var(--color-faint); }

.composer__input:focus { border-color: var(--color-primary); outline: none; }

/* 44px, not 38: it is the minimum comfortable touch target, and it matches
   the collapsed height of the textarea beside it. The row is bottom-aligned
   so the box grows downward-anchored as you type — with a shorter button that
   left the icons sitting a few pixels below the text they sit next to. Same
   height on both controls is what makes the row read as one row. */
.composer__send {
    align-items: center;
    background: var(--color-primary);
    border: none;
    border-radius: 50%;
    color: var(--color-on-accent);
    cursor: pointer;
    display: flex;
    flex-shrink: 0;
    height: 44px;
    justify-content: center;
    transition: background var(--dur-quick);
    width: 44px;
}

.composer__send:hover { background: var(--color-primary-hover); }
.composer__send svg { width: 18px; height: 18px; }

/* Replaced the wider "+ Topics" chip. Round and label-less so the row holds
   three controls at 375px and still has room for the photo button later —
   everything extra lives in its menu rather than on the row. */
.composer__plus {
    align-items: center;
    background: var(--color-chip-bg);
    border: none;
    border-radius: 50%;
    color: var(--color-ink-2);
    cursor: pointer;
    display: flex;
    flex-shrink: 0;
    height: 44px;
    justify-content: center;
    transition: background var(--dur-quick);
    width: 44px;
}

.composer__plus:hover { background: var(--color-chip-border); }
.composer__plus svg { height: 19px; width: 19px; }

/* ── Why these controls opt out of double-tap zoom ─────────────────────────
   Without touch-action, a tappable element is still a double-tap-to-zoom
   target, and the browser has to wait ~300ms after the first tap to find out
   whether a second one is coming. Two things follow, and Saleh reported both
   from his phone: the first tap on + often appeared to do nothing (it was
   being held), and tapping again — the natural response — was read as the
   double tap and zoomed the page into the button.

   `manipulation` keeps panning and pinch-zoom, and gives up only double-tap
   zoom, so the tap fires immediately. It goes on the controls rather than on
   a viewport meta tag: disabling zoom page-wide would take pinch away from
   anyone who needs it to read, which is a real accessibility loss to fix a
   button. */
.composer__plus,
.composer__send,
.bubble-menu-btn,
.bubble-image,
.reaction-picker__option,
.reaction-picker__action { touch-action: manipulation; }

/* Opens upward from the composer, which sits at the bottom of the screen. */
.composer-menu { min-width: 168px; }

/* The microphone owns its gestures: without touch-action the browser claims
   the drag for scrolling, and user-select stops a long press turning into a
   text selection — the exact conflict that broke the Discover filter. */
#composer-action[data-mode="voice"] {
    -webkit-user-select: none;
    user-select: none;
    touch-action: none;
}

/* Guarding the button alone was not enough. Holding it starts the press on
   the button but the finger slides across the transcript — "slide to cancel"
   asks for exactly that — and the selection the browser begins lands on the
   messages, not the control. So recording a note highlighted half the
   conversation in blue and popped the copy/share handles over it.
   Suppressed everywhere for as long as the recording lasts, then handed
   straight back so the transcript stays selectable the rest of the time.

   .is-pressing is the same guard raised EARLIER — synchronously on pointerdown,
   before the mic permission await that .is-recording used to sit behind. See
   window.__pressGuard in app.js. Both are kept: is-pressing covers the gesture,
   is-recording covers the tail of a locked hands-free recording. */
body.is-recording,
body.is-recording *,
body.is-pressing,
body.is-pressing * {
    -webkit-touch-callout: none;
    -webkit-user-select: none;
    user-select: none;
}

/* ── Recording ────────────────────────────────────────────────────────────
   Sits over the composer's own row, so starting a recording changes nothing
   about the page's height and the conversation above never jumps. */
.composer { position: relative; }

.composer--recording > *:not(.recorder) { visibility: hidden; }

.recorder {
    align-items: center;
    background: var(--color-surface);
    display: flex;
    gap: .6rem;
    inset: .85rem 1.25rem;
    position: absolute;
}

.recorder[hidden] { display: none; }

.recorder__dot {
    animation: recorder-pulse 1.2s ease-in-out infinite;
    background: var(--color-danger);
    border-radius: 50%;
    flex-shrink: 0;
    height: 9px;
    width: 9px;
}

@keyframes recorder-pulse {
    50% { opacity: .35; }
}

@media (prefers-reduced-motion: reduce) {
    .recorder__dot { animation: none; }
}

.recorder__time {
    color: var(--color-ink);
    font-size: .85rem;
    font-variant-numeric: tabular-nums;
}

/* The countdown turns red near the cap so a sentence is never cut off
   without warning. */
.recorder__time--warning { color: var(--color-danger); font-weight: 600; }

.recorder__hint {
    align-items: center;
    color: var(--color-faint);
    display: flex;
    flex: 1;
    font-size: .78rem;
    gap: .25rem;
    justify-content: center;
}

.recorder__hint[hidden] { display: none; }

/* Once locked the finger is off the screen, so "slide to cancel" is
   meaningless — cancelling becomes a button you can actually press. */
.recorder__cancel {
    background: none;
    border: none;
    color: var(--color-muted);
    cursor: pointer;
    font: inherit;
    font-size: .82rem;
    margin-inline-start: auto;
    padding: .35rem .5rem;
}

.recorder__cancel[hidden] { display: none; }

.recorder__stop {
    align-items: center;
    background: var(--color-primary);
    border: none;
    border-radius: 50%;
    color: var(--color-on-accent);
    cursor: pointer;
    display: flex;
    flex-shrink: 0;
    height: 38px;
    justify-content: center;
    width: 38px;
}

.recorder__stop[hidden] { display: none; }
.recorder__stop svg { height: 18px; width: 18px; }

.guided-topic-pill {
    align-items: center;
    background: var(--color-amber-tint);
    border-top: 1px solid var(--color-hairline);
    color: var(--color-amber-text);
    display: none;
    font-size: .8rem;
    font-weight: 600;
    gap: .4rem;
    padding: .55rem 1.25rem;
}
.guided-topic-pill.active { display: flex; }
.guided-topic-pill svg { width: 14px; height: 14px; flex-shrink: 0; }
.guided-topic-pill__clear {
    background: none;
    border: none;
    color: inherit;
    cursor: pointer;
    display: flex;
    margin-left: auto;
    padding: .2rem;
}

/* ─── Conversation topics modal ──────────────────────────────────────────── */
.topic-group { margin-bottom: 1rem; }
.topic-group:last-of-type { margin-bottom: 0; }
.topic-group__label {
    color: var(--color-muted);
    font-size: .72rem;
    font-weight: 700;
    letter-spacing: .02em;
    margin-bottom: .5rem;
    text-transform: uppercase;
}

.topic-row {
    background: var(--color-card);
    border: 1px solid var(--color-card-border);
    border-radius: var(--radius-inner);
    cursor: pointer;
    display: flex;
    flex-direction: column;
    gap: .2rem;
    margin-bottom: .5rem;
    padding: .7rem .9rem;
    text-align: left;
    transition: background var(--dur-quick), border-color var(--dur-quick);
    width: 100%;
}
.topic-row:hover { background: var(--color-primary-tint); border-color: var(--color-primary-border); }
.topic-row--guided { background: var(--color-amber-tint); border-color: var(--color-amber-border); }
.topic-row--guided:hover { background: var(--color-amber-tint); }
.topic-row__title { color: var(--color-ink); font-size: .85rem; font-weight: 600; }
.topic-row__opener { color: var(--color-muted); font-size: .78rem; }

/* ─── Requests ───────────────────────────────────────────────────────────── */
.tabs {
    border-bottom: 1px solid var(--color-hairline);
    display: flex;
    gap: 0;
    margin-bottom: 1.25rem;
}

.tab {
    align-items: center;
    background: transparent;
    border: none;
    border-bottom: 2.5px solid transparent;
    border-radius: 0;
    box-shadow: none;
    color: var(--color-muted);
    cursor: pointer;
    display: flex;
    font-size: .875rem;
    font-weight: 500;
    gap: .4rem;
    min-height: auto;
    padding: .75rem 1rem;
    transition: color var(--dur-quick), border-color var(--dur-quick);
}

.tab:hover { color: var(--color-ink-2); }

.tab--active {
    border-bottom-color: var(--color-primary);
    color: var(--color-primary-text);
    font-weight: 600;
}

.request-card {
    background: var(--color-card);
    border: 1.5px solid var(--color-card-border);
    border-radius: 16px;
    display: flex;
    flex-direction: column;
    gap: .85rem;
    margin-bottom: .85rem;
    padding: 1.25rem 1.35rem;
}

.request-card__header { align-items: flex-start; display: flex; gap: .85rem; }
.request-card__identity { flex: 1; min-width: 0; }
.request-card__name { font-size: .95rem; font-weight: 600; }

.request-card__meta {
    align-items: center;
    color: var(--color-muted);
    display: flex;
    flex-wrap: wrap;
    font-size: .78rem;
    gap: .4rem;
    margin-top: .2rem;
}

/* The sender's note. Previously set as a cream, italic, serif pull-quote with
   an oversized quotation mark — postcard-era styling. Pine x Ink asks for plain
   message text, so it is now quiet body copy marked only by a pine rule: one
   less filled block on the screen, and the emphasis moves to the actions. */
.request-card__message {
    border-left: 2px solid var(--color-primary-border);
    color: var(--color-ink-2);
    font-size: .9rem;
    line-height: 1.55;
    padding: .1rem 0 .1rem .85rem;
}

/* Accept / Decline split the row 50/50, per the interactive prototype */
.request-card__actions { display: flex; gap: .65rem; }
.request-card__actions form { display: flex; flex: 1; }
.request-card__actions .btn { justify-content: center; width: 100%; }

/* Sessions page → "Practice requests" entry card (invitations live under
   Sessions since Phase 2 removed their nav tab). */
/* Pine card, not a white one: this is the single promoted action on Sessions,
   so it carries the brand tint and the eyebrow leads instead of an icon tile. */
.requests-entry {
    background: var(--color-primary-tint2);
    border: 1px solid var(--color-primary-border);
    border-radius: 16px;
    color: inherit;
    display: block;
    margin-bottom: 1.5rem;
    padding: .9rem 1.1rem 1rem;
    text-decoration: none;
    transition: border-color var(--dur-quick);
}

.requests-entry:hover { border-color: var(--color-primary); }

.requests-entry__eyebrow {
    align-items: center;
    color: var(--color-primary-text);
    display: flex;
    gap: .35rem;
    margin-bottom: .55rem;
}

.requests-entry__eyebrow svg { width: 14px; height: 14px; }

.requests-entry__row { align-items: center; display: flex; gap: .75rem; }
.requests-entry__text { flex: 1; min-width: 0; }
.requests-entry__title { display: block; font-size: 1rem; font-weight: 600; }
.requests-entry__sub { color: var(--color-muted); display: block; font-size: .82rem; margin-top: .1rem; }

/* Solid pine count pill, white text — the outline variant disappeared against
   the tinted card. */
.count-pill--solid {
    background: var(--color-primary);
    border-color: var(--color-primary);
    color: var(--color-on-accent);
}

/* Requests page. Incoming and Sent are now one panel at a time, chosen by the
   segmented control, so the grid is a single column at every width. */
.requests-grid { align-items: start; display: grid; gap: 1rem; grid-template-columns: 1fr; }

.requests-section__header { align-items: center; display: flex; gap: .45rem; margin: 0 0 .75rem; }
.requests-section__header svg { height: 15px; width: 15px; }

/* ─── Top match today ────────────────────────────────────────────────────────
   The one recommendation the product makes on Discover, so it gets the only
   pine-bordered card on the page. Shown on unfiltered views only. */
.top-match {
    background: var(--color-primary-tint2);
    border: 1px solid var(--color-primary-border);
    border-radius: var(--radius-card);
    display: flex;
    flex-direction: column;
    gap: .85rem;
    margin-bottom: 1.25rem;
    padding: 1rem;
}

.top-match__eyebrow {
    align-items: center;
    color: var(--color-primary-text);
    display: flex;
    gap: .3rem;
}

.top-match__body { align-items: center; display: flex; gap: .75rem; }
.top-match__avatar { flex-shrink: 0; display: inline-flex; }

.top-match__identity {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: .35rem;
    flex: 1;
    min-width: 0;
}

.top-match__name {
    color: var(--color-ink);
    font-size: 1rem;
    font-weight: 600;
    text-decoration: none;
}

.top-match__meta {
    color: var(--color-muted);
    flex-basis: 100%;
    font-size: .8rem;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.top-match__cta { justify-content: center; width: 100%; }

/* Waiting-for-a-reply variant of the same card. A status, not a
   recommendation, so it drops the brand tint for a plain card surface and
   keeps only the shape — the slot doesn't reflow when it changes role. */
.top-match--waiting {
    background: var(--color-surface);
    border-color: var(--color-card-border);
}

.top-match__note {
    color: var(--color-muted-2);
    font-size: .8rem;
    margin: 0;
}

.top-match--waiting form { width: 100%; }

/* Match ring — the arc motif, shared with the spot illustrations. */
.match-ring {
    flex-shrink: 0;
    height: 60px;
    position: relative;
    width: 60px;
}

.match-ring svg { height: 100%; width: 100%; }

.match-ring__value {
    align-items: center;
    color: var(--color-primary-text);
    display: flex;
    font-size: .95rem;
    font-weight: 600;
    inset: 0;
    justify-content: center;
    position: absolute;
}

/* ─── Segmented control ──────────────────────────────────────────────────────
   A pill track holding two or more mutually exclusive panels. The active
   segment is a pine fill; the track is the neutral chip grey. Used for the
   Incoming/Sent split on the requests page. */
.segmented {
    background: var(--color-chip-bg);
    border-radius: var(--radius-pill);
    display: flex;
    gap: 2px;
    margin-bottom: 1rem;
    padding: 3px;
}

.segmented__item {
    align-items: center;
    background: none;
    border: 0;
    border-radius: var(--radius-pill);
    color: var(--color-muted);
    cursor: pointer;
    display: flex;
    flex: 1;
    font-family: inherit;
    font-size: .875rem;
    font-weight: 600;
    gap: .4rem;
    justify-content: center;
    min-height: 34px;
    padding: .35rem .5rem;
    transition: background var(--dur-fast) var(--ease-standard),
                color var(--dur-fast) var(--ease-standard);
}

.segmented__item--active {
    background: var(--color-primary);
    color: var(--color-on-accent);
}

/* Count rides inside the segment. On an inactive segment it sits on the grey
   track, so it fills white to stay legible as a pill; on the active segment it
   flips to a translucent white over the pine. */
.segmented__count {
    background: var(--color-card);
    border-radius: var(--radius-pill);
    color: var(--color-muted);
    font-size: .75rem;
    font-weight: 700;
    line-height: 1;
    min-width: 20px;
    padding: .2rem .4rem;
    text-align: center;
}

.segmented__item--active .segmented__count {
    background: rgb(255 255 255 / .22);
    color: var(--color-on-accent);
}

/* Section eyebrow — the small uppercase label that sits above a page title or
   introduces a group of rows. 11px/700 with wide tracking, per the type scale. */
.eyebrow,
.requests-section__eyebrow {
    color: var(--color-muted);
    font-size: .6875rem;
    font-weight: 700;
    letter-spacing: .14em;
    line-height: 1.4;
    text-transform: uppercase;
}

/* Eyebrow introducing a list rather than a page (e.g. "CHATS"). */
.eyebrow--group {
    display: block;
    margin: 1.25rem 0 .5rem;
}

/* Pine eyebrows. These sit on a tinted card and name the one promoted thing on
   the screen, so they take the brand colour rather than the neutral grey.
   They live AFTER the base .eyebrow rule on purpose: both selectors are single
   classes, so source order decides, and when these were declared earlier the
   grey silently won and the eyebrows rendered neutral. */
.eyebrow.requests-entry__eyebrow,
.eyebrow.top-match__eyebrow { color: var(--color-primary-text); }

/* The waiting card wears the same eyebrow but is a status, not a
   recommendation, so it takes the grey back. Same trap as the note above and
   the same answer: equal specificity, so it has to come AFTER the brand rule
   or it loses on source order alone. */
.eyebrow.top-match__eyebrow--muted { color: var(--color-muted); }

/* Pine count pill — "3 new". Replaces the old amber circle: a count with its
   noun reads faster than a bare number, and the action colour is pine. */
.count-pill {
    align-items: center;
    background: var(--color-primary);
    border-radius: var(--radius-pill);
    color: var(--color-on-accent);
    display: inline-flex;
    flex-shrink: 0;
    font-size: .7rem;
    font-weight: 700;
    justify-content: center;
    line-height: 1;
    padding: 5px 10px;
    white-space: nowrap;
}

/* The > affordance at the end of a tappable row. Non-text, so it may sit at
   the 3:1 step rather than the 4.5:1 one. */
.row-chevron {
    color: var(--color-faint-2);
    flex-shrink: 0;
    height: 18px;
    width: 18px;
}

.requests-empty { color: var(--color-muted); font-size: .85rem; padding: .35rem 0 .75rem; }

/* ─── Profile ────────────────────────────────────────────────────────────── */
.profile-identity-card { position: relative; }

/* Mobile-only: desktop already has its own bell in the rail, so this stays
   hidden there — shown and pinned to the card's corner under the same
   breakpoint the rail switches to a bottom tab bar. */
/* Bell + edit share the Profile top bar (§5.6). The bell is pine-on-tint2 to
   the edit button's neutral chip, so the one that can carry an unread dot is
   the one with colour. */
.profile-header__actions { align-items: center; display: flex; gap: .5rem; }

.profile-header__actions .notif-bell {
    background: var(--color-primary-tint2);
    border-color: var(--color-primary-border);
    color: var(--color-primary-text);
}

/* Centred masthead — no card, no CEFR stamp on the avatar. The level is part
   of the meta line now, so the avatar carries nothing. */
.profile-identity {
    align-items: center;
    display: flex;
    flex-direction: column;
    gap: .45rem;
    /* Tightened — the masthead was leaving too much dead space above the
       first card. Top padding removed (the bare header already sits close),
       bottom trimmed. */
    padding: 0 0 .75rem;
    text-align: center;
}

.profile-identity .avatar { margin-bottom: .15rem; }
.profile-identity__meta { color: var(--color-muted); font-size: .9rem; }

.profile-identity__info { flex: 1; }
.profile-identity__name {
    color: var(--color-ink);
    font-size: 1.75rem;
    font-weight: 600;
    line-height: 1.15;
}

.profile-identity__meta {
    align-items: center;
    color: var(--color-muted);
    display: flex;
    flex-wrap: wrap;
    font-size: .82rem;
    gap: .65rem;
    margin-top: .4rem;
}

/* Three figures split by vertical hairlines. */
.profile-stats {
    border-top: 1px solid var(--color-hairline);
    display: flex;
    margin: 0;
    padding: 1.15rem 0;
}

.profile-stat {
    align-items: center;
    border-left: 1px solid var(--color-hairline);
    display: flex;
    flex: 1;
    flex-direction: column;
    gap: .2rem;
}

.profile-stat:first-child { border-left: 0; }
.profile-stat__num { color: var(--color-ink); font-size: 1.6rem; line-height: 1; }
.profile-stat__num--accent { color: var(--color-primary-text); }

.profile-stat__label {
    color: var(--color-muted);
    font-size: .68rem;
    font-weight: 700;
    letter-spacing: .1em;
    text-transform: uppercase;
}

.profile-stats-legacy {
    display: flex;
    gap: 1.5rem;
    margin: 1rem 0;
}

.profile-stat { text-align: center; }
.profile-stat__value { font-size: 1.4rem; font-weight: 700; }
.profile-stat__label { color: var(--color-muted); font-size: .72rem; }

.level-progress-bar {
    background: var(--color-hairline);
    border-radius: var(--radius-pill);
    height: 6px;
    margin: .5rem 0;
    overflow: hidden;
}

.level-progress-fill {
    background: var(--color-primary);
    border-radius: var(--radius-pill);
    height: 100%;
}

/* ─── Modals ─────────────────────────────────────────────────────────────── */
.modal-scrim {
    align-items: center;
    /* Was a hardcoded rgba() identical to the LIGHT value of --color-scrim, so
       every modal kept a 42% near-black scrim in dark mode — barely visible
       over an already near-black page, and the modal never separated from what
       was behind it. The install sheet used the token and went to 58%.
       DesignTokenIntegrityTest could not catch this: it matched hex only. */
    background: var(--color-scrim);
    bottom: 0;
    display: flex;
    justify-content: center;
    left: 0;
    padding: 1rem;
    position: fixed;
    right: 0;
    top: 0;
    z-index: 200;
}

.modal {
    background: var(--color-card);
    border-radius: var(--radius-modal);
    box-shadow: var(--shadow-modal);
    max-width: 440px;
    padding: 1.75rem;
    width: 100%;
}

.modal--sm { max-width: 420px; }

.modal__header {
    align-items: center;
    display: flex;
    font-size: 1.05rem;
    font-weight: 700;
    gap: .7rem;
    margin-bottom: 1rem;
}

.modal__header svg { width: 20px; height: 20px; flex-shrink: 0; }
.modal__header--danger { color: var(--color-danger); }

.modal__body { color: var(--color-ink-2); font-size: .875rem; line-height: 1.55; margin-bottom: 1.25rem; }

.modal__footer { display: flex; gap: .65rem; justify-content: flex-end; }

.modal-option {
    align-items: center;
    background: var(--color-card);
    border: 1.5px solid var(--color-card-border);
    border-radius: var(--radius-inner);
    cursor: pointer;
    display: flex;
    justify-content: space-between;
    margin-bottom: .5rem;
    padding: .75rem .9rem;
    text-align: left;
    transition: background var(--dur-quick), border-color var(--dur-quick);
    width: 100%;
}

/* The radio drives its own selected state now — see the report modal. Kept
   .modal-option--selected alongside it for any option still set from JS. */
.modal-option--selected,
.report-radio:checked + .modal-option {
    background: var(--color-primary-tint);
    border-color: var(--color-primary);
    color: var(--color-primary-text);
    font-weight: 600;
}

/* The input is .sr-only, so the focus ring has to be drawn on the visible
   sibling or a keyboard user cannot see where they are in the list. */
.report-radio:focus-visible + .modal-option {
    outline: 2px solid var(--color-primary);
    outline-offset: 2px;
}

.modal-option-label {
    cursor: pointer;
    display: block;
}

.btn--primary:disabled { background: var(--color-disabled-bg); color: var(--color-disabled-text); cursor: not-allowed; transform: none; }

/* Guidance under the onboarding photo uploader — what makes a photo usable,
   not a second explanation of why it is required. */
.photo-tips {
    list-style: none;
    margin: 0 0 .5rem;
    padding: 0;
}

.photo-tips li {
    align-items: center;
    color: var(--color-ink-2);
    display: flex;
    font-size: .88rem;
    gap: .65rem;
    padding: .38rem 0;
}

.photo-tips svg { color: var(--color-primary-text); flex-shrink: 0; }

/* ── Voice notes ──────────────────────────────────────────────────────────
   Sized so a note is one line tall, the same budget a short text message
   gets — the density work would be undone by a bubble twice that height. */
.bubble--voice { min-width: 208px; }

/* ── Photo bubbles ───────────────────────────────────────────────────────
   The picture IS the bubble, so the padding goes and the image fills the
   rounded box. Padding comes back only when there is a caption under it. */
.bubble--image {
    max-width: 78%;
    overflow: hidden;
    padding: 0;
}

.bubble--image .bubble-quote {
    margin: .5rem .5rem 0;
    width: calc(100% - 1rem);
}

.bubble-image {
    /* Width and height both arrive inline, in pixels, from
       ChatImages::displayBox — NOT from a percentage plus an aspect-ratio.
       That earlier pairing resolved to zero until the image had loaded,
       because the bubble it measured against shrink-wraps its own content, and
       the thread then scrolled to a bottom that did not yet include the photo.
       Fixed pixels make the bubble's height final at insert time. */
    background: var(--color-bubble-in);
    border: 0;
    border-radius: inherit;
    cursor: pointer;
    display: block;
    overflow: hidden;
    padding: 0;

    /* The press belongs to the app, not to the OS. Without these iOS answers a
       long press with its own Save / Copy sheet, which is what put the
       reaction picker out of reach on a photo. */
    -webkit-touch-callout: none;
    user-select: none;
    -webkit-user-select: none;
}

.bubble-image img {
    display: block;
    height: 100%;
    /* The box is fitted to the picture's own shape, so `cover` has nothing to
       crop and nothing to letterbox — it is here for the one case that can
       still mismatch: an image whose sender reported no dimensions. */
    object-fit: cover;
    pointer-events: none;
    width: 100%;
}

/* While the bytes are going up. Dimmed rather than covered with a spinner:
   the percentage already lives in the meta row, and one progress signal per
   message is enough. */
.bubble-image--uploading { opacity: .55; }

/* A taken-back message. Muted and italic so it reads as an absence rather
   than as something someone said — it is a note about the conversation, not a
   line in it. */
/* The way past a step you cannot complete right now. A link, not a button:
   it must be findable without competing with the thing actually being asked
   for. Still a full-width tap target, because it is reached on a phone. */
.onboard-skip {
    background: none;
    border: 0;
    color: var(--color-muted);
    cursor: pointer;
    display: block;
    font: inherit;
    font-size: .85rem;
    margin-top: .85rem;
    min-height: 44px;
    text-decoration: underline;
    touch-action: manipulation;
    width: 100%;
}

.onboard-skip:hover { color: var(--color-ink); }

/* `display: block` above beats the browser's own `[hidden] { display: none }`,
   so without this the escape hatch is visible on the happy path — which is
   exactly what it must not be. Caught in a browser, not by a test: the second
   time this rule bit in one session (see .msg-actions__item.is-gone). */
.onboard-skip[hidden] { display: none; }

.bubble__deleted {
    align-items: center;
    display: inline-flex;
    font-style: italic;
    gap: .35rem;
    opacity: .75;
}

.bubble--deleted { font-size: .82rem; }

/* The one entry in the actions menu that cannot be undone, so it is the one
   that looks different. Colour alone would fail anyone who cannot see it —
   it is also last in the list and separated from the rest. */
.msg-actions__item--danger { color: var(--color-danger); }
.msg-actions__item--danger svg { color: var(--color-danger); }
.msg-actions__item--danger + * { margin-top: 0; }

.bubble-image__caption {
    display: block;
    padding: .5rem .9rem .65rem;
}

/* ── Full-screen photo viewer ─────────────────────────────────────────────
   Opens from the thumbnail and returns to it. The scrim is a real material
   rather than flat black: the thread stays faintly legible behind the
   picture, so it reads as a layer over the conversation instead of a
   different screen. */
.photo-viewer {
    align-items: center;
    display: flex;
    inset: 0;
    justify-content: center;
    position: fixed;
    z-index: 120;
}

.photo-viewer.hidden { display: none; }

.photo-viewer__scrim {
    background: rgb(0 0 0 / 82%);
    backdrop-filter: blur(24px) saturate(140%);
    -webkit-backdrop-filter: blur(24px) saturate(140%);
    inset: 0;
    opacity: 0;
    position: absolute;
    transition: opacity var(--dur-slow) ease;
}

.photo-viewer--in .photo-viewer__scrim { opacity: 1; }

.photo-viewer__stage {
    align-items: center;
    display: flex;
    flex-direction: column;
    gap: .75rem;
    margin: 0;
    max-height: 100%;
    padding: 3.5rem 1rem 2rem;
    position: relative;
    touch-action: none;

    /* Critically damped: it settles onto the target without overshooting,
       because opening a photo carries no momentum of its own. The drag-to-
       dismiss turns this off while the finger is down and tracks 1:1. */
    transition: transform .26s cubic-bezier(.32, .72, 0, 1), opacity .26s ease;
    will-change: transform;
}

.photo-viewer--dragging .photo-viewer__stage { transition: none; }

.photo-viewer__stage img {
    border-radius: 14px;
    display: block;
    max-height: calc(100vh - 8rem);
    max-width: 100%;
    object-fit: contain;
    -webkit-touch-callout: none;
    user-select: none;
    -webkit-user-select: none;
}

.photo-viewer__caption {
    color: var(--color-on-accent);
    font-size: .875rem;
    max-width: 40ch;
    opacity: .92;
    text-align: center;
}

.photo-viewer__close {
    align-items: center;
    background: rgb(255 255 255 / 16%);
    backdrop-filter: blur(12px);
    -webkit-backdrop-filter: blur(12px);
    border: 0;
    border-radius: 50%;
    color: var(--color-on-accent);
    cursor: pointer;
    display: flex;
    height: 36px;
    justify-content: center;
    position: absolute;
    right: 1rem;
    top: max(1rem, env(safe-area-inset-top, 0px));
    width: 36px;
    z-index: 1;
}

/* The thread must not scroll underneath an open viewer. */
body.has-photo-viewer { overflow: hidden; }

@media (prefers-reduced-motion: reduce) {
    /* The gentler equivalent, not the absence of feedback: it still arrives
       and still leaves, it just does not travel to do it. */
    .photo-viewer__stage { transition: opacity .2s ease; transform: none !important; }
    .photo-viewer__scrim { transition: opacity .2s ease; }
}

@media (prefers-reduced-transparency: reduce) {
    .photo-viewer__scrim { backdrop-filter: none; -webkit-backdrop-filter: none; background: rgb(0 0 0 / 96%); }
    /* Same colour, more of it — a reduced-transparency preference asks for a
       frostier surface, not a different one. */
    .photo-viewer__close { backdrop-filter: none; -webkit-backdrop-filter: none; background: rgb(255 255 255 / 30%); }
}

.voice {
    align-items: center;
    display: flex;
    gap: 10px;
}

.voice__play {
    align-items: center;
    background: rgb(255 255 255 / 22%);
    border: none;
    border-radius: 50%;
    color: inherit;
    cursor: pointer;
    display: flex;
    flex-shrink: 0;
    height: 30px;
    justify-content: center;
    padding: 0;
    position: relative;
    width: 30px;
}

/* The received bubble is a light surface, where a white wash is invisible. */
.bubble--in .voice__play { background: rgb(0 0 0 / 8%); }

/* The visible circle is 30px; this pads the tap area to the 44px minimum
   without adding any layout height. */
.voice__play::before {
    content: '';
    height: 44px;
    inset: 50% auto auto 50%;
    position: absolute;
    transform: translate(-50%, -50%);
    width: 44px;
}

.voice__icon svg,
.voice__play svg { height: 15px; width: 15px; }
.voice__icon[hidden] { display: none; }

.voice__wave {
    align-items: center;
    cursor: pointer;
    display: flex;
    flex: 1;
    gap: 2px;
    height: 26px;
    min-width: 0;
}

.voice__bar {
    background: currentColor;
    border-radius: 1.5px;
    flex: 1;
    min-width: 2px;
    /* Unplayed bars are faded; playback fills them in, which is the only cue
       for where you are in a note. */
    opacity: .4;
    transition: opacity var(--dur-quick) linear;
}

.voice__bar--played { opacity: 1; }

.voice__time {
    flex-shrink: 0;
    font-size: .72rem;
    font-variant-numeric: tabular-nums;
    opacity: .85;
}

/* ── A voice note while it is (or failed) being sent ──────────────────────
   The bubble appears the instant the finger lifts; the upload wears a
   determinate ring around the play control while the meta line counts the
   percent, and a lost one turns into Retry / Delete instead of vanishing. */
/* The ring IS the button's rim, not a second circle around it.
   It first sat 1.1px outside the play control, which read as two concentric
   circles glued together — and worse, it mapped "how much has uploaded" onto
   a control that means "play". While a note is uploading that circle is not a
   play button at all: it is Cancel, and the ring is its edge. One circle, one
   meaning.

   Scoped to .voice__play deliberately: the icon rule above it is
   `.voice__play svg { height: 15px; width: 15px }`, which outranks a lone
   class and silently shrank this ring to a 15px dot in the corner. Two
   classes beat one class plus a type selector. */
.voice__play .voice-upload-ring {
    height: 100%;
    left: 0;
    pointer-events: none;
    position: absolute;
    top: 0;
    width: 100%;
}

/* Cancel while uploading. Same 30px circle, so nothing in the row moves when
   the upload finishes and it becomes the play button again — only what the
   circle means changes, and its icon says so. */
.voice__play--uploading { cursor: pointer; }

/* Play and pause step aside for the duration; the X is the only thing this
   control offers while bytes are still going up. */
.voice__play--uploading .voice__icon { display: none; }

/* The class ends up ON the <svg>, not on a wrapper: lucide replaces the <i>
   placeholder and carries its attributes across. `.voice__icon-cancel svg`
   therefore matches nothing. Two classes are also needed to outrank
   `.voice__play svg`, which would otherwise size this at 15px. */
.voice__play .voice__icon-cancel { height: 11px; width: 11px; }

.voice-upload-ring circle {
    fill: none;
    stroke: currentColor;
    /* 3 units in a 36 viewBox drawn at 30px: outer edge lands exactly on the
       button's rim (r 16.5 + half of 3 = 18 = the viewBox edge). Any thinner
       and the ring floats inside the disc; any wider and it clips. */
    stroke-width: 3;
}

.voice-upload-ring__track { opacity: .25; }

.voice-upload-ring__arc {
    stroke-linecap: round;
    transition: stroke-dashoffset .2s linear;
}

/* Cancel / Retry / Delete, inline in the meta row. */
.bubble__send-action {
    background: none;
    border: none;
    color: inherit;
    cursor: pointer;
    font: inherit;
    /* Horizontal room only. The vertical half of the 44px minimum comes from
       the overlay below, because real padding here would double the height of
       a meta line that every message carries. */
    padding: 2px 6px;
    position: relative;
    text-decoration: underline;
}

/* The 44px tap target, grown upward and downward over the line rather than
   pushing it apart — same trick as .voice__play::before. Retry and Delete sit
   side by side, so this deliberately does NOT widen them: a destructive action
   whose hit area has crept under the finger aiming at Retry is worse than a
   narrow one. */
.bubble__send-action::before {
    content: '';
    height: 44px;
    inset: 50% auto auto 50%;
    position: absolute;
    transform: translate(-50%, -50%);
    width: 100%;
}

.bubble__meta--failed { color: var(--color-danger); }

/* A failed note's own bubble loses its confident brand fill — the message did
   not arrive, and a bubble that still looks sent says otherwise. */
.bubble-wrap--out:has(.bubble__meta--failed) .bubble--out {
    background: var(--color-danger-tint);
    color: var(--color-danger-hover);
}

.voice__transcript-toggle {
    background: none;
    border: none;
    color: inherit;
    cursor: pointer;
    display: block;
    font: inherit;
    font-size: .74rem;
    margin-top: .45rem;
    opacity: .85;
    padding: 0;
    text-align: start;
}

.voice__transcript {
    font-size: .82rem;
    line-height: 1.5;
    margin: .35rem 0 0;
}

.voice__transcript[hidden] { display: none; }

.voice__transcript-pending {
    display: block;
    font-size: .72rem;
    margin-top: .4rem;
    opacity: .7;
}

/* ── Replies ──────────────────────────────────────────────────────────────
   The quote sits inside the bubble, above the reply's own text, with a bar
   down its leading edge — the shape people already read as "quoted". */
.bubble-quote {
    /* Darkens the fill it sits on rather than lightening it. A white wash
       raised the surface toward the white text above it, so the quote read at
       3.45:1 in dark — the wash meant to set it apart was what made it hard to
       read. Going the other way separates it AND buys contrast. */
    background: rgb(0 0 0 / 20%);
    border: 0;
    border-inline-start: 3px solid currentColor;
    border-radius: 6px;
    /* A <button> does NOT inherit colour — the UA stylesheet hands it
       `buttontext`, which is black. `font: inherit` was here and colour was
       not, so the quote rendered near-black on the glacier fill: 4.18:1 in
       light and unreadable-looking in dark, where the eye expects the same
       white as the message under it. Same family as the composer textarea
       that fell back to black on a dark fill — an element that sets its own
       surface must state its colour; inheritance will not save a form
       control. */
    color: inherit;
    cursor: pointer;
    display: block;
    font: inherit;
    margin-bottom: .4rem;
    /* No opacity. It faded the text toward the fill on top of everything else,
       and the wash, the rule down the side and two smaller type sizes already
       say "this is quoted" without spending contrast on it. */
    padding: .3rem .5rem;
    text-align: start;
    width: 100%;
}

/* The received bubble is a light surface, so a white wash is invisible on it. */
.bubble--in .bubble-quote { background: rgb(0 0 0 / 6%); }

.bubble-quote__author {
    display: block;
    font-size: .74rem;
    font-weight: 700;
}

.bubble-quote__snippet {
    display: block;
    font-size: .78rem;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* Flashed when you tap a quote to find the original. */
@keyframes bubble-flash {
    0%, 100% { background: transparent; }
    30% { background: var(--color-primary-tint); }
}

.bubble-wrap--flash { animation: bubble-flash 1.1s ease-out; border-radius: var(--radius-inner); }

@media (prefers-reduced-motion: reduce) {
    .bubble-wrap--flash { animation: none; outline: 2px solid var(--color-primary); }
}

.reply-banner {
    align-items: center;
    background: var(--color-chip-bg);
    border-top: 1px solid var(--color-card-border);
    display: flex;
    gap: .6rem;
    padding: .5rem .85rem;
}

.reply-banner[hidden] { display: none; }

.reply-banner__body {
    border-inline-start: 3px solid var(--color-primary);
    flex: 1;
    min-width: 0;
    padding-inline-start: .55rem;
}

.reply-banner__label { color: var(--color-muted); display: block; font-size: .72rem; }

.reply-banner__snippet {
    color: var(--color-ink-2);
    display: block;
    font-size: .8rem;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.reply-banner__cancel {
    background: none;
    border: 0;
    color: var(--color-muted);
    cursor: pointer;
    flex-shrink: 0;
    padding: .25rem;
}

/* ── Message reactions ────────────────────────────────────────────────────
   The row sits under a bubble and is empty until someone reacts, so it must
   collapse to nothing rather than leave a gap on every message. */
/* Tucked INTO the bubble's bottom edge, the way iMessage does it. With a
   positive margin the chip floated in the gap below — and since a grouped run
   closes to 3px, it ended up nearer the NEXT message than the one it belongs
   to, which is the opposite of what a reaction means. Overlapping the bubble
   makes ownership unambiguous no matter how tight the run is. */
.reaction-row {
    display: flex;
    flex-wrap: wrap;
    gap: 4px;
    margin-top: -.7rem;
    /* Above the bubble it sits on, and inset from the corner so the fill still
       reads as continuous behind it. */
    padding-inline: .55rem;
    position: relative;
    z-index: 1;
}

/* The row overlaps upward, so the space it needs has to come back below it or
   the next message rides up into the chips. */
.reaction-row:not(:empty) { margin-bottom: .25rem; }

.reaction-row:empty { display: none; }

.bubble-wrap--out .reaction-row { justify-content: flex-end; }

.reaction-chip {
    align-items: center;
    background: var(--color-chip-bg);
    /* A ring in the canvas colour, so a chip lying over the bubble reads as a
       separate object on top of it instead of a hole cut into the fill. */
    box-shadow: 0 0 0 2px var(--color-surface);
    border: 1px solid var(--color-chip-border);
    border-radius: var(--radius-pill);
    cursor: pointer;
    display: inline-flex;
    font-size: .78rem;
    gap: 3px;
    line-height: 1;
    padding: 3px 8px;
}

.reaction-chip--mine {
    background: var(--color-primary-tint);
    border-color: var(--color-primary-border);
    color: var(--color-primary-text);
    font-weight: 600;
}

.reaction-chip__emoji { font-size: .9rem; }

/* Opens the actions popover. Sits inside .bubble__meta, so it adds no height
   to a message — it replaced a permanent row of chips under every bubble,
   which cost a whole line each and meant fewer messages per screen.
   A real button, not a long-press: long-press is invisible to a first-time
   user and on iOS it fights the system text-selection gesture.
   The glyph is small, but ::before pads the tap area out to the 44px minimum
   without taking any layout space. */
.bubble-menu-btn {
    background: none;
    border: none;
    border-radius: var(--radius-pill);
    color: var(--color-faint);
    cursor: pointer;
    display: inline-flex;
    margin-inline-end: 2px;
    padding: 0;
    position: relative;
    vertical-align: middle;
}

.bubble-menu-btn::before {
    content: '';
    height: 44px;
    inset: 50% auto auto 50%;
    position: absolute;
    transform: translate(-50%, -50%);
    width: 44px;
}

.bubble-menu-btn:hover { color: var(--color-primary-text); }
.bubble-menu-btn svg { height: 13px; width: 13px; }

/* Lives on <body>: #message-list scrolls, and a popover inside a scroll
   container gets clipped by it. z-index clears the composer. */
/* ── Message actions: reactions above, commands below ─────────────────────
   The two halves are separate surfaces because they are separate kinds of
   act — a reaction is one tap that lands immediately, the menu items open
   something else. Between them sits the message itself, still lit while the
   rest of the thread is dimmed, so it is always obvious what is being acted
   on.

   `.reaction-picker` below is NOT dead: the composer's + menu borrows it.
   Leave it. */
.msg-actions { inset: 0; position: fixed; z-index: 170; }
.msg-actions.hidden { display: none; }

.msg-actions__scrim {
    background: var(--color-scrim);
    backdrop-filter: blur(14px) saturate(120%);
    -webkit-backdrop-filter: blur(14px) saturate(120%);
    inset: 0;
    position: absolute;
}

/* The lifted message. `position: relative` is already on .bubble-wrap; this
   only raises it, so the one message you pressed stays sharp above the blur. */
.bubble-wrap--focused { z-index: 175; }

.msg-actions__reactions,
.msg-actions__menu {
    background: var(--color-card);
    border: 1px solid var(--color-card-border);
    box-shadow: var(--shadow-dropdown);
    position: fixed;
    z-index: 185;
}

/* A capsule, not a card: it reads as a strip of choices rather than a panel,
   which is what keeps it from looking like the menu's first row. */
.msg-actions__reactions {
    border-radius: var(--radius-pill);
    display: flex;
    gap: 2px;
    padding: 5px;
}

.msg-actions__emoji {
    background: none;
    border: none;
    border-radius: var(--radius-pill);
    cursor: pointer;
    font-size: 1.35rem;
    line-height: 1;
    /* 44px: the touch-target minimum, and six still fit across a 375px phone
       inside the capsule's padding. */
    min-height: 44px;
    min-width: 44px;
    touch-action: manipulation;
    transition: transform var(--dur-quick) var(--ease-standard),
                background var(--dur-quick) var(--ease-standard);
}

.msg-actions__emoji:active { transform: scale(.88); }

/* Which one you already chose. Reading it off the message means the picker
   answers "what did I react with?" without anyone having to remember. */
.msg-actions__emoji--mine { background: var(--color-primary-tint2); }

.msg-actions__menu {
    border-radius: var(--radius-inner);
    display: flex;
    flex-direction: column;
    min-width: 208px;
    overflow: hidden;
    padding: 5px;
}

.msg-actions__item {
    align-items: center;
    background: none;
    border: none;
    border-radius: var(--radius-inner);
    color: var(--color-ink);
    cursor: pointer;
    display: flex;
    font: inherit;
    font-size: .9rem;
    gap: .65rem;
    min-height: 46px;
    padding: 0 .7rem;
    text-align: start;
    touch-action: manipulation;
    width: 100%;
}

/* `is-gone`, not the `hidden` attribute: `display: flex` above beats the
   browser's own `[hidden] { display: none }`, so setting `hidden` left every
   entry on screen — a photo and a sentence both offered all three. The
   attribute is pinned here too so the next person to reach for it is right. */
.msg-actions__item.is-gone,
.msg-actions__item[hidden] { display: none; }

.msg-actions__item:hover { background: var(--color-chip-bg); }
.msg-actions__item:active { background: var(--color-chip-border); }
.msg-actions__item svg { color: var(--color-primary-text); height: 17px; width: 17px; }

/* The mascot sits in the same slot as a 17px glyph but reads at 24px: she is a
   face, not a symbol, and at glyph size the ears close up. Finished artwork —
   no radius, no tint. */
.msg-actions__mascot { display: block; flex-shrink: 0; height: 24px; width: 24px; }

/* Both panels grow out of the edge nearest the message (origin set inline). */
.msg-actions:not(.hidden) .msg-actions__reactions,
.msg-actions:not(.hidden) .msg-actions__menu {
    animation: msg-actions-in var(--dur-med) var(--ease-standard) both;
}

.msg-actions:not(.hidden) .msg-actions__menu { animation-delay: 30ms; }

@keyframes msg-actions-in {
    from { opacity: 0; transform: scale(.9); }
    to   { opacity: 1; transform: scale(1); }
}

.msg-actions:not(.hidden) .msg-actions__scrim {
    animation: msg-actions-scrim-in var(--dur-med) var(--ease-standard) both;
}

@keyframes msg-actions-scrim-in {
    from { opacity: 0; }
    to   { opacity: 1; }
}

body.has-msg-actions { overflow: hidden; }

/* On a mouse this is a popover, not a takeover.
   A finger commits to a long press and the whole screen answering it is
   proportionate; a click on a ⋮ button is a much smaller act, and dimming and
   blurring an entire conversation for it reads as a modal that was never
   asked for. The scrim stays — an outside click still has to close the menu —
   but it becomes invisible and stops blurring. The panels are unchanged, so
   the arrangement a learner already knows survives the change of device. */
@media (pointer: fine) {
    .msg-actions__scrim {
        backdrop-filter: none;
        -webkit-backdrop-filter: none;
        background: transparent;
    }

    /* Nothing is dimmed, so nothing needs lifting out of the dim. */
    .bubble-wrap--focused { z-index: auto; }

    /* The page does not move under a popover on desktop either, but it also
       must not lose its scrollbar and shift the layout sideways. */
    body.has-msg-actions { overflow: auto; }
}

@media (prefers-reduced-motion: reduce) {
    .msg-actions:not(.hidden) .msg-actions__reactions,
    .msg-actions:not(.hidden) .msg-actions__menu,
    .msg-actions:not(.hidden) .msg-actions__scrim { animation: none; }
}

@media (prefers-reduced-transparency: reduce) {
    .msg-actions__scrim { backdrop-filter: none; -webkit-backdrop-filter: none; }
}

.reaction-picker {
    background: var(--color-card);
    border: 1px solid var(--color-card-border);
    border-radius: var(--radius-inner);
    box-shadow: var(--shadow-dropdown);
    min-width: 208px;
    overflow: hidden;
    padding: 5px;
    position: fixed;
    z-index: 180;
}

.reaction-picker[hidden] { display: none; }

.reaction-picker__row {
    display: flex;
    gap: 2px;
    justify-content: space-between;
}

.reaction-picker__option {
    background: none;
    border: none;
    border-radius: var(--radius-pill);
    cursor: pointer;
    font-size: 1.2rem;
    line-height: 1;
    /* 40px square: the guideline minimum for a touch target, and six of them
       still fit across the narrowest phone we support. */
    min-height: 40px;
    min-width: 40px;
    transition: transform var(--dur-quick);
}

.reaction-picker__option:hover {
    background: var(--color-chip-bg);
    transform: scale(1.15);
}

.reaction-picker__actions {
    border-top: 1px solid var(--color-hairline);
    display: flex;
    flex-direction: column;
    margin-top: 4px;
    padding-top: 4px;
}

.reaction-picker__action {
    align-items: center;
    background: none;
    border: none;
    border-radius: var(--radius-inner);
    color: var(--color-ink);
    cursor: pointer;
    display: flex;
    font: inherit;
    font-size: .85rem;
    gap: .55rem;
    min-height: 44px;
    padding: 0 .6rem;
    text-align: start;
    width: 100%;
}

.reaction-picker__action:hover { background: var(--color-chip-bg); }
.reaction-picker__action svg { color: var(--color-primary-text); height: 15px; width: 15px; }

/* ── Add-to-home-screen sheet ─────────────────────────────────────────────
   A bottom sheet rather than a centred modal: it is a suggestion, not a
   decision, and a sheet reads as dismissible on the phones this targets. */
.install-sheet-scrim {
    align-items: flex-end;
    background: var(--color-scrim);
    bottom: 0;
    display: flex;
    justify-content: center;
    left: 0;
    position: fixed;
    right: 0;
    top: 0;
    z-index: 240;
}

.install-sheet-scrim[hidden] { display: none; }

/* .btn sets its own display, which outranks the UA stylesheet's
   [hidden] { display: none } — so the hidden install button rendered anyway.
   Scoped here so anything inside the sheet can be hidden by the attribute. */
.install-sheet [hidden] { display: none; }

.install-sheet {
    animation: install-sheet-rise .28s ease-out;
    background: var(--color-card);
    border-radius: var(--radius-modal) var(--radius-modal) 0 0;
    box-shadow: var(--shadow-modal);
    max-width: 440px;
    /* Clears the iOS home indicator, which otherwise sits over "Maybe later". */
    padding: .75rem 1.5rem calc(1.25rem + env(safe-area-inset-bottom));
    position: relative;
    text-align: center;
    width: 100%;
}

@media (min-width: 640px) {
    .install-sheet-scrim { align-items: center; }
    .install-sheet { border-radius: var(--radius-modal); }
}

@keyframes install-sheet-rise {
    from { transform: translateY(100%); }
    to   { transform: translateY(0); }
}

@media (prefers-reduced-motion: reduce) {
    .install-sheet { animation: none; }
}

.install-sheet__grip {
    background: var(--color-input-border);
    border-radius: var(--radius-pill);
    display: block;
    height: 4px;
    margin: 0 auto .75rem;
    width: 36px;
}

.install-sheet__close {
    background: var(--color-chip-bg);
    border: 0;
    border-radius: var(--radius-pill);
    color: var(--color-muted);
    cursor: pointer;
    display: grid;
    height: 30px;
    place-items: center;
    position: absolute;
    right: 1rem;
    top: 1rem;
    width: 30px;
}

/* No border-radius: the badge's corner is cut into the artwork, and rounding
   it again shaves the field. Size comes from the component; this only centres
   it in the sheet. */
.install-sheet__mark { margin: .5rem auto .9rem; }

.install-sheet__title { font-size: 1.3rem; margin: 0 0 .4rem; }

.install-sheet__sub {
    color: var(--color-muted);
    font-size: .88rem;
    line-height: 1.5;
    margin: 0 0 1.15rem;
}

.install-sheet__tabs {
    background: var(--color-chip-bg);
    border-radius: var(--radius-pill);
    display: flex;
    gap: .25rem;
    margin-bottom: 1rem;
    padding: .25rem;
}

.install-sheet__tab {
    background: none;
    border: 0;
    border-radius: var(--radius-pill);
    color: var(--color-muted);
    cursor: pointer;
    flex: 1;
    font-size: .85rem;
    font-weight: 600;
    padding: .55rem .5rem;
}

.install-sheet__tab[aria-selected="true"] {
    background: var(--color-primary);
    color: var(--color-on-accent);
}

/* The three reasons, above the platform steps. Icon + one line each: a learner
   deciding whether to install reads this, not the instructions. */
.install-sheet__reasons {
    display: flex;
    flex-direction: column;
    gap: .55rem;
    list-style: none;
    margin: 0 0 1rem;
    padding: 0;
    text-align: start;
    width: 100%;
}

.install-sheet__reasons li {
    align-items: center;
    color: var(--color-ink-2);
    display: flex;
    font-size: .85rem;
    gap: .6rem;
}

.install-sheet__reasons svg {
    color: var(--color-primary-text);
    flex: 0 0 auto;
}

.install-steps {
    list-style: none;
    margin: 0 0 1.15rem;
    padding: 0;
    text-align: left;
}

.install-steps[hidden] { display: none; }

.install-steps li {
    align-items: center;
    color: var(--color-ink-2);
    display: flex;
    font-size: .88rem;
    gap: .6rem;
    padding: .4rem 0;
}

.install-steps__n {
    background: var(--color-primary-tint);
    border-radius: var(--radius-pill);
    color: var(--color-primary-text);
    display: grid;
    flex-shrink: 0;
    font-size: .72rem;
    font-weight: 700;
    height: 21px;
    place-items: center;
    width: 21px;
}

.install-sheet__later {
    background: none;
    border: 0;
    color: var(--color-muted);
    cursor: pointer;
    font-size: .85rem;
    font-weight: 600;
    margin-top: .85rem;
    padding: .35rem;
    width: 100%;
}

.sayhi-header { align-items: center; display: flex; gap: .85rem; margin-bottom: .35rem; }
.sayhi-header__title { font-size: 1.4rem; }
.sayhi-header__sub { color: var(--color-muted); font-size: .82rem; margin: .8rem 0 1rem; }

.sayhi-textarea { min-height: 108px; resize: none; }

.sayhi-suggested {
    background: var(--color-primary-tint);
    border: 1.5px solid var(--color-input-border);
    border-radius: var(--radius-inner);
    cursor: pointer;
    margin-top: .85rem;
    padding: .75rem .9rem;
    text-align: left;
    width: 100%;
}
.sayhi-suggested:hover { background: var(--color-primary-tint); }
.sayhi-suggested__label {
    align-items: center;
    color: var(--color-primary-text);
    display: flex;
    font-size: .75rem;
    font-weight: 600;
    gap: .35rem;
    margin-bottom: .3rem;
}
.sayhi-suggested__text { color: var(--color-ink-2); font-size: .85rem; line-height: 1.45; }

/* ─── Empty State ────────────────────────────────────────────────────────── */
.empty-state {
    align-items: center;
    color: var(--color-muted);
    display: flex;
    flex-direction: column;
    gap: .85rem;
    padding: 3rem 1.5rem;
    text-align: center;
}

/* Bare glyphs stay small; a spot illustration sizes itself (see .spot). */
.empty-state svg:not(.spot) { color: var(--color-faint); height: 40px; width: 40px; }
.empty-state h3 { color: var(--color-ink-2); font-size: 1rem; font-weight: 600; }
.empty-state p  { font-size: .85rem; max-width: 280px; }

/* Spot illustration — decorative art for empty states. Colours come entirely
   from tokens (see the spot-illustration component), so it re-skins with the
   rest of the product instead of needing to be re-exported. */
.spot {
    display: block;
    height: auto;
    margin-bottom: .25rem;
    max-width: 45%;
    width: 132px;
}

/* ─── Toast notification ─────────────────────────────────────────────────── */
.toast-notification {
    position: fixed;
    top: 78px;
    left: 50%;
    transform: translate(-50%, -12px);
    z-index: 60;
    display: flex;
    align-items: center;
    gap: 11px;
    /* Ink pill, white text — §5.10. Was a white card with a hairline; the
       prototype's success/error toast is a solid dark pill so it reads as a
       transient status over any screen rather than as another card. */
    background: var(--color-ink);
    /* --color-paper, NOT --color-on-accent. on-accent is white in both themes
       while ink flips to near-white in dark, so this pill rendered white on
       white (~1.05:1) and every toast was invisible in dark mode. paper flips
       together with ink, so the pair holds either way. Exactly the bug already
       fixed on .pill-new — see the note there. */
    color: var(--color-paper);
    padding: 10px 20px 10px 11px;
    border-radius: var(--radius-pill);
    box-shadow: var(--shadow-toast);
    font-size: .90625rem;
    font-weight: 500;
    white-space: nowrap;
    visibility: hidden;
    pointer-events: none;
    transition: transform var(--dur-med) ease;
}

.toast-notification.toast-notification--error .toast-notification__disc {
    background: var(--color-danger);
}

.toast-notification__disc {
    width: 23px;
    height: 23px;
    border-radius: 50%;
    background: var(--color-primary);
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
}

/* ─── Utility ────────────────────────────────────────────────────────────── */
.icon-xs { width: 13px; height: 13px; flex-shrink: 0; }
.icon-sm { width: 16px; height: 16px; flex-shrink: 0; }
.icon-md { width: 20px; height: 20px; flex-shrink: 0; }
.icon-lg { width: 24px; height: 24px; flex-shrink: 0; }

.text-muted  { color: var(--color-muted); }
.text-faint  { color: var(--color-faint); }
.text-primary{ color: var(--color-primary-text); }
.text-danger { color: var(--color-danger); }
.text-sm     { font-size: .82rem; }
.text-xs     { font-size: .72rem; }
.font-600    { font-weight: 600; }
.font-700    { font-weight: 700; }

.flex        { display: flex; }
.items-center{ align-items: center; }
.gap-2       { gap: .5rem; }
.gap-3       { gap: .75rem; }
.mt-1        { margin-top: .25rem; }
.mt-2        { margin-top: .5rem; }
.mt-3        { margin-top: .75rem; }
.mb-1        { margin-bottom: .25rem; }
.hidden      { display: none; }

/* ─── Skeleton loaders ───────────────────────────────────────────────────── */
@keyframes shimmer {
    0%   { background-position: -400px 0; }
    100% { background-position: 400px 0; }
}

.skeleton {
    animation: shimmer 1.4s infinite linear;
    background: linear-gradient(90deg, var(--color-skeleton-base) 25%, var(--color-skeleton-sheen) 50%, var(--color-skeleton-base) 75%);
    background-size: 800px 100%;
    border-radius: 6px;
}

.skeleton-card {
    background: var(--color-card);
    border: 1px solid var(--color-card-border);
    border-radius: var(--radius-card);
    padding: 1.375rem;
}

/* ─── Filter bar chip overrides (white bg, taller) ──────────────────────── */
.filter-bar .chip {
    background: var(--color-card);
    border-color: var(--color-card-border);
    color: var(--color-ink-2);
    padding: .5rem .9rem;
}
.filter-bar .chip--dropdown { background: var(--color-card); border: 1.5px solid var(--color-card-border); padding: .5rem .9rem; }

/* Active filter = solid pine, per §4.4 — a tint was too close to the inactive
   white to read as "this filter is on" at a glance. */
.filter-bar .chip--active {
    background: var(--color-primary);
    border-color: var(--color-primary);
    color: var(--color-on-accent);
}

/* Tab badge sits inline (not pushed right) */
.tab .nav-badge { margin-left: 0; }

/* ─── Notifications (bell, panel, push toast) ───────────────────────────── */
.notif-bell-wrap { position: static; }

.notif-bell {
    align-items: center;
    background: none;
    border: none;
    border-radius: 10px;
    color: var(--color-muted);
    cursor: pointer;
    display: flex;
    justify-content: center;
    position: relative;
}
.notif-bell:hover { background: var(--color-chip-bg); color: var(--color-ink); }
.notif-bell--rail { height: 34px; width: 34px; }
.notif-bell--topbar {
    background: var(--color-card);
    border: 1px solid var(--color-card-border);
    border-radius: 50%;
    height: 36px;
    width: 36px;
}

/* Gentle ring while unread */
.notif-bell--unread svg { animation: pu-bell-ring 5s ease-in-out infinite; transform-origin: 50% 15%; }

.notif-badge {
    align-items: center;
    background: var(--color-clay);
    border: 2px solid var(--color-surface);
    border-radius: var(--radius-pill);
    box-sizing: border-box;
    /* paper, not on-accent. Clay stays warm in both themes but lightens in
       dark, where white on it measures 2.74:1 — an unread count nobody can
       read. paper flips to near-black there (6.6:1) and stays white in light,
       where clay is dark enough for it (4.5:1). Third instance of this pair
       being wrong; see .pill-new and .toast-notification. */
    color: var(--color-paper);
    display: flex;
    font-size: .62rem;
    font-weight: 700;
    height: 16px;
    justify-content: center;
    min-width: 16px;
    padding: 0 3px;
    position: absolute;
    right: -2px;
    top: -2px;
}

.notif-panel {
    background: var(--color-card);
    border: 1px solid var(--color-hairline);
    border-radius: var(--radius-modal);
    box-shadow: var(--shadow-modal);
    max-height: 460px;
    overflow: hidden;
    position: fixed;
    width: 344px;
    z-index: 200;
}

.notif-panel__header {
    align-items: center;
    border-bottom: 1px solid var(--color-hairline);
    display: flex;
    justify-content: space-between;
    padding: .95rem 1.1rem;
}
.notif-panel__title { color: var(--color-ink); font-size: 1.3rem; }
.notif-panel__mark-all { background: none; border: none; color: var(--color-primary-text); cursor: pointer; font-size: .8rem; font-weight: 600; }

.notif-panel__list { max-height: 392px; overflow-y: auto; padding: 6px; }
.notif-panel__empty { color: var(--color-muted); font-size: .85rem; padding: 1.5rem 1rem; text-align: center; }

.notif-row {
    align-items: flex-start;
    border-radius: 12px;
    color: inherit;
    display: flex;
    gap: 11px;
    padding: 11px 10px;
    text-decoration: none;
}
.notif-row:hover { background: var(--color-chip-bg); }
.notif-row--unread { background: var(--color-primary-faint); }

/* Type icon — a 40px tinted circle per notification kind, so the type reads
   before the text does. Accent groups come from tokens.css via [data-accent],
   which keeps the palette re-skinnable and the names semantic. */
.notif-row__icon {
    align-items: center;
    border-radius: 50%;
    display: flex;
    flex-shrink: 0;
    height: 40px;
    justify-content: center;
    width: 40px;
}

.notif-row__icon svg { height: 18px; width: 18px; }

.notif-row__icon[data-accent="message"]  { background: var(--acc-message-tint);  color: var(--acc-message); }
.notif-row__icon[data-accent="streak"]   { background: var(--acc-streak-tint);   color: var(--acc-streak); }
.notif-row__icon[data-accent="success"]  { background: var(--acc-success-tint);  color: var(--acc-success); }
.notif-row__icon[data-accent="safety"]   { background: var(--acc-safety-tint);   color: var(--acc-safety); }
.notif-row--unread:hover { background: var(--color-primary-tint); }
.notif-row__body { flex: 1; min-width: 0; }
.notif-row__line { color: var(--color-ink); font-size: .875rem; line-height: 1.4; }
.notif-row__preview { color: var(--color-muted); display: block; font-size: .8rem; line-height: 1.4; margin-top: 2px; }
.notif-row__time { color: var(--color-muted-2); display: block; font-size: .72rem; margin-top: 4px; }
/* Unread marker: pine, 8px — the prototype's value. It was clay, which read as
   a fifth notification type rather than as a state. */
.notif-row__dot { background: var(--color-primary); border-radius: 50%; flex-shrink: 0; height: 8px; margin-top: 6px; width: 8px; }

/* The panel reads as a stack of dashed-divided notes, not a data table */
.notif-panel__list .notif-row:not(:last-child) { border-bottom: 1px solid var(--color-hairline); border-radius: 0; }

@keyframes notifPushIn { from { transform: translateY(-8px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }

/* Live in-app notification (§5.10): white card, toast shadow, type-coloured
   icon. Was a clay-bordered card with a clay-tinted shadow and a single
   envelope glyph — postcard-era, and it made every kind look the same. */
.notif-push {
    align-items: flex-start;
    animation: notifPushIn .22s ease;
    background: var(--color-card);
    border: 1px solid var(--color-card-border);
    border-radius: 16px;
    box-shadow: var(--shadow-toast);
    display: flex;
    gap: 12px;
    padding: 12px 14px;
    position: fixed;
    right: 24px;
    top: 24px;
    width: 336px;
    z-index: 300;
}

/* Same 40px tinted circle as the panel rows — accent groups from tokens.css. */
.notif-push__icon {
    align-items: center;
    border-radius: 50%;
    display: flex;
    flex-shrink: 0;
    height: 40px;
    justify-content: center;
    width: 40px;
}
.notif-push__icon svg { height: 18px; width: 18px; }
.notif-push__icon[data-accent="message"] { background: var(--acc-message-tint); color: var(--acc-message); }
.notif-push__icon[data-accent="streak"]  { background: var(--acc-streak-tint);  color: var(--acc-streak); }
.notif-push__icon[data-accent="success"] { background: var(--acc-success-tint); color: var(--acc-success); }
.notif-push__icon[data-accent="safety"]  { background: var(--acc-safety-tint);  color: var(--acc-safety); }
.notif-push__body { flex: 1; min-width: 0; }
.notif-push__line { align-items: flex-start; display: flex; gap: 8px; justify-content: space-between; }
.notif-push__text { color: var(--color-ink); font-size: .875rem; line-height: 1.35; }
.notif-push__close { background: none; border: none; color: var(--color-faint); cursor: pointer; flex-shrink: 0; font-size: 1.1rem; line-height: 1; }
.notif-push__preview { color: var(--color-ink-2); font-size: .82rem; line-height: 1.45; margin-top: 3px; }
.notif-push__footer { align-items: center; color: var(--color-muted-2); display: flex; font-size: .68rem; gap: 5px; margin-top: 7px; }
.notif-push__footer-dot { background: var(--color-online); border-radius: 50%; height: 6px; width: 6px; }

/* ─── Responsive — mobile ────────────────────────────────────────────────── */
@media (max-width: 768px) {
    /* App shell: topbar row + scrollable main */
    .app-shell {
        grid-template-columns: 1fr;
        grid-template-rows: auto 1fr;
        height: 100vh;
        /* Must re-declare dvh here: this later 100vh would otherwise win the
           cascade over the base .app-shell dvh fix, which is exactly what
           broke fixed header/footer + keyboard layout on mobile. */
        height: 100dvh;
        overflow: hidden;
    }

    /* The mobile brand bar is gone — see navigation.blade.php. */

    .rail .rail__logo,
    .streak-card,
    .rail__footer { display: none; }

    /* Edge-to-edge sticky bottom bar: white, one hairline on top, no shadow and
       no floating pill. The bar itself recedes so the active tab's hue is the
       only thing carrying colour down here. padding-bottom picks up the iOS
       home-indicator inset so the tabs aren't sitting under it. */
    .rail {
        background: var(--color-surface);
        border: 0;
        border-top: 1px solid var(--color-hairline);
        border-radius: 0;
        bottom: 0;
        box-shadow: none;
        display: flex;
        flex-direction: row;
        height: auto;
        left: 0;
        min-height: auto;
        /* Extra bottom clearance so the tap targets sit well above the iOS
           home-indicator swipe zone (0.75rem on top of the safe-area inset),
           and a touch more top padding for a taller, comfier bar. */
        padding: .5rem .25rem calc(.75rem + env(safe-area-inset-bottom));
        position: fixed;
        right: 0;
        top: auto;
        transform: none;
        width: 100%;
        max-width: none;
        z-index: 40;
    }

    /* ── Expanding-label tab bar ──────────────────────────────────────────
       Inactive tabs are icon-only; the active tab expands into a pine-tint
       pill that reveals its label. This treatment uses ONE pine active tint —
       the per-tab hues (blue/pine/purple) are set aside for it. */
    .nav {
        align-items: center;
        flex-direction: row;
        gap: 6px;
        justify-content: center;
        width: 100%;
    }

    .nav-link {
        align-items: center;
        background: none;
        border-radius: var(--radius-pill);
        color: var(--color-muted-2);
        flex: 0 0 auto;
        flex-direction: row;
        /* 13px, tight tracking — reads as crisp and high-resolution rather than
           chunky. Pairs with the 21px stroke-2 icon below. */
        font-size: .8125rem;
        font-weight: 600;
        letter-spacing: -.01em;
        gap: 8px;
        justify-content: center;
        min-height: 54px;
        min-width: 0;
        padding: 0 15px;
        position: relative;
        transition: background var(--dur-med) var(--ease-standard),
                    color var(--dur-med) var(--ease-standard),
                    padding var(--dur-med) var(--ease-standard);
    }

    /* The pill wrapper collapses to a plain inline icon holder — the whole
       link is the pill in this treatment. */
    .nav-link__pill {
        align-items: center;
        background: none;
        border-radius: 0;
        display: inline-flex;
        height: auto;
        justify-content: center;
        width: auto;
    }

    /* Label rides collapsed until the tab is active, then expands. */
    .nav-link__label {
        max-width: 0;
        opacity: 0;
        overflow: hidden;
        white-space: nowrap;
        transition: max-width var(--dur-med) var(--ease-standard),
                    opacity var(--dur-med) var(--ease-standard);
    }

    .nav-link--mobile-only { display: flex; }

    /* Active tab: pine-tint pill, pine icon + label. Three explicit [data-nav]
       selectors so this beats the desktop per-hue rules on specificity. */
    .nav-link.active[data-nav="sessions"],
    .nav-link.active[data-nav="discover"],
    .nav-link.active[data-nav="profile"] {
        background: var(--color-primary-tint);
        color: var(--color-primary-text);
        padding: 0 20px;
    }
    .nav-link.active[data-nav] .nav-link__pill { background: none; }
    .nav-link.active .nav-link__label { max-width: 120px; opacity: 1; }

    /* Hover fill on a touch bar just leaves a stuck highlight after a tap. */
    .nav-link:hover { background: none; color: var(--color-muted-2); }
    .nav-link.active[data-nav]:hover { background: var(--color-primary-tint); color: var(--color-primary-text); }

    /* 21px, stroke 2 — a hair smaller and finer than before for a higher-res
       feel. The count badge is anchored to this icon by the base
       .nav-link__pill .nav-badge rule (no mobile-specific override needed). */
    .nav-link svg { width: 21px; height: 21px; }

    /* Main area — clears the taller sticky bottom bar plus the iOS
       home-indicator inset. */
    .page {
        overflow-y: auto;
        padding-bottom: calc(88px + env(safe-area-inset-bottom));
        height: 100%;
    }

    .page-body   { --page-gutter: 1rem; --page-gutter-y: 1rem; padding: var(--page-gutter-y) var(--page-gutter); }
    .page-header { padding: .85rem 1rem; flex-wrap: wrap; row-gap: .65rem; }
    .page-header__title { font-size: 1.6rem; }

    /* Discover: search drops to its own full-width row; the count reads as a
       full sentence under the title. */
    .discover-search { flex: 1 1 100%; margin-left: 0; }
    .discover-search form,
    .discover-search .search-box { width: 100%; }

    /* Stat cards stay icon-left (icon, then number above label) — just more
       compact so all three fit a row without sideways scroll. */
    .profile-stats { gap: .6rem; }
    .stat-card { gap: .6rem; padding: .85rem .9rem; }
    .stat-card__num { font-size: 1.35rem; }
    .stat-card__label { font-size: .7rem; }

    /* Chat is full-height on mobile: no top bar, no floating nav pill — just the
       chat's own header (with its back arrow) + composer (per design). */
    .app-shell--chat .rail { display: none; }
    .app-shell--chat .page { padding-bottom: 0; }

    /* Onboarding renders no nav at all, so the page needs no clearance for it. */
    .app-shell--onboarding .page { padding-bottom: 1.5rem; }

    /* 2 cards per row on wider mobile/tablet */
    .partner-grid { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }

    .bubble { max-width: 88%; }

    .onboard-wrap { padding: 1.5rem 1rem; }

    /* iOS Safari auto-zooms on focus for any input/textarea under 16px;
       the composer inherits the app's 15px body size otherwise. */
    .composer__input { font-size: 16px; }
}

@media (max-width: 480px) {
    .partner-grid { grid-template-columns: 1fr; }
    .profile-stats { gap: 1rem; }
    .request-card__actions { flex-direction: column; }
    .request-card__actions .btn { width: 100%; }
}


/* ─── Accessibility: reduced motion ──────────────────────────────────────── */
/* Jump entry animations (postcard stamps, notification slide-ins, …) straight
   to their settled end state for users who prefer reduced motion. */
@media (prefers-reduced-motion: reduce) {
    *, *::before, *::after {
        animation-delay: 0s !important;
        animation-duration: .01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: .01ms !important;
    }
}

/* ═══ Press feedback ═══════════════════════════════════════════════════════
   MUST STAY LAST IN THIS FILE. These rules deliberately sit at the end of the
   cascade: .btn declares its own transition around line 730 and
   .btn--primary:hover carries the same specificity as .btn:active, so anything
   written earlier is silently overridden and :active never lands on desktop.

   Vanda is a phone app whose feedback was, until now, entirely bound to
   :hover — a state a finger can never produce. The only thing acknowledging a
   tap was the browser's own highlight, a flat system blue we never chose.

   Compact controls take a scale; wide rows take a tint, because scaling
   something the width of the screen reads as rubbery rather than responsive.
   Down is faster than up — see --dur-press / --dur-release in tokens.css.
   ═══════════════════════════════════════════════════════════════════════════ */

.btn, .chip, .modal-option, .reaction-picker__option, .reaction-picker__action,
.composer__plus, .o2c-option, .o2c-select, .iconbtn, .kebab-btn, .notif-bell,
.bubble-menu-btn, .rail__gear-btn, .segmented__item, .who-trigger,
.theme-toggle button, .profile-section__action, .message-row, .requests-entry,
.card--hover, .nav-link, .settings-row, .partner-row {
    /* We supply our own feedback now, so the stock flash is noise on top. */
    -webkit-tap-highlight-color: transparent;
}

/* Releasing settles rather than snapping back. */
.btn, .chip, .modal-option, .reaction-picker__option, .reaction-picker__action,
.composer__plus, .o2c-option, .o2c-select, .iconbtn, .kebab-btn, .notif-bell,
.bubble-menu-btn, .rail__gear-btn, .segmented__item, .who-trigger,
.theme-toggle button, .profile-section__action {
    transition: transform var(--dur-release) var(--ease-standard),
                background var(--dur-release) var(--ease-standard);
}

.btn:active, .chip:active, .modal-option:active, .reaction-picker__option:active,
.reaction-picker__action:active, .composer__plus:active, .o2c-option:active,
.o2c-select:active, .iconbtn:active, .kebab-btn:active, .notif-bell:active,
.bubble-menu-btn:active, .rail__gear-btn:active, .segmented__item:active,
.who-trigger:active, .theme-toggle button:active,
.profile-section__action:active {
    transform: scale(.96);
    transition: transform var(--dur-press) var(--ease-standard);
}

/* Wide surfaces: tint, don't scale. */
.message-row:active, .requests-entry:active, .card--hover:active,
.nav-link:active, .settings-row:active, .partner-row:active {
    background: var(--color-chip-bg);
    transition: background var(--dur-press) var(--ease-standard);
}

/* Reduced motion keeps the acknowledgement and drops only the movement — a
   press with no feedback at all would be a regression for these users. */
@media (prefers-reduced-motion: reduce) {
    .btn:active, .chip:active, .modal-option:active,
    .reaction-picker__option:active, .reaction-picker__action:active,
    .composer__plus:active, .o2c-option:active, .o2c-select:active,
    .iconbtn:active, .kebab-btn:active, .notif-bell:active,
    .bubble-menu-btn:active, .rail__gear-btn:active, .segmented__item:active,
    .who-trigger:active, .theme-toggle button:active,
    .profile-section__action:active {
        transform: none;
        background: var(--color-chip-bg);
    }
}

/* ═══ Touch targets ════════════════════════════════════════════════════════
   A sweep of the running app found ~20 controls under 44x44, the worst being
   the per-message actions trigger at 13x13.

   The fix is a transparent ::after that grows the HIT area without touching
   the visual size or the layout — icons stay the size they were drawn, rows
   keep their height, nothing reflows. Only .btn gets a real min-height, and
   only on coarse pointers, so the desktop keeps its compact buttons.
   ═══════════════════════════════════════════════════════════════════════════ */

.bubble-menu-btn::after,
.kebab-btn::after,
.notif-bell::after,
.rail__gear-btn::after,
.profile-section__action::after,
.onboard-back::after,
.hit-target::after {
    content: '';
    position: absolute;
    /* -16, not -15: the smallest of these is the 13x13 message-actions trigger,
       and -15 landed it on 43x43 — one pixel short of the floor. */
    inset: -16px;
}

/* ::after needs a positioned parent. These four are static today; the other
   two already declare position: relative and are left alone. */
.kebab-btn,
.rail__gear-btn,
.profile-section__action,
.onboard-back,
.hit-target {
    position: relative;
}

/* An inline text link in a form footer is ~17-20px tall. Expand it vertically
   only — growing it sideways would swallow the words next to it.
   .onboard-back is already full-width, so it only ever needed the height. */
.hit-target::after,
.onboard-back::after {
    inset: -14px -4px;
}

@media (pointer: coarse) {
    /* 44px is the floor for a finger. Kept off precise pointers so the
       desktop's smaller buttons — including .btn--sm — stay as designed. */
    .btn { min-height: 44px; }
    .segmented__item { min-height: 44px; }
}

/* ═══ Motion ═══════════════════════════════════════════════════════════════
   Character: critically damped, no overshoot. --ease-standard is a decelerate
   curve with no bounce and every animation here uses it. Bounce was considered
   and rejected — these fire on screens opened dozens of times a day.

   Everything below animates transform and opacity only.
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── A message arriving ──────────────────────────────────────────────────
   Rises into place while the thread eases down, so it reads as coming from
   below rather than being spliced in. See appendMessage() in the chat view. */
.bubble-enter {
    opacity: 0;
    transform: translateY(10px);
}

.bubble-enter--settled {
    opacity: 1;
    transform: none;
    transition: opacity var(--dur-slow) var(--ease-standard),
                transform var(--dur-slow) var(--ease-standard);
}

/* ── Sheets leave the way they arrived ───────────────────────────────────
   The install sheet used to rise over 280ms and then vanish in a single frame
   via `hidden = true`. Enter and exit now travel the same path. */
.install-sheet--leaving {
    animation: install-sheet-fall var(--dur-slow) var(--ease-standard) forwards;
}

.install-sheet-scrim--leaving {
    animation: scrim-fade var(--dur-slow) var(--ease-standard) forwards;
}

@keyframes install-sheet-fall {
    from { transform: translateY(0); }
    to   { transform: translateY(100%); }
}

@keyframes scrim-fade {
    from { opacity: 1; }
    to   { opacity: 0; }
}

/* ── Popovers grow from what opened them ─────────────────────────────────
   transform-origin is set inline at open time to the trigger's position, so
   the link between the button you pressed and the panel that appeared is
   visible rather than implied. */
.reaction-picker,
.composer-menu {
    animation: popover-in var(--dur-med) var(--ease-standard);
}

@keyframes popover-in {
    from { opacity: 0; transform: scale(.92); }
    to   { opacity: 1; transform: scale(1); }
}

.modal-scrim { animation: scrim-in var(--dur-med) var(--ease-standard); }
.modal-scrim .modal { animation: modal-in var(--dur-med) var(--ease-standard); }

.modal-scrim--leaving { animation: scrim-fade var(--dur-med) var(--ease-standard) forwards; }
.modal-scrim--leaving .modal { animation: modal-out var(--dur-med) var(--ease-standard) forwards; }

@keyframes scrim-in  { from { opacity: 0; } to { opacity: 1; } }
@keyframes modal-in  { from { opacity: 0; transform: scale(.96); } to { opacity: 1; transform: none; } }
@keyframes modal-out { from { opacity: 1; transform: none; } to { opacity: 0; transform: scale(.96); } }

/* ── List reveal ─────────────────────────────────────────────────────────
   Rows fade up one after another on first paint. --i is set per row by the
   stagger initialiser; the cap keeps a long list from crawling in. */
.stagger-in > * {
    opacity: 0;
    transform: translateY(8px);
    animation: stagger-row var(--dur-slow) var(--ease-standard) forwards;
    animation-delay: calc(var(--i, 0) * var(--stagger-step));
}

@keyframes stagger-row {
    to { opacity: 1; transform: none; }
}

/* A conversation arriving in a list that is already on screen. Deliberately
   NOT `forwards`: the row's resting state must be its own real style, so there
   is nothing for it to fall back out of on a later style recalculation. That
   is the failure `.stagger-in` used to have while it stayed on the container
   for the life of the page. */
.message-row--entering {
    animation: row-enter var(--dur-slow) var(--ease-standard);
}

@keyframes row-enter {
    from { opacity: 0; transform: translateY(-6px); }
}

/* ── Recorder: slide-to-cancel becomes a dial ────────────────────────────
   --slide is written on each pointermove as 0 → 1 progress toward the cancel
   threshold, so the hint fades and the row drifts with the finger instead of
   the gesture being invisible until it fires. */
.recorder__hint {
    opacity: calc(1 - (var(--slide, 0) * .8));
    transform: translateX(calc(var(--slide, 0) * -22px));
}

.recorder__dot,
.recorder__time {
    transform: translateX(calc(var(--slide, 0) * -10px));
}

.recorder--arming .recorder__hint { color: var(--color-danger); }

@media (prefers-reduced-motion: reduce) {
    /* Keep every state change, drop every movement. */
    .bubble-enter,
    .bubble-enter--settled { opacity: 1; transform: none; transition: none; }

    .install-sheet--leaving,
    .install-sheet-scrim--leaving,
    .reaction-picker,
    .composer-menu,
    .modal-scrim,
    .modal-scrim .modal,
    .stagger-in > *,
    .message-row--entering { animation: none; opacity: 1; transform: none; }

    .recorder__hint,
    .recorder__dot,
    .recorder__time { transform: none; }
}

/* ═══ Field-level validation ═══════════════════════════════════════════════
   A failed submit used to surface as a single auto-dismissing toast carrying
   only $errors->first(). Registering with three problems showed one of them,
   named no field, and then vanished. These make the failure stay on screen,
   next to the input that caused it.
   ═══════════════════════════════════════════════════════════════════════════ */

.form-input--invalid,
.form-select--invalid {
    border-color: var(--color-danger);
}

.form-input--invalid:focus,
.form-select--invalid:focus {
    outline-color: var(--color-danger);
}

.field-error {
    align-items: flex-start;
    color: var(--color-danger);
    display: flex;
    font-size: .8rem;
    gap: .3rem;
    line-height: 1.4;
    margin-top: .35rem;
}

.field-error::before {
    content: '';
    background: currentColor;
    border-radius: 50%;
    flex-shrink: 0;
    height: 5px;
    margin-top: .45rem;
    width: 5px;
}

/* Skip link — hidden until a keyboard reaches it, then the first stop on the
   page. The nav renders before <main>, so without this every tab-through
   started with the tab bar on every single page. */
.skip-link {
    align-items: center;
    background: var(--color-primary);
    border-radius: 0 0 var(--radius-input) 0;
    color: var(--color-on-accent);
    display: flex;
    font-size: .85rem;
    font-weight: 600;
    left: 0;
    /* 44px: it measured 43 and is the first thing a keyboard or switch user
       lands on, so it should not be the one control that misses the floor. */
    min-height: 44px;
    padding: .7rem 1.1rem;
    position: fixed;
    top: 0;
    transform: translateY(-120%);
    z-index: 300;
}

.skip-link:focus {
    transform: translateY(0);
    outline: 2px solid var(--color-primary-glint);
    outline-offset: -4px;
}

@media (prefers-reduced-motion: reduce) {
    .skip-link { transition: none; }
}

/* ═══ Profile header ═══════════════════════════════════════════════════════ */

/* Full-bleed brand cover. It used to sit inside the page gutter with white
   above and to both sides, which read as a floating card rather than a header
   band. Negative margins cancel the gutter the page-body applied, so it runs
   edge to edge and butts straight up under the top bar. The gutters are
   variables, so this stays correct at both breakpoints without a media query. */
.profile-cover {
    /* The bottom corners' drop and horizontal run. Both are consumed here and
       by the overlay in "The band's bottom edge" further down — change them in
       one place and the box grows, the margin compensates, and the corner
       ellipse widens together. */
    --cover-dip: 24px;
    --cover-run: 32px;

    align-items: flex-start;
    background: var(--color-brand-cover);
    border-radius: 0;
    display: flex;
    justify-content: center;
    /* Cancels the page gutter on three sides so the band runs edge to edge and
       butts up under the top bar. The -60px keeps pulling the identity block
       up over the band, which is what makes the avatar overlap it. The extra
       --cover-dip gives back the height the padding below adds, so growing the
       box for the corners moves nothing under it. */
    margin: calc(var(--page-gutter-y) * -1) calc(var(--page-gutter) * -1) calc(-60px - var(--cover-dip));
    /* Absorbs the cancelled top padding so the mark keeps the optical position
       it had before the bleed. */
    padding-top: calc(44px + var(--page-gutter-y));
    /* Box, not band: the last --cover-dip of it is painted back to page colour
       except at the two corners. */
    padding-bottom: var(--cover-dip);
    min-height: calc(118px + var(--page-gutter-y) + var(--cover-dip));
}

/* ── The ears ─────────────────────────────────────────────────────────────
   They perk up once on arrival rather than looping. A permanent loop on a
   screen you open often becomes wallpaper at best and a distraction at worst,
   and a slow oscillation is exactly the kind of motion that bothers people
   sensitive to it. Once, on entry, then still.

   transform-box: fill-box makes transform-origin resolve against each polygon's
   own bounding box rather than the SVG viewport, so each ear pivots at its own
   base — the way an ear actually hinges. */
.profile-ear {
    transform-box: fill-box;
    transform-origin: 50% 92%;
    animation: ear-perk 520ms cubic-bezier(.34, 1.32, .64, 1) both;
}

.profile-ear--l { animation-delay: 90ms; }
.profile-ear--r { animation-delay: 160ms; }

/* On the reload after a photo upload the ears wait for the face. At their
   normal 90/160ms they would perk while the new avatar is still fading in
   (see .profile-avatar-btn--landed) and the two would read as one blur;
   pushed back, the face arrives and then the ears react to it. Same :has()
   shape as the twitch below — the avatar is a later sibling, not a child. */
.profile-cover:has(+ .profile-identity .profile-avatar-btn--landed) .profile-ear--l { animation-delay: 300ms; }
.profile-cover:has(+ .profile-identity .profile-avatar-btn--landed) .profile-ear--r { animation-delay: 370ms; }

@keyframes ear-perk {
    from { transform: rotate(var(--droop)); }
    to   { transform: rotate(0deg); }
}

.profile-ear--l { --droop: -26deg; }
.profile-ear--r { --droop:  26deg; }

/* And a twitch when you touch the avatar — the ears belong to the face right
   below them, so reacting to it is the one interaction that makes them read as
   part of the same creature rather than decoration parked above it. */
.profile-avatar-btn:active ~ .profile-cover .profile-ear,
.profile-cover:has(+ .profile-identity .profile-avatar-btn:active) .profile-ear {
    animation: ear-twitch 340ms cubic-bezier(.34, 1.32, .64, 1);
}

@keyframes ear-twitch {
    0%   { transform: rotate(0deg); }
    35%  { transform: rotate(calc(var(--droop) * .45)); }
    100% { transform: rotate(0deg); }
}

/* The profile page's three animations, silenced for anyone who asked for less
   motion. The ears' own comment already reasons about vestibular sensitivity —
   "a slow oscillation is exactly the kind of motion that bothers people
   sensitive to it" — but never carried the media query that acts on it, and
   the avatar's landing rises and scales, which is the same class of movement.
   Per the reduced-motion contract elsewhere in this file: no motion, but the
   element still arrives, so nothing is lost except the travel. */
@media (prefers-reduced-motion: reduce) {
    .profile-ear,
    .profile-avatar-btn:active ~ .profile-cover .profile-ear,
    .profile-cover:has(+ .profile-identity .profile-avatar-btn:active) .profile-ear,
    .profile-cover:has(+ .profile-identity .profile-avatar-btn--landed) .profile-ear--l,
    .profile-cover:has(+ .profile-identity .profile-avatar-btn--landed) .profile-ear--r {
        animation: none;
    }

    .profile-avatar-btn--landed { animation: none; }
}

/* ── Avatar as the way to change your photo ───────────────────────────────
   A face you can tap is a far more obvious affordance than a settings screen
   two taps away. The camera badge is what makes it discoverable — without it
   the avatar is just a picture. */
.profile-avatar-btn {
    background: none;
    border: 0;
    cursor: pointer;
    display: inline-block;
    padding: 0;
    position: relative;
    /* opacity is here for the busy state below, which used to snap. */
    transition: transform var(--dur-release) var(--ease-standard),
                opacity var(--dur-med) var(--ease-standard);
}

.profile-avatar-btn:active { transform: scale(.97); transition-duration: var(--dur-press); }

.profile-avatar-btn__badge {
    align-items: center;
    background: var(--color-primary);
    border: 3px solid var(--color-surface);
    border-radius: 50%;
    bottom: 4px;
    color: var(--color-on-accent);
    display: flex;
    height: 34px;
    justify-content: center;
    position: absolute;
    right: 4px;
    width: 34px;
}

.profile-avatar-btn__badge svg { height: 16px; width: 16px; }

/* ── While the photo is being prepared ────────────────────────────────────
   Choosing the picture IS the confirmation here — there is no Save button — so
   between the tap and the page reload the avatar is the only thing that can
   report progress. That gap is not short: phone cameras hand us 5-25 MB and
   the downscale in photo-upload.js runs on the main thread, so on a mid-range
   phone it is seconds. It used to be a bare opacity snap with nothing moving,
   which reads as "nothing happened" and invites a second tap.

   The dim now eases (see the transition above) and the badge breathes. The
   pulse is on the badge alone, not the whole avatar: a 34px disc is enough to
   say "working", and scaling a 110px face under the user's own photo would be
   the screen's loudest motion for something that is only a wait. */
.profile-avatar-btn[data-busy="true"] { opacity: .6; pointer-events: none; }

.profile-avatar-btn[data-busy="true"] .profile-avatar-btn__badge {
    animation: badge-busy 1.1s var(--ease-standard) infinite;
}

@keyframes badge-busy {
    50% { transform: scale(1.12); }
}

/* ── And when the new face lands ──────────────────────────────────────────
   The upload ends in a full page reload, so the photo the user just chose
   arrived cold with everything else — the one moment they are actually asking
   "did that work?" had no answer beyond the flash message. Only on the reload
   that follows an upload: the class comes from a one-shot session flag, so a
   normal visit to the page is untouched and this never becomes wallpaper.

   fill-mode is `backwards`, deliberately. It hides the avatar through the
   delay, but after the run the element drops back to its own styles — with
   `forwards` or `both` the held transform would outrank :active and silently
   kill the press feedback on the same button for the rest of the page's life. */
.profile-avatar-btn--landed {
    animation: avatar-land var(--dur-slow) var(--ease-standard) 120ms backwards;
}

@keyframes avatar-land {
    from { opacity: 0; transform: scale(.94); }
    to   { opacity: 1; transform: scale(1); }
}

/* ── Native date input ────────────────────────────────────────────────────
   Left alone, input[type=date] ignores most of .form-input: Safari and iOS
   collapse it to the height of its own text, drop it out of alignment with the
   fields either side, and right-align the value. appearance:none strips the
   native chrome so the shared input styling actually applies. */
input[type="date"].form-input {
    -webkit-appearance: none;
    appearance: none;
    font: inherit;
    /* Built from the same numbers .form-input is: line-height 1.55, the shared
       .65rem vertical padding, and the 1.5px border top and bottom. Tuning the
       inner pseudo-elements did not hold across engines, but deriving the box
       from the same terms does — and it still scales if the type scale moves. */
    height: calc(1.55em + 1.3rem + 3px);
}

input[type="date"].form-input::-webkit-date-and-time-value {
    margin: 0;
    text-align: left;
}

/* Kept for iOS Safari, which is the real target and which I cannot measure
   from here. On Chrome neither rule moved the box — the height above is what
   actually fixed the alignment — but zeroing the inner padding is the standard
   guard against WebKit inflating this field, so it stays. */
input[type="date"].form-input {
    line-height: 1.55;
}

input[type="date"].form-input::-webkit-datetime-edit {
    line-height: 1.55;
    padding: 0;
}

input[type="date"].form-input::-webkit-calendar-picker-indicator {
    /* Left at its intrinsic size the indicator is taller than the text line and
       drags the whole field 2px past the inputs beside it. Sizing it to the
       text puts Birthday back on exactly the same height as Mobile. */
    block-size: 1em;
    cursor: pointer;
    inline-size: 1em;
    opacity: .55;
    padding: 0;
}

input[type="date"].form-input::-webkit-calendar-picker-indicator:hover { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
    .profile-ear { animation: none; }
    .profile-avatar-btn:active { transform: none; }
}

/* ── Header that continues the brand cover ────────────────────────────────
   The cover used to begin below this bar, leaving a white strip across the top
   of the profile. Giving the header the same charcoal makes the two read as one
   band running from the very top of the screen.

   Everything inside it has to be recoloured: the eyebrow and both icon buttons
   are tuned for a white surface and would sit at roughly 2:1 on this one.
   --color-brand-cover-mark is the token already designated for marks drawn ON
   the cover, so it is what they take. */
.page-header--onbrand {
    background: var(--color-brand-cover);
    /* Stops where the body's reading column stops. .page-header is normally
       --color-surface, the same colour as the page, so running full width past
       the 960px column is invisible on every other page. Here it is charcoal,
       and on a desktop viewport it ran ~240px further right than the cover
       under it — a dark bar with a white notch bitten out of its right end.
       Capped, the header and the cover are one masthead exactly as wide as the
       cards below, and the curved corners land on the column's own edge.
       Nothing scrolls under the strip this gives up: .page-body never reaches
       there. Below 960px of room this is a no-op and the bar is full-bleed. */
    max-width: var(--page-column);
    /* Reaches under the status bar on an installed PWA, where the header is the
       topmost thing on screen. */
    padding-top: calc(1.375rem + env(safe-area-inset-top));
}

.page-header--onbrand .eyebrow { color: var(--color-brand-cover-mark); opacity: .72; }

.page-header--onbrand .iconbtn,
.page-header--onbrand .notif-bell {
    background: transparent;
    border-color: transparent;
    color: var(--color-brand-cover-mark);
}

.page-header--onbrand .iconbtn:active,
.page-header--onbrand .notif-bell:active { background: rgb(255 255 255 / .14); }

/* The bell's unread ring and count sit on charcoal now, so the ring needs to be
   drawn in the on-cover colour rather than the page's surface colour. */
.page-header--onbrand .notif-bell--unread { box-shadow: 0 0 0 2px var(--color-brand-cover-mark); }

/* The cover already cancels the page gutter; with the header the same colour
   the seam between them disappears, so it only needs its own height now. */
.page-header--onbrand + .page-body .profile-cover { margin-top: calc(var(--page-gutter-y) * -1); }

/* ── The band's bottom edge ───────────────────────────────────────────────
   Reopened against a reference image after five verbal attempts failed.

   What the reference actually shows: the band's corners do not round AWAY —
   the page's top corners round, and the band stays behind them. That
   inversion is why every earlier try looked wrong. border-radius on
   .profile-cover is convex: it takes black out of the corner. The drawing
   wants black left IN the corner, which is concave, and no radius on this
   element can express it.

   So the band is grown --cover-dip taller than its visible edge (see the base
   rule) and this overlay paints that strip back to page colour with its own
   two top corners rounded. Ellipse, not a circle: --cover-run of travel for
   --cover-dip of drop. A circular radius reads as a bump; the longer run
   reads as a sweep.

   The run is measured off the reference. That drawing is 650px wide with its
   corner running 55px in; its band is 185px tall, which at 375px of viewport
   works out to 107px — near enough to this band's own height to confirm the
   drawing is a full-width phone. Scale factor 650/375 = 1.733, so 55 lands on
   32. The drop the same arithmetic gave (13px) was too shallow to see on a
   real screen, and Saleh asked for more; 24px was picked off a rendered set of
   four at 13/18/24/30. Past 30 the ellipse turns nearly circular and the
   corner starts to read as a hook rather than a sweep — that is the ceiling.

   Rejected before this: a white dome carved upward, plain rounded corners, a
   concave radial-gradient notch, a convex lobe hung below each corner, and an
   S-curved clip-path tail. */
.profile-cover { position: relative; }

.profile-cover::after {
    background: var(--color-paper);
    border-radius: var(--cover-run) var(--cover-run) 0 0 /
                   var(--cover-dip) var(--cover-dip) 0 0;
    bottom: 0;
    content: '';
    height: var(--cover-dip);
    left: 0;
    position: absolute;
    right: 0;
}

/* The dark-mode hairline that used to live here is gone. It existed only
   because --color-brand-cover sat at 1.13:1 against the page and nothing else
   defined the band's edge — a patch over an invisible surface. The token is now
   lifted to ~1.5:1, so the band draws its own edge and the line has nothing
   left to do except read as a seam between two surfaces that meet cleanly. */

/* ── Account security (email & password) ─────────────────────────────────
   Two forms on one page, so each is a card with its own heading: proximity
   is what tells a learner which password field belongs to which action. */
.account-stack {
    display: flex;
    flex-direction: column;
    gap: 1.25rem;
    max-width: 600px;
}

.account-card {
    display: flex;
    flex-direction: column;
    gap: 1.1rem;
    padding: 1.25rem;
}

.account-card__title {
    font-size: 1.05rem;
    font-weight: 600;
    margin: 0 0 .2rem;
}

.account-card__sub {
    font-size: .84rem;
    color: var(--color-muted);
    margin: 0;
    /* Said before the form, not after it fails — the consequence of the
       action is part of the decision to take it. */
    line-height: 1.55;
}

.account-current {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: .5rem;
    padding: .7rem .85rem;
    background: var(--color-chip-bg);
    border-radius: 12px;
}

.account-current__label {
    font-size: .72rem;
    font-weight: 700;
    letter-spacing: .06em;
    text-transform: uppercase;
    color: var(--color-muted);
}

.account-current__value {
    font-size: .9rem;
    font-weight: 600;
    word-break: break-all;
    flex: 1 1 auto;
    min-width: 0;
}

.account-badge {
    display: inline-flex;
    align-items: center;
    gap: .25rem;
    font-size: .72rem;
    font-weight: 700;
    padding: .15rem .5rem;
    border-radius: 999px;
    white-space: nowrap;
}

.account-badge--ok {
    background: var(--acc-success-tint);
    color: var(--acc-success);
}

.account-badge--warn {
    background: var(--color-warning-tint);
    color: var(--color-warning-text);
}

.account-pending {
    display: flex;
    gap: .75rem;
    padding: .9rem;
    border: 1px solid var(--color-card-border);
    border-radius: 12px;
    background: var(--color-chip-bg);
}

.account-pending__icon {
    width: 32px;
    height: 32px;
    border-radius: 9px;
    background: var(--color-primary-tint);
    color: var(--color-primary-text);
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
}

.account-pending__title {
    font-weight: 600;
    font-size: .9rem;
    margin: 0 0 .2rem;
}

.account-pending__text {
    font-size: .82rem;
    color: var(--color-muted);
    line-height: 1.6;
    margin: 0;
    word-break: break-word;
}

.account-pending__actions {
    display: flex;
    flex-wrap: wrap;
    gap: .5rem;
    margin-top: .7rem;
}

.account-form {
    display: flex;
    flex-direction: column;
    gap: 1rem;
}

.form-hint {
    font-size: .78rem;
    color: var(--color-muted);
    margin: .35rem 0 0;
    line-height: 1.5;
}

.form-hint--centered {
    text-align: center;
    margin-top: .1rem;
}

/* The toggle sits inside the field, so the input needs room for it on the
   side the text ends — logical padding keeps that correct under RTL. */
.password-field {
    position: relative;
    display: block;
}

.password-field .form-input {
    padding-inline-end: 2.85rem;
}

.password-toggle {
    position: absolute;
    inset-inline-end: .35rem;
    top: 50%;
    transform: translateY(-50%);
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 40px;
    height: 40px;
    border: 0;
    background: transparent;
    color: var(--color-muted);
    border-radius: 9px;
    cursor: pointer;
    transition: color .15s, background .15s;
}

.password-toggle:hover { color: var(--color-ink-2); }
.password-toggle:active { background: var(--color-chip-bg); }
.password-toggle[aria-pressed="true"] { color: var(--color-primary-text); }

@media (prefers-reduced-motion: reduce) {
    .password-toggle { transition: none; }
}
