    /* ════════════════════════════════════════════════════════════════════
     *   static/main.css   —   /v1 page styles
     * ════════════════════════════════════════════════════════════════════
     *
     *   CSS variables (design tokens) live in static/themes/default.css.
     *   This file holds component rules that reference those tokens via
     *   var(--name).
     *
     *   Loaded by templates/v1.html as the second stylesheet (after
     *   default.css). One <style> block injected inline at build time —
     *   that's why every rule sits at 4-space indent.
     *
     *   ──  TABLE OF CONTENTS  ────────────────────────────────────────
     *
     *   §0   GLOBAL BASE           reset, font stacks, body, html-lang
     *   §1   APP SHELL             outer phone frame + 2-column grid
     *   §2A  DASHBOARD top         right-panel lesson info, stats cards,
     *                              phrase list, dialog preview, footer
     *   §3   SETUP ROW             top: lang/level/Go + translate menu
     *   §4   BUTTONS (shared)      .primary / .ghost / .top-cta
     *   §5   CHAT WRAPPER          .chat container + .panel-alert
     *   §6   TOPIC MODAL           topic picker overlay
     *   §7   BROWSER topic-complete-summary +     misc top-of-chat alerts
     *        STATUS LINE
     *   §8   CHAT — SHARED         messages, bubble base, speakers,
     *                              target-phrase, phrase-translation,
     *                              msg-para, flow-hint
     *   §9   CHAT — L5 ONLY        .pronun-rule callout
     *   §10  CHAT — L3 ONLY        .bubble.your-turn, .bubble-prefix-label,
     *                              .score-feedback, retry shake/flash,
     *                              translation tooltip hover-reveal,
     *                              .topic-complete-summary
     *   §11  CHAT — L1/L2/L5       .target-phrase-blink, .recall-pulse,
     *                              .next-phrase-label
     *   §12  CHAT — SHARED tail    .assistant/.user color tints,
     *                              mic-captured pulse, .your-turn-prompt
     *   §13  COMPOSER              input row, lightbulb, side tools
     *   §14  MIC / SEND BUTTONS    record-btn, sendBtn + animations
     *   §15  RESPONSIVE @media     mobile / tablet breakpoints
     *   §16  VOCAB TOOLBAR         headphone row + tooltip
     *   §17  AUTH                  .auth-chip (legacy standalone)
     *   §18  DASHBOARD settings    .lesson-settings-row, lang select,
     *        strip                 inline Settings + Logout, user-chip-link
     *   §19  UPGRADE MODAL         pro upgrade overlay
     *
     *   ──  LEVEL → SECTION QUICK MAP  ────────────────────────────────
     *
     *   L1 / L2 / L5 phrase practice  →  §8 §9 §11 §12
     *   L3 mini-dialog                →  §8 §10 §12
     *   L4 free talk                  →  §8 §12 (minimal styling)
     *   Right-side dashboard          →  §2A §18
     *   All chat                      →  §5 §7 §13 §14
     *
     * ════════════════════════════════════════════════════════════════════ */


    /* ════════════════════════════════════════════════════════════════════
     * §0 · GLOBAL BASE   —   reset + font stacks + body/html-lang
     * ════════════════════════════════════════════════════════════════════ */

    * { box-sizing: border-box; }
    /* Font stacks split by script — Latin-only / CJK-only — so the body
       rules can concatenate them into a controlled chain. Crucially: no
       `sans-serif` or `-apple-system` INSIDE the variables. Both keywords
       match every glyph and short-circuit the chain before reaching the
       explicit CJK fonts (Noto Sans TC/SC loaded via Google Fonts).
       The body rule below appends `-apple-system, sans-serif` AT THE END
       as the final fallback, so the order is:
         Inter/Montserrat (Latin) → Noto Sans TC/SC (CJK web fonts)
         → -apple-system (system fallback) → sans-serif (generic). */
    :root {
      --font-ui-latin: 'Inter', 'Montserrat';
      --font-ui-cjk-tw: 'Noto Sans TC', 'PingFang TC', 'Microsoft JhengHei';
      --font-ui-cjk-cn: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei';
      /* Display/heading font — Fraunces (modern warm serif) for the dashboard
         title + greeting only. CJK titles fall back to the UI CJK font. */
      /* Heading font for the dashboard title + greeting. Swap the first name
         to try another (both Anton + Space Grotesk are loaded). Fallback is
         the body sans (Montserrat) so it matches during font-load. */
      --font-display: 'Anton', 'Montserrat', var(--font-ui-cjk-tw), sans-serif;
    }
    body {
      margin: 0;
      min-height: 100dvh;
      background: var(--bg-page);
      color: var(--text-body);
      font-family: var(--font-ui-latin), var(--font-ui-cjk-tw), -apple-system, sans-serif;
      -webkit-font-smoothing: antialiased;
      -moz-osx-font-smoothing: grayscale;
      /* Enable kerning + ligatures across Mac/Windows for more consistent
         text rendering (Latin words like "affari" / "caffè" / "difficile"
         get tighter letter pairing; "fi" / "fl" / "ff" form proper
         ligatures in Inter / DM Sans). CJK is unaffected — Noto Sans
         TC/SC has no ligatures, but the hint costs nothing for it.
         Perf impact is negligible on modern browsers; old Android bugs
         (pre-Lollipop) are no longer relevant. */
      text-rendering: optimizeLegibility;
      /*font-family: 'Noto Sans TC', 'Noto Sans SC', 'Montserrat', -apple-system,
                   BlinkMacSystemFont, 'Segoe UI', sans-serif; */
      line-height: 1.65;
      letter-spacing: -0.01em;
      display: grid;
      place-items: center;
      padding: 18px;
    }
    html:lang(zh-TW) body,
    html:lang(zh-HK) body {
      font-family: var(--font-ui-latin), var(--font-ui-cjk-tw), -apple-system, sans-serif;
    }
    html:lang(zh-CN) body,
    html:lang(zh-SG) body {
      font-family: var(--font-ui-latin), var(--font-ui-cjk-cn), -apple-system, sans-serif;
    }


    /* ════════════════════════════════════════════════════════════════════
     * §1 · APP SHELL   —   outer phone frame + 2-column grid layout
     * Scope: shared (all levels)
     * ════════════════════════════════════════════════════════════════════ */

    .app-shell {
      display: grid;
      grid-template-columns: 1fr;
      gap: 22px;
      align-items: start;
      width: 100%;
      justify-items: center;
    }
    .phone {
      width: min(100%, 430px);
      height: 95vh;
      background: var(--white);
      border: var(--frame-border);
      border-radius: var(--radius-xl);
      box-shadow: var(--shadow);
      overflow: hidden;
      display: grid;
      grid-template-rows: auto 1fr auto;
      position: relative;
    }
    .setup {
      margin: 10px 14px 0;
      padding: 0;
      background: transparent;
      border: 0;
      border-radius: 0;
      display: block;
    }
    /* ════════════════════════════════════════════════════════════════════
     * §2A · DASHBOARD (right panel — top)
     *     Lesson info card, stats chips (Streak / Level / This Topic),
     *     phrase list, dialog preview, footer.
     *     Settings strip (lang select + Settings + Logout) is §18 below.
     * Scope: shared (visible on all levels except mobile collapsed view)
     * ════════════════════════════════════════════════════════════════════ */

    /* Dashboard：右側整體容器與內層布局 */
    .lesson-panel {
      display: none;
    }
    .lesson-panel-inner {
      height: 100%;
      min-height: 0;
      display: grid;
      grid-template-rows: auto 1fr auto;
    }
    /* Dashboard 頂部：外層間距與白色標題卡 */
    .lesson-setup {
      margin: 10px 14px 0;
    }
    .lesson-mini-head {
      /* A scene that wants a full-bleed header BAR sets --header-bg plus
         --header-margin (negative, to bleed to the panel edges) and
         --header-pad (roomier). Themes WITHOUT a bar leave those unset →
         compact original padding, no bleed (just title text). */
      background: var(--header-bg, transparent);
      margin: var(--header-margin, 0);
      border: none;
      border-radius: 0;
      padding: var(--header-pad, 10px 10px 5px);
      box-shadow: none;
    }
    .lesson-head-row {
      display: flex;
      align-items: flex-start;
      justify-content: space-between;
      gap: 10px;
    }
    .lesson-head-copy {
      min-width: 0;
      flex: 1;
    }
    .lesson-back-btn {
      display: none;
      flex: 0 0 auto;
      width: 38px;
      height: 38px;
      border-radius: var(--radius-full);
      border: none;
      background: var(--orange);
      color: var(--on-brand);
      font-size: 17px;
      align-items: center;
      justify-content: center;
      cursor: pointer;
      /*box-shadow: 0 10px 24px rgba(226, 107, 62, 0.22);
      transition: transform 0.18s ease, filter 0.18s ease;*/
    }
    .lesson-back-btn:hover {
      filter: brightness(1.03);
    }
    .lesson-back-btn:active {
      transform: scale(0.95);
    }
    /* Dashboard 內容區：下面所有卡片的容器 */
    .lesson-chat {
      min-height: 0;
      overflow-y: auto;
      background: var(--bg-panel);
      /* Tight top padding so the settings row sits close to the title block
         above (was 15px, made tagline → settings gap feel wasted). Sides /
         bottom keep the 15px breathing room. */
      padding: 6px 15px 15px;
      display: grid;
      align-content: start;
      gap: 12px;
      border-top: 1px solid var(--line);
      border-bottom: 1px solid var(--line);
    }
    /* Dashboard 卡片：Key Pattern / Key Grammar / Cultural Tip */
    .lesson-card {
      background: var(--white);
      border: 1px solid rgba(0, 0, 0, 0.04);
      border-radius: var(--radius-sm);
      padding: 10px 10px 5px;
      box-shadow: var(--shadow-sm);
    }
    .lesson-stats-card {
      background: transparent;
      border: 0;
      box-shadow: none;
      padding: 0;
    }
    /* Dashboard 文字：主標、副標、卡片標題與列表 */
    .lesson-kicker,
    .lesson-title,
    .lesson-copy,
    .lesson-section-title,
    .lesson-list,
    .lesson-link,
    .lesson-chips {
    }
    .lesson-title {
      margin: 0 0 2px;
      font-family: var(--font-display);
      font-size: 1.2rem;
      line-height: 1.15;
      /* Deeper teal than the Go btn (--my-go-btn-color var(--brand)). Sits
         in the same brand-blue family but reads as a title rather than
         a button. */
      color: var(--header-ink, var(--title));
      font-weight: 520;
    }
    /* CJK learner: ease title weight from 520 → 450. Noto Sans TC/SC at
       520 render as effectively semi-bold + tall Chinese glyphs make it
       feel "shouting" in the panel. 450 keeps the title visually present
       but reads as a calm heading rather than an emphatic label. */
    body.learner-cjk .lesson-title {
      font-weight: 450;
    }
    .lesson-copy {
      margin: 0;
      color: var(--header-ink-soft, var(--text-primary));
      font-size: 0.88rem;
      font-weight: 400;
      line-height: 1.3;
    }
    /* "👋 nana, Iniziamo!" — highlight the user's name inside the
       greeting CTA. Background now matches the Streak / Level /
       This Topic stat chips (--chip-warm light mauve), so the
       dashboard's three pastel cards + the name-tag read as ONE
       chip family. Brick-red text keeps the personalised accent
       and creates a warm vs cool contrast against the mauve bg.
       The retry-flash bubble still uses var(--brick-red-wash) —
       L3 animation context, separate concern. */
    .user-greeting-name {
      color: var(--header-chip-ink, var(--brick-red));
      font-weight: 600;
      background: var(--header-chip, var(--chip-warm));
      padding: 1px 8px;
      border-radius: var(--radius-sm);
    }
    .lesson-section-title {
      font-size: 0.75rem;
      font-weight: 510;           /* Latin +10 (DM Sans variable). CJK overridden below. */
      color: var(--section-title); /* default = dark mauve #7a5e6f; premium re-tones to green */
      margin-bottom: 1px;
    }
    body.learner-cjk .lesson-section-title {
      font-weight: 500;
    }
    .lesson-section-helper {
      margin: 2px 0 10px;
      color: var(--text-muted);
      font-size: 0.76rem;
      line-height: 1.25;
      font-weight: 400;
    }
    .lesson-list {
      margin: 0;
      padding-left: 10px;
      color: var(--text-primary);
      font-size: 0.94rem;
      line-height: 1.45;
    }
    /* b — note → 下一個 formula 的間距 */
    .lesson-list li + li {
      margin-top: 2px;
    }
    .lesson-list-main {
      font-weight: 500;
      color: var(--soft);
      font-size: 0.8rem;
      line-height: 1.28;
    }
    .lesson-list-plain {
      font-weight: 400;
    }
    .lesson-keyword-link {
      display: inline-block;
      text-decoration: none;
      color: var(--text-primary);
      font-size: 0.84rem;
      line-height: 1.28;
    }
    .lesson-keyword-link:hover {
      color: var(--accent);
      text-decoration: underline;
    }
    /* L3 mini-dialog search link — inline inside the existing Key Pattern
       card (re-using its body slot when on a mini-dialog turn). Style is
       intentionally restrained: small, accent-colored, opens Google search
       in a new tab so the user does not lose their dialog state. */
    .lesson-search-link {
      display: inline-block;
      text-decoration: none;
      color: var(--accent);
      font-size: 0.85rem;
      line-height: 1.35;
      word-break: break-word;
    }
    .lesson-search-link:hover {
      text-decoration: underline;
    }
    /* L3 dialog phrase list — each <li> stacks the 🔍 phrase link on top
       and its translation below in a softer/smaller register. Reads as
       "study card" rows: line you'll say / line that means. */
    .lesson-dialog-phrase {
      padding: 6px 0;
    }
    .lesson-dialog-phrase + .lesson-dialog-phrase {
      border-top: 1px solid rgba(0, 0, 0, 0.06);
    }
    .lesson-dialog-phrase-translation {
      /* Plain text reference, not a link — see main.js for rationale.
         display:block keeps it stacked under the phrase link rather than
         inline at the end. */
      display: block;
      margin-top: 2px;
      color: var(--soft);
      font-size: 0.78rem;
      line-height: 1.28;
    }
    /* a — formula → note 的間距 (1/3 of b) */
    .lesson-list-note {
      margin-top: 1px;
      color: var(--soft);
      font-size: 0.78rem;
      line-height: 1.28;
    }
    .lesson-chips {
      margin: 0;
      padding: 0;
      list-style: none;
      display: flex;
      flex-wrap: wrap;
      gap: 8px;
    }
    .lesson-chips li {
      padding: 7px 10px;
      border-radius: var(--radius-pill);
      background: var(--chip-green);
      color: var(--forest);
      font-size: 0.88rem;
      font-weight: 400;
    }
    .lesson-link {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      padding: 12px 16px;
      border-radius: var(--radius-pill);
      background: var(--text-primary);
      color: var(--on-brand);
      text-decoration: none;
      font-size: 0.92rem;
      font-weight: 600;
      box-shadow: var(--shadow-lg);
    }
    .lesson-inline-link {
      display: inline-flex;
      align-items: center;
      margin-top: 12px;
      color: var(--accent);
      text-decoration: none;
      font-size: 0.92rem;
      font-weight: 600;
    }
    .lesson-link-note {
      margin-top: 3px;
      font-size: 0.78rem;
      line-height: 1.35;
      color: var(--forest-mid);
    }
    .lesson-natural-use {
      margin: 0;
      color: var(--text-primary);
      font-size: 0.84rem;
      line-height: 1.28;
      font-weight: 400;
    }
    /* Dashboard Stats：上方三個小資訊卡 */
    .lesson-stats-grid {
      display: grid;
      grid-template-columns: repeat(3, minmax(0, 1fr));
      gap: 3px;
    }
    .lesson-stat-chip {
      border-radius: var(--radius-md);
      background: var(--panel-bg);
      padding: 7px 9px;
      min-width: 0;
    }
    /* Stat-card type — flat 500 weight for label + value (works for both
       Latin DM Sans + CJK Noto Sans without script-specific override).
       Text uses --panel-ink (coordinated with --panel-bg) so the whole
       card re-tones as one unit per scene. */
    .lesson-stat-label {
      color: var(--soft);
      font-size: 0.71rem;
      margin-bottom: 2px;
      font-weight: 500;
    }
    .lesson-stat-value {
      color: var(--panel-ink);
      font-size: 0.88rem;
      font-weight: 500;
    }
    .lesson-stat-subvalue {
      color: var(--soft);
      font-size: 0.76rem;
      font-weight: 420;
    }
    body.learner-cjk .lesson-stat-subvalue {
      font-weight: 400;
    }
    .lesson-footer {
      padding: 14px 16px 16px;
      display: flex;
      justify-content: center;
      background: var(--composer-bg);
    }
    /* Setup row — mirrors .input-row exactly: content as direct flex
       children on the left, .button-group absolutely positioned on the
       right. input-row has one #textInput; setup-row has two <label>s
       (target-lang + level select).
       padding-right: 100px so the labels' total span lines up with
       #textInput's visible text area (which also reserves 100px on its
       right). Two labels together = same usable width as the input. */
    /* ════════════════════════════════════════════════════════════════════
     * §3 · SETUP ROW (top of chat panel)
     *     Language picker, level picker, Go button, translate menu
     *     ([Italian ▼] [3 - Mini-dialog ▼] [Go]).
     * Scope: shared (all levels)
     * ════════════════════════════════════════════════════════════════════ */

    .setup-row {
      position: relative;
      display: flex;
      align-items: center;
      gap: 6px;
      max-width: 100%;
      min-width: 0;
      padding-right: 100px;
    }
    /* Right-padding reserves space for the absolute button-group.
       Width depends on which icons are showing:
         base                          Go only                       ~100px
         has-translate                 Go + 🌐 (English target)      ~142px
         has-auth-pocket               Go + 🔒 (anon)                ~142px
         has-translate.has-auth-pocket Go + 🌐 + 🔒 (anon + English) ~185px
       Classes toggled by JS — `has-translate` in main.js's renderTranslateBtn;
       `has-auth-pocket` in static/js/auth.js's renderAuthChip. */
    .setup-row.has-translate          { padding-right: 142px; }
    .setup-row.has-auth-pocket        { padding-right: 142px; }
    .setup-row.has-translate.has-auth-pocket { padding-right: 185px; }
    .setup-row > label {
      flex: 1 1 0;
      min-width: 0;
    }
    /* Language dropdown slightly narrower than level — language names
       tend to be short ("English", "Italiano"), level can be longer
       ("Mini-dialog", "Small talk (beta)"). ~8% trim (was 10%). */
    .setup-row > label:first-child {
      flex: 0.92 1 0;
    }
    .setup-row .button-group {
      position: absolute;
      right: 8px;
      top: 50%;
      transform: translateY(-50%);
      display: flex;
      gap: 6px;
      align-items: center;
      z-index: 2;
    }
    /* Auth pocket — round avatar in the action area. Logged-in shows
       Google picture (or 👤 fallback); anon shows 🔒 + teal CTA bg so
       it reads as a Login button, not passive decoration. Mobile users
       can't reach the right-panel Settings chip, so this is their only
       account-access touchpoint. Avatar swap handled by renderAuthChip()
       in static/js/auth.js. */
    .auth-user-pocket {
      flex: 0 0 auto;
      width: 36px;
      height: 36px;
      border-radius: var(--radius-full);
      background: var(--surface-warm);
      display: inline-flex;
      align-items: center;
      justify-content: center;
      overflow: hidden;
      text-decoration: none;
      transition: background 0.15s, transform 0.1s;
    }
    /* Override the display:inline-flex above when hidden — without
       this the [hidden] attribute set by renderAuthChip silently fails
       (specificity: .auth-user-pocket beats the generic [hidden]). */
    .auth-user-pocket[hidden] { display: none; }
    .auth-user-pocket:hover { transform: scale(1.05); }
    .auth-user-pocket img { width: 100%; height: 100%; object-fit: cover; }
    .auth-pocket-avatar { font-size: 1.1rem; line-height: 1; }
    /* Anon state — Login CTA styling. Teal bg matches the Go button so
       user reads it as "click me", not just an icon. */
    .auth-user-pocket.auth-anon {
      background: var(--my-go-btn-color);
      color: var(--on-brand);
    }
    .auth-user-pocket.auth-anon:hover {
      background: var(--my-go-btn-hover);
      filter: brightness(1.05);
    }
    /* Translate picker — compact select that sits in the setup-row's
       button-group, to the left of Go. First <option> reads "Translate"
       as the placeholder (no external label). Only revealed when target
       = English (JS toggles `hidden`). */
    /* Translate picker — icon-only button. Click reveals .setup-translate-menu.
       Closed state always shows 🌐 regardless of which language is
       selected, so the picker never grows wider than its 38px circle. */
    .setup-translate-btn {
      flex: 0 0 auto;
      width: 38px;
      height: 38px;
      border-radius: var(--radius-full);
      background: var(--field-bg);
      /* Thin teal border = same color family as the Go btn next to it.
         Reads as a paired CTA without competing visually. */
      border: 1px solid var(--my-go-btn-color);
      /* The 🌐 is now a single-colour inline SVG (stroke: currentColor), so
         the globe takes this colour. Defaults to the Go-button colour; a
         scene can override --translate-icon-color independently. */
      color: var(--translate-icon-color, var(--my-go-btn-color));
      font-size: 1rem;
      padding: 0;
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
    }
    .setup-translate-icon {
      width: 20px;
      height: 20px;
      display: block;
    }
    .setup-translate-btn[hidden] { display: none; }
    .setup-translate-btn[aria-expanded="true"] {
      background: rgba(0, 151, 185, 0.08);
      border-color: rgba(0, 151, 185, 0.4);
    }
    /* Popup menu — absolutely positioned under the button. JS toggles
       `hidden`; click-outside also closes it. */
    .setup-translate-menu {
      position: absolute;
      top: calc(100% + 4px);
      right: 50px;          /* tucks under the icon, clear of the Go btn */
      z-index: 10;
      background: var(--white);
      border: 1px solid var(--line);
      border-radius: var(--radius-md);
      box-shadow: var(--shadow-lg);
      padding: 4px;
      display: flex;
      flex-direction: column;
      min-width: 100px;
    }
    .setup-translate-menu[hidden] { display: none; }
    .setup-translate-menu > button {
      border: none;
      background: transparent;
      text-align: left;
      padding: 6px 10px;
      border-radius: var(--radius-sm);
      font-size: 0.75rem;
      font-weight: 500;
      cursor: pointer;
      color: var(--ink);
    }
    .setup-translate-menu > button:not([disabled]):hover {
      background: var(--menu-hover-bg, rgba(0, 151, 185, 0.10));
    }
    .setup-translate-menu > button[aria-selected="true"] {
      background: var(--menu-active-bg, rgba(0, 151, 185, 0.14));
    }
    /* Disabled header row — acts as the "Translation" label inside the
       popup so the picker doesn't need an external label. Muted color,
       smaller font, no hover/cursor change. */
    .setup-translate-menu > .setup-translate-menu-header {
      font-size: 0.72rem;
      font-weight: 500;
      color: var(--text-subtle, var(--text-subtle));
      cursor: default;
      padding: 4px 10px 2px;
      /* "Phrase Translation" wraps to 2 lines on narrow menus instead
         of expanding the menu wider than the language options. */
      white-space: normal;
      line-height: 1.2;
    }
    label {
      display: grid;
      gap: 4px;
      font-size: 0;
      font-weight: 600;
    }
    select, button, input {
      font: inherit;
    }
    select, input {
      width: 100%;
      border: 1px solid var(--line);
      border-radius: var(--radius-md);
      padding: 6px 10px;
      background: var(--field-bg);
      color: var(--ink);
      font-size: 0.85rem;
      font-weight: 500;
    }
    /* Setup-row dropdowns (language / level): "seamless" — transparent bg so
       they blend into the panel top, keep only the faint --line border + a
       whisper shadow. Colour stays var(--ink) so it flips per theme (a
       hardcoded grey would vanish on the dark scene). The interface picker
       (.lesson-lang-select) keeps its own look — higher specificity. */
    select {
      background: transparent;
      border-radius: var(--radius-sm);
      box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
      outline: none;
      cursor: pointer;
    }
    select:hover {
      background: rgba(0, 0, 0, 0.02);
    }
    /* ── Custom setup-row pickers (target language / level) ──────────────
       Replaces the OS-native <select> open list (which CSS can't style) with
       a hand-rolled popup that matches the 🌐 translate menu. The native
       <select> is kept (visually hidden via [hidden]) as the value source;
       JS (select-picker.js) drives it from these elements. */
    .setup-select {
      position: relative;
      width: 100%;
    }
    .setup-select-btn {
      width: 100%;
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 6px;
      border: 1px solid var(--line);
      border-radius: var(--radius-sm);
      background: transparent;
      box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
      padding: 6px 10px;
      font-size: 0.85rem;
      font-weight: 500;
      color: var(--ink);
      cursor: pointer;
      text-align: left;
    }
    .setup-select-btn:hover { background: rgba(0, 0, 0, 0.02); }
    .setup-select-btn[aria-expanded="true"] {
      border-color: var(--my-go-btn-color);
      background: rgba(0, 0, 0, 0.02);
    }
    .setup-select-label {
      /* Single line + ellipsis. Tried `white-space: normal` to allow
         wrapping on small screens, but with 5 controls (lang / level /
         lock / globe / Go) competing in PWA standalone mode (which is
         even narrower than Safari), it just wrapped 'English' onto
         2 lines and '2 - Phrases' onto 3, which looked worse than
         truncation. Solution is the short-label map in select-picker.js
         instead. */
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
    .setup-select-chevron {
      flex: 0 0 auto;
      font-size: 0.7rem;
      opacity: 0.55;
      transition: transform 0.15s ease;
    }
    .setup-select-btn[aria-expanded="true"] .setup-select-chevron {
      transform: rotate(180deg);
    }
    .setup-select-menu {
      position: absolute;
      top: calc(100% + 4px);
      left: 0;
      z-index: 20;
      min-width: 100%;
      background: var(--white);
      border: 1px solid var(--line);
      border-radius: var(--radius-md);
      box-shadow: var(--shadow-lg);
      padding: 4px;
      display: flex;
      flex-direction: column;
      max-height: 320px;
      overflow-y: auto;
    }
    .setup-select-menu[hidden] { display: none; }
    .setup-select-menu > button {
      border: none;
      background: transparent;
      text-align: left;
      padding: 7px 10px;
      border-radius: var(--radius-sm);
      font-size: 0.8rem;
      font-weight: 500;
      cursor: pointer;
      color: var(--ink);
      white-space: nowrap;
    }
    .setup-select-menu > button:hover { background: var(--menu-hover-bg, rgba(0, 151, 185, 0.10)); }
    .setup-select-menu > button[aria-selected="true"] {
      background: var(--menu-active-bg, rgba(0, 151, 185, 0.14));
    }
    button {
      border: 0;
      border-radius: var(--radius-pill);
      padding: 9px 12px;
      cursor: pointer;
      transition: transform 140ms ease, filter 140ms ease;
      font-weight: 600;
    }
    button:hover {
      filter: brightness(1.03);
    }
    /* go button */
    /* ════════════════════════════════════════════════════════════════════
     * §4 · BUTTONS (shared primitives)
     *     .primary (filled Go-style), .top-cta (header CTA), .ghost
     *     (transparent secondary).
     * Scope: shared
     * ════════════════════════════════════════════════════════════════════ */

    .primary {
      background: var(--my-go-btn-color);
      color: var(--on-brand);
      width: 50px;
      height: 28px;
      font-weight: 600;
      align-items: center;
    }
    .primary:hover {
      background: var(--my-go-btn-hover);
      filter: brightness(1.1);
    }
    .top-cta {
      justify-self: center;
      align-self: center;
      height: 38px;
      width: 38px;
      /* Lock dimensions in a flex container — without flex:0 0 auto the
         translate select sibling can shrink Go to an oval shape. */
      flex: 0 0 auto;
      padding: 0;
      font-size: 0.88rem;
      border-radius: var(--radius-full);
      display: flex;
      align-items: center;
      justify-content: center;
    }
    .ghost {
      background: rgba(255,255,255,0.92);
      border: 1px solid rgba(40, 91, 83, 0.12);
      color: var(--forest);
    }
    /* ════════════════════════════════════════════════════════════════════
     * §5 · CHAT WRAPPER + PANEL ALERTS
     *     Outer .chat container, .panel-alert banner (e.g. mic permission
     *     warning), top-of-chat status strip.
     * Scope: shared (all levels)
     * ════════════════════════════════════════════════════════════════════ */

    .chat {
      margin-top: 8px;
      flex: 1;
      width: 100%;
      overflow-y: auto;
      background: var(--bg-panel);
      padding: 15px;
      display: grid;
      grid-template-rows: 1fr auto;
      gap: 4px;
      position: relative;
    }
    .panel-alert {
      position: absolute;
      left: 50%;
      top: 50%;
      transform: translate(-50%, -50%);
      z-index: 5;
      width: min(62%, 220px);
      padding: 9px 12px;
      border-radius: var(--radius-lg);
      background: var(--alert-bg);
      border: 1px solid var(--alert-border);
      box-shadow: var(--shadow-lg);
      color: var(--alert-ink);
      font-size: 0.84rem;
      font-weight: 600;
      line-height: 1.25;
      text-align: center;
    }
    .panel-alert[hidden] {
      display: none;
    }
    /* ════════════════════════════════════════════════════════════════════
     * §6 · TOPIC MODAL   —   topic picker overlay
     *     Triggered by 💡 lightbulb button. Lists Free / Plus / Pro
     *     tier topics in chip form. .topic-chip-locked = above current tier.
     * Scope: shared (all levels) — opens via composer lightbulb
     * ════════════════════════════════════════════════════════════════════ */

    .topic-modal[hidden] {
      display: none;
    }
    .topic-modal {
      position: fixed;
      inset: 0;
      z-index: 40;
      display: grid;
      place-items: center;
      padding: 20px;
    }
    .topic-modal-backdrop {
      position: absolute;
      inset: 0;
      background: rgba(20, 27, 36, 0.28);
    }
    .topic-modal-card {
      position: relative;
      width: min(100%, 420px);
      max-height: min(76vh, 620px);
      overflow: auto;
      background: var(--white);
      border: 1px solid rgba(201, 209, 220, 0.24);
      border-radius: var(--radius-xl);
      box-shadow: var(--shadow-modal);
      padding: 18px 18px 16px;
    }
    .topic-modal-head {
      margin-bottom: 14px;
    }
    .topic-modal-title {
      margin: 0 0 6px;
      color: var(--text-primary);
      font-size: 1rem;
      line-height: 1.2;
    }
    .topic-modal-copy {
      margin: 0;
      color: var(--soft);
      font-size: 0.9rem;
      line-height: 1.4;
    }
    .topic-modal-list {
      display: flex;
      flex-direction: column;
      gap: 16px;
      margin-bottom: 14px;
    }
    .topic-section {
      display: flex;
      flex-direction: column;
      gap: 8px;
    }
    /* Business English big-category header (sections → groups → topics). */
    .topic-tier-title {
      margin: 14px 0 2px;
      color: var(--topic-head);
      font-size: 1.06rem;
      font-weight: 700;
      line-height: 1.3;
    }
    .topic-tier-title:first-child { margin-top: 0; }
    /* Indent the subgroups so they read as children of the big category. */
    .topic-section-biz { padding-left: 10px; }
    .topic-section-title {
      margin: 0;
      color: var(--topic-head); /* default = muted warm red; dark scene = white-grey */
      font-size: 0.92rem;
      font-weight: 500;      /* lighter than the modal title; "not too bold" */
      line-height: 1.3;
    }
    .topic-section-subtitle {
      margin: 0;
      color: var(--soft);
      font-size: 0.78rem;
      line-height: 1.4;
    }
    .topic-section-chips {
      display: flex;
      flex-wrap: wrap;
      gap: 10px;
      margin-top: 4px;
    }
    .topic-chip {
      border: 1px solid var(--topic-chip-border, rgba(76, 153, 132, 0.18));
      background: var(--topic-chip-bg, var(--chip-green));
      color: var(--topic-chip-ink);
      padding: 10px 14px;
      border-radius: var(--radius-pill);
      font-size: 0.88rem;
      font-weight: 500;           /* +100 from 400 — Latin reads heavier; CJK weight diff is small */
    }
    .topic-chip-locked {
      background: var(--surface-alt);
      color: var(--text-faint);
      border-color: var(--border);
      opacity: 0.85;
      cursor: pointer;  /* still clickable — shows signup modal */
    }
    .topic-chip-locked:hover {
      background: var(--border);
      color: var(--text-subtle);
    }
    .topic-close-btn {
      width: 100%;
      justify-content: center;
    }
    /* ════════════════════════════════════════════════════════════════════
     * §7 · BROWSER WARNING + STATUS LINE
     *     Chrome/Safari-only warning panel (Firefox users see it).
     *     Status line below messages ("Ready." / "Paro speaking..." /
     *     "Recording...") + frontend debug line (admin only).
     * Scope: shared (all levels)
     * ════════════════════════════════════════════════════════════════════ */

    .browser-warning-msg {
      margin: 40px auto 0;
      max-width: 280px;
      text-align: center;
      padding: 24px 20px;
      background: var(--warning-bg);
      border: 1px solid var(--warning-border);
      border-radius: var(--radius-xl);
    }
    .browser-warning-icon {
      font-size: 2.4rem;
      margin-bottom: 10px;
    }
    .browser-warning-title {
      font-size: 1.05rem;
      font-weight: 600;
      color: var(--text-primary);
      margin-bottom: 8px;
    }
    .browser-warning-body {
      font-size: 0.88rem;
      color: var(--text-subtle);
      line-height: 1.5;
    }
    .status-line {
      min-height: 0.9em;
      padding: 0;
      font-size: 0.68rem;
      font-weight: 400;
      color: var(--status);
      justify-self: center;
      text-align: center;
    }
    .status-line.warning {
      background: transparent;
      color: var(--status);
      padding: 0;
      border-radius: 0;
      box-shadow: none;
      max-width: none;
      font-size: 0.68rem;
      font-weight: 400;
      line-height: normal;
    }
    .debug-line {
      display: none;
    }
    .frontend-debug-on .debug-line {
      display: block;
      min-height: 0.9em;
      padding: 0;
      font-size: 0.62rem;
      font-weight: 400;
      color: var(--debug);
      justify-self: center;
      text-align: center;
      max-width: 90%;
      word-break: break-word;
    }
    /* ════════════════════════════════════════════════════════════════════
     * §8 · CHAT — SHARED   (messages container + bubble base infrastructure)
     *     .messages grid container, .bubble base shape/shadow, .speaker
     *     label (Paro: / You: prefix), .dialog-speaker (L3 inline NPC name),
     *     .target-phrase (L1/L2/L5/L3 highlighted phrase),
     *     .phrase-translation (- meaning_xx line), .msg-para paragraph
     *     spacing, .flow-hint transition cue, .phrase-gap.
     * Scope: shared — every chat bubble across L1/L2/L3/L4/L5 inherits
     *        from .bubble base. Per-level overrides live in §10 / §11.
     * ════════════════════════════════════════════════════════════════════ */

    .messages {
      min-height: 0;
      overflow: auto;
      display: grid;
      align-content: start;
      /* Default gap — applies to L1/L2/L4/L5 where bubbles are pure
         conversation sequence without round dividers. */
      gap: 10px;
      /* Breathing room below the newest bubble (~1/4 screen) so a fresh reply /
         prompt floats up off the composer instead of sticking to the mic — no
         need to scroll up to read it. Tune this vh number to taste. */
      padding-bottom: 25vh;
      width: 100%;
    }
    /* L3 mini-dialog: tighter gap. L3 has .bubble.your-turn margin-top
       (40px) acting as round divider, so bubble-to-bubble gap can be
       collapsed without losing visual structure. Detected via :has() —
       only L3 generates .bubble.your-turn elements. */
    .messages:has(.bubble.your-turn) {
      gap: 3px;
    }
    .bubble {
      max-width: 92%;
      line-height: 1.4;
      padding: 11px 14px;
      border-radius: var(--radius-bubble);
      /* blur 3px (was 10px) — Chrome triggers a GPU composited layer
         for box-shadows with blur ≥ ~5-6px. The composited layer's
         text antialiasing visibly differs from non-composited siblings
         (slightly heavier + softer glyphs), making bubble text look
         thicker/blurrier than the right-panel dashboard text that has
         no shadow. 3px blur keeps the soft "lifted" cue but stays
         under the compositing threshold → text renders consistently. */
      box-shadow: var(--shadow-bubble);
      font-size: 0.93rem;
      /* Default weight is for Latin-script learners (en/it/fr/etc.) — 420
         is barely past Regular, just enough variable-font bump on DM Sans
         to feel a touch more solid. Chinese learners are downgraded to
         400 below to avoid CJK feeling too heavy. */
      font-weight: 420;
      white-space: pre-wrap;
    }
    body.learner-cjk .bubble {
      /* 400 — aligns with .flow-hint CJK weight so all bubble body
         text feels consistent under CJK interface. History: 390/395
         → 398 → 405 → 400 (user feedback: 405 felt heavier than the
         .flow-hint reference at 400). */
      font-weight: 400;
    }
    body.learner-cjk .speaker {
      /* CJK speaker labels ("Paro: ", "You: ") at 500 felt heavier
         than adjacent body text at 400. Drop to 400 to match. */
      font-weight: 400;
    }
    .speaker {
      /* De-emphasised so the brick-red .target-phrase pops first.
         font-size 0.9rem + lighter grey colour push the role label
         (Paro: / Host: / You:) into secondary visual rank — still
         legible, but the eye lands on the phrase before the speaker. */
      font-weight: 500;
      font-size: 0.9rem;
      letter-spacing: -0.01em;
    }
    .assistant .speaker {
      /* Medium-light grey (var(--text-subtle)) — softer than --text-body (#333)
         so the tutor label fades back; brand emphasis lives on the
         target-phrase, not the speaker name. */
      color: var(--text-subtle);
    }
    .user .speaker {
      /* Same de-emphasised grey on the user side too. Symmetry between
         "Paro:" and "You:" — neither competes with the message body. */
      color: var(--text-subtle);
    }
    /* L3 dialog speaker label (e.g. "Friend:" / "You:" inside a preview
       block or NPC bubble). Same de-emphasis as the bubble's .speaker
       prefix — smaller + greyer — so the brick-red .target-phrase stays
       the visual focal point. */
    .dialog-speaker {
      color: var(--text-subtle);
      font-size: 0.9rem;
      font-weight: 500;
    }
    .target-phrase {
      /* display:inline (NOT inline-block!) — inline-block treats the whole
         phrase as one atomic block, so a long phrase wrapping orphans the
         "3." prefix on the previous line. Plain inline lets long phrases
         wrap mid-text, keeping the number with its phrase. */
      display: inline;
      font-size: 1rem;
      /* 500 = "medium" — dialed back from 515 to feel less bold while
         still distinct from body text (420). 515 read as faux-bold next
         to body, especially on long lessons; 500 keeps the emphasis but
         lighter on the eye. Brick red color still does most of the
         "this is target" lifting. */
      font-weight: 500;
      /* Brick red (#a83232) — softer than pure dark red but still high
         contrast against the cream/dog bubble. Reads more authoritatively
         than the prior caramel orange (#cc7435) without going as heavy as
         pure dark red. Tutor brand stays on the speaker label; the target
         phrase gets its own accent that doesn't compete with --accent
         (#a85a3c). */
      color: var(--brick-red);
    }
    /* Topic-complete summary callout — brand-teal badge wrapping the
       "✅ This topic, level X, score Y%" line at the top of the review
       block. Inline-block + padding makes it a self-contained "card"
       chip. Teal pairs with the Go button + phone-frame (same var(--brand)
       family) so the callout reads as "ParoMe brand moment" rather than
       generic success-green / failure-red. Works for both ✅ (high
       score) and ❤️ (lower score) — bg is sentiment-neutral, the emoji
       carries the verdict.
       Iteration history (in case you want to revert):
         mint-green:  bg #eaf5ef, color #1d5e55
         brick-red:   bg rgba(219, 90, 90, 0.08), color #C85A5A
         warm-gold:   bg rgba(232, 180, 70, 0.10), color #B8901E
         background: rgba(140, 193, 203, 1.0);
         color: #2f4b3e;*/

    .topic-complete-summary {
      display: inline-block;
      background: var(--complete-bg);
      color: var(--complete-ink);
      font-weight: 500;
      padding: 4px 12px;
      border-radius: var(--radius-pill);
      line-height: 1.4;
    }

    /* Lesson-complete bubble needs a clear visual break from the
       practice attempts above (especially the trailing score chips
       like [100%] Ben fatto ✅). With .messages { gap: 3px } the
       bubble was glued to the last score line — no "this lesson ended"
       beat. 18px top margin gives the eye time to register the shift
       from "still playing" to "lesson complete" before reading the
       summary. */
    .bubble.assistant:has(.topic-complete-summary) {
      margin-top: 18px;
    }
    body.learner-cjk .topic-complete-summary {
      /* CJK fonts at weight 500 read as semi-bold; back off to 460 so
         the callout doesn't feel "shouted" in Chinese / Japanese / Korean. */
      font-weight: 460;
    }
    /* ════════════════════════════════════════════════════════════════════
     * §9 · CHAT — L5 ONLY   (.pronun-rule callout)
     *     The 🎯 green pill that shows the lesson's pronunciation rule
     *     above the phrase list. Only L5 emits [PRON]...[/PRON] markers.
     * Scope: L5 only
     * ════════════════════════════════════════════════════════════════════ */

    /* L5 pronunciation-rule callout — the topic's sound rule shown above
       the phrase list (🎯 prefix added in main.js). Same chip shape as
       .topic-complete-summary but a clearer green so it reads as
       "here's the rule for this lesson", not "you finished". block (not
       inline-block) so a long rule wraps cleanly and the chip spans the
       bubble width. */
    .pronun-rule {
      display: block;
      background: var(--pronun-rule-bg, #e3f4e1);
      color: var(--pronun-rule-ink, #1f5d2a);
      font-weight: 500;
      padding: 8px 14px;
      border-radius: var(--radius-md);
      line-height: 1.5;
      margin: 2px 0 4px;
    }
    body.learner-cjk .pronun-rule {
      font-weight: 460;
      /* CJK glyphs read larger; nudge down so the rule doesn't dominate
         the phrase list it introduces. */
      font-size: 0.92rem;
    }

    .phrase-translation {
      display: block;
      font-size: 0.9rem;
      /* Lightened from --soft (var(--soft)) to var(--text-subtle) so the meaning line reads as
         an annotation under the target phrase, not co-equal body text.
         Combined with the .85em indent below, the eye treats it as a
         "footnote" while still being legible (var(--text-subtle) = 6.3:1 on white = AAA). */
      color: var(--text-subtle);
      /* Indented ~12px so it visually nests under the phrase above —
         matches the "comment / sub-text" pattern, less visual weight than
         a hard line-up with the left margin. Uses em (not px) so the
         indent scales with bubble font size. */
      padding-left: 0.85em;
      /* tight gap to the phrase above so the numbered item + translation
         read as one tight unit. Reduced margin-bottom from 12px to 6px:
         with the trailing-\n consumption in main.js, the gap between
         consecutive numbered items no longer needs to fight a preserved
         pre-wrap newline; 6px is enough to separate items visually
         without creating the airy 40px chasm we had before. */
      margin-top: -2px;
      margin-bottom: 6px;
    }
    /* CJK learner: scale translation from 0.9rem to 0.89rem. CJK glyphs
       fill more visual space than Latin at the same px size, but a too-
       aggressive shrink (was 0.84rem) reads as fine print on phones — the
       translation IS the learner's anchor for understanding the phrase.
       0.89rem keeps it visibly secondary to the 0.93rem brick-red phrase
       above (~0.6px gap + weight + color contrast carry the hierarchy)
       while staying comfortably readable. */
    body.learner-cjk .phrase-translation {
      font-size: 0.89rem;
    }
    /* Two adjacent translations under one phrase (pinyin + meaning, or
       transliteration + meaning) read as one block — kill the 12px gap
       between them so they sit on consecutive lines. The 12px below the
       last one still separates from the next numbered item. */
    .phrase-translation + .phrase-translation {
      margin-top: -10px;
    }
    /* hide redundant <br> tags around translation blocks */
    /* Hide the redundant <br> right after a phrase-translation: that span
       is display:block so it auto-newlines; the <br> would add an extra
       blank line between numbered items. */
    .phrase-translation + br { display: none; }
    /* NOTE: previously also had `.target-phrase + br { display: none }` for
       the same reason in preview, but it accidentally collapsed the review
       section ("1. phrase<br>2. phrase<br>..." all on one line). Now we
       consume the leading \n in the phrase-translation regex so target-phrase
       sits IMMEDIATELY before phrase-translation (no <br> between), and we
       don't need this hide rule. <br> after a bare target-phrase (review,
       Repeat-after-me) renders as intended. */
    .phrase-gap {
      display: block;
      height: 0;
    }
    /* Paragraphs of assistant text — backend uses `\n\n` to separate them
       (markdown convention). formatAssistantText emits the FIRST paragraph
       as inline content (joins the "Gelato:" speaker label same line) and
       wraps SUBSEQUENT paragraphs in <span class="msg-para"> with display:
       block. We use <span>+display:block instead of <div> because the
       outer wrapper in appendMessage is a <span> and span-containing-div
       is invalid HTML — browser silently collapsed the margin. <span>
       inside <span> with CSS block is valid and reliable. */
    .msg-para {
      display: block;
      margin-top: 14px;
    }
    .flow-hint {
      /* Body text color — "Next phrase." / "Now, let's move to practice."
         no longer green-emphasized; they're transitional cues, not the
         main content the learner needs to focus on. */
      color: var(--text-body);
      /* Same learner-script-aware weight as .bubble: Latin gets a subtle 420
         bump (DM Sans variable), Chinese stays at 400 to keep CJK comfortable. */
      font-weight: 420;
    }
    body.learner-cjk .flow-hint {
      font-weight: 400;
    }
    /* ════════════════════════════════════════════════════════════════════
     * §10 · CHAT — L3 ONLY   (mini-dialog UI)
     *     .bubble.your-turn          — the "say this now" prompt bubble
     *     .bubble-prefix-label       — floating "Your turn" / "Try again" /
     *                                  NPC speaker name above bubble
     *     .bubble.score-feedback     — right-aligned floating [N%] text
     *     .topic-complete-summary    — ❤️ score callout at lesson end
     *     yourTurnPulse keyframes    — shadow ring breathing animation
     *     translation tooltip        — :has(.bubble-prefix-label) hover reveal
     *     l3-retry-shake / -flash    — bubble shake + red halo on miss
     *     .bubble.your-turn.retry    — retry state mechanics
     *     .l3-round-first            — first round of dialog (no top divider)
     *
     *     Scoped via either explicit class or :has() — L1/L2/L5 bubbles
     *     never carry .your-turn or .bubble-prefix-label, so these rules
     *     don't bleed cross-level.
     * Scope: L3 only
     *
     *     NOTE: rules here are split into 3 chunks intentionally NOT
     *     consolidated (would change CSS cascade order and risk regressions).
     *     §10A here covers your-turn bubble + speaker labels + translation.
     *     §10B (line ~1380) is score-feedback + retry shake.
     *     §10C (line ~1430) is retry your-turn + round margins.
     * ════════════════════════════════════════════════════════════════════ */

    /* L3 mini-dialog "Your turn" PROMPT bubble — right side, but a different
       color from the user's own reply bubble so the student doesn't confuse
       "what I should say" (this) with "what I just said" (.bubble.user).
       Light amber/cream = "spotlight on the line you're about to read". */
    .bubble.your-turn {
      /* Match .user transcript bubble: same pale mint fill + deep
         forest text. Distinction from .user is a visible thin green
         border (the .user is borderless), so right-aligned mint
         bubbles still tell apart "what to say" vs "what I said".
         Big rounded corners + no tail. */
      background: var(--user);                /* #e6f5f0 — same pale mint as .user */
      color: var(--user-ink, var(--forest-deep));   /* user-bubble text (default #1d5e55 green) */
      border: 1px solid var(--user-frame, #b5d6c4); /* thin frame — only on .your-turn (default green) */
      border-radius: var(--radius-xl);
      justify-self: end;
      width: fit-content;
      max-width: 92%;
      animation: yourTurnPulse 1.8s ease-in-out infinite;
    }
    /* L3 target phrase inside .your-turn — no bold (user request).
       Keep the deep forest color (inherits from bubble) so the phrase
       reads as the main content of the bubble without competing weight.
       Overrides the earlier `.bubble.your-turn strong { color: var(--brick-red);
       font-weight: 550 }` rule via later cascade. */
    .bubble.your-turn strong {
      color: inherit;
      font-weight: 400;
    }
    body.target-cjk .bubble.your-turn strong {
      font-weight: 400;
    }
    /* Only the latest your-turn pulses. Older ones (Excuse me… / Is it
       far… etc.) stay calm so the screen doesn't sparkle in three places
       at once. :has() selector = any your-turn that has another your-turn
       sibling after it → freeze. This ALSO covers passed retries — once
       the next round starts, the previous retry bubble drops its red
       halo and goes calm. */
    .bubble.your-turn:has(~ .bubble.your-turn) {
      animation: none;
    }
    /* L3-only: bump radius on the .user transcript bubble + the
       .assistant NPC bubble to match the bigger rounded your-turn.
       Scoped via `.messages:has(.bubble.your-turn)` so L1/L2 bubbles
       (no your-turn class anywhere) keep their current radius. */
    .messages:has(.bubble.your-turn) .bubble.user,
    .messages:has(.bubble.your-turn) .bubble.assistant:has(.bubble-prefix-label) {
      border-radius: var(--radius-xl);
    }
    /* Floating prefix label above the your-turn bubble — "Your turn" or
       "Try again" (extracted in JS appendMessage). Sits above the bubble
       on the right edge (since bubble is right-aligned) so the eye reads
       "label → bubble" top-to-bottom.
       (position: relative + margin-top: 40px are set on .bubble.your-turn
       elsewhere; this rule just styles the floating label child.) */
    .bubble-prefix-label {
      position: absolute;
      bottom: 100%;
      right: 14px;                 /* default: right-side bubbles (.your-turn) */
      margin-bottom: 3px;
      font-size: 0.7rem;
      font-weight: 500;
      letter-spacing: 0.04em;
      color: var(--meta-gray);              /* soft warm gray — meta-info register */
      background: transparent;
      padding: 0;
      white-space: nowrap;
      pointer-events: none;        /* label is decorative; clicks go to bubble */
    }
    /* NPC bubble (.assistant with dialog-speaker) is left-aligned, so
       its floating speaker label needs to anchor LEFT not right. Mirrors
       the .your-turn label exactly but on the opposite side. */
    .bubble.assistant:has(.bubble-prefix-label) {
      position: relative;
      margin-top: 40px;            /* room for label above (matches your-turn) */
    }
    .bubble.assistant .bubble-prefix-label {
      right: auto;
      left: 14px;
    }
    /* When this is a Try again retry, tint the label red to match the
       retry shake/flash cue. */
    .bubble.your-turn.retry .bubble-prefix-label {
      color: var(--brick-red);
    }
    /* L3 NPC bubble phrase — backend wraps the target phrase in **bold**
       (`[SP]Friend:[/SP] **Vuoi andare al parco?**`), main.js converts
       to <strong>, which the browser defaults to font-weight: 700.
       That reads heavier than needed — the white bubble + speaker label
       already mark this as "the phrase to learn". 500 keeps a subtle
       weight cue without the chunky bold register. Scoped to NPC
       per-turn bubbles only (`.bubble-prefix-label` is the JS-extracted
       speaker tag, present only on L3 dialog NPC turns) so L1/L2
       preview phrases still get the full ** emphasis. */
    .bubble.assistant:has(.bubble-prefix-label) strong {
      font-weight: 500;
    }
    @keyframes yourTurnPulse {
      /* Match .bubble blur=3px so the pulsing your-turn bubble doesn't
         flip in/out of composited-layer rendering on each animation frame. */
      0%, 100% { box-shadow: var(--shadow-bubble); }
      50%      { box-shadow: 0 2px 3px rgba(0, 0, 0, 0.06), 0 0 0 6px var(--user-glow, rgba(40, 91, 83, 0.22)); }
    }
    /* ════════════════════════════════════════════════════════════════════
     * §11 · CHAT — L1/L2/L5   (phrase recall cues)
     *     .target-phrase-blink     — amber-bg blink on a .target-phrase
     *                                span inside the prior "Repeat after
     *                                me" bubble (recall cue when learner
     *                                ==target, e.g. learning English in
     *                                English UI).
     *     .bubble.recall-pulse     — bubble-level halo (DISABLED 2026-05-26;
     *                                see comment block below — class is
     *                                still added by JS for easy revert).
     *     targetPhraseBlink keyframes — the amber background animation.
     *
     *     Frontend adds these via main.js when backend sends
     *     `practice_blink: true` (see lesson_flow.py:328).
     * Scope: L1 / L2 / L5 same-language practice case
     * ════════════════════════════════════════════════════════════════════ */

    /* Inline phrase pulse: applied to the .target-phrase span in the prior
       "Repeat after me: @@phrase@@" bubble when learner==target. Tells the
       learner where to look to recall the phrase (since the practice
       question doesn't show the answer in target-language). Same amber
       palette as L3's yourTurnPulse, but on background instead of shadow. */
    @keyframes targetPhraseBlink {
      0%, 100% { background-color: transparent; }
      50%      { background-color: rgba(245, 166, 35, 0.45); }
    }
    .target-phrase-blink {
      animation: targetPhraseBlink 1.4s ease-in-out infinite;
      border-radius: var(--radius-sm);
      padding: 1px 6px;
      /* "Raised card" look — soft shadow + 1px tinted outline so the
         highlighted phrase reads as a small floating card the eye can
         lock onto. Pairs with the bg blink. box-decoration-break: clone
         keeps the look intact when the phrase wraps to a 2nd line. */
      box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(204, 116, 53, 0.25);
      box-decoration-break: clone;
      -webkit-box-decoration-break: clone;
    }
    /* Bubble-level halo USED TO be applied alongside .target-phrase-blink
       so the prior "Repeat after me" bubble pops the same way L3's
       .your-turn does (yourTurnPulse shadow ring). Disabled 2026-05-26 —
       two simultaneous pulses (outer halo + inner phrase blink) competed
       for attention; the inline phrase blink alone is enough to draw the
       eye to "A dozen eggs", and the outer halo turned the whole bubble
       into a second strobing object. Class kept in JS / CSS so re-enabling
       is a one-line revert. */
    .bubble.recall-pulse {
      animation: none;
    }
    /* Tighten the gap between the bolded phrase line and its translation
       inside the L3 mini-dialog YT bubble specifically — default
       phrase-translation has 12px margin-bottom which is right for L1/L2
       review (numbered list spacing) but creates an awkward gap inside the
       compact YT prompt bubble. Scoped to .your-turn so L1/L2 is untouched. */
    .bubble.your-turn .phrase-translation {
      margin-bottom: 2px;
    }
    /* L3 round grouping — each "Your turn:" bubble starts a new conversation
       round (user-turn → score → NPC-reply). Give it extra top margin +
       a hairline divider so the eye can track which bubbles belong
       together. First .your-turn after the preview doesn't need the
       extra space (preview already separates), so :not(:first-of-type)
       — but in practice messagesEl has many other bubbles before, so
       use a more reliable check: skip the divider on the very first
       .your-turn child in messagesEl (handled in JS by adding
       `.l3-round-first` class to suppress). */
    /* L3 translation as FLOATING TOOLTIP (not inline).
       Earlier rev toggled display:none/block which made the bubble
       grow/shrink → all bubbles below jumped on hover → "screen shake."
       Now translation sits as an absolutely-positioned tooltip below
       the bubble: doesn't take layout space, bubble stays the same
       height, no reflow.
       Reveal mechanism is platform-aware:
         • Desktop (true hover device): mouse hover the bubble → peek.
         • Touch (no hover): single tap toggles sticky `.show-translation`.
       Scoped to L3 bubbles (.your-turn + assistant bubbles containing
       a .dialog-speaker) so L1/L2 preview translations stay visible. */
    .bubble.your-turn,
    .bubble.assistant:has(.bubble-prefix-label),
    .bubble.assistant.tap-translate {
      position: relative;       /* anchor for the absolute tooltip child */
    }
    .bubble.your-turn .phrase-translation,
    .bubble.assistant:has(.bubble-prefix-label) .phrase-translation,
    .bubble.assistant.tap-translate .phrase-translation {
      /* Float as tooltip ABOVE the bubble — visible state via
         opacity+visibility (not display) so transitions work and layout
         stays put. For .your-turn the bubble has a floating "Your turn"
         label just above (at bottom:100%, right-aligned), so the
         tooltip uses calc(100% + 22px) to clear that label vertically. */
      position: absolute;
      bottom: calc(100% + 22px);
      left: 50%;
      transform: translate(-50%, 0);
      z-index: 50;
      visibility: hidden;
      opacity: 0;
      pointer-events: none;
      transition: opacity 0.12s ease;
      /* Light bg + dark text (user explicitly asked: NOT black-on-white
         like the 🎧 vocab tooltip). Cream-white card with soft shadow. */
      background: var(--surface-warm);
      color: var(--text-primary);
      padding: 7px 12px;
      border-radius: var(--radius-sm);
      border: 1px solid rgba(180, 140, 110, 0.18);
      box-shadow: var(--shadow-md);
      font-size: 0.82rem;
      font-weight: 400;
      line-height: 1.4;
      white-space: normal;          /* allow wrap if translation is long */
      min-width: max-content;
      max-width: min(280px, 90vw);
      text-align: center;
    }
    /* NPC bubble (.assistant:has(.dialog-speaker)) has NO floating label,
       so its translation tooltip sits closer to the bubble — just 6px
       above instead of 22px. Tighter visual coupling. */
    .bubble.assistant:has(.bubble-prefix-label) .phrase-translation {
      bottom: calc(100% + 6px);
    }
    /* L1/L2 tap-translate: tooltip 顯示在 bubble 下方（不是上方）。
       Override base 的 bottom: calc(100% + 22px) → 改用 top。
       Arrow 翻轉成從 tooltip 上邊指上。 */
    .bubble.assistant.tap-translate .phrase-translation {
      bottom: auto;
      top: calc(100% + 8px);
    }
    /* Little arrow pointing DOWN from the tooltip to the bubble. */
    .bubble.your-turn .phrase-translation::before,
    .bubble.assistant:has(.bubble-prefix-label) .phrase-translation::before {
      content: "";
      position: absolute;
      top: 100%;
      left: 50%;
      transform: translateX(-50%);
      border: 6px solid transparent;
      border-top-color: var(--surface-warm);
    }
    /* L1/L2 tap-translate: arrow 在 tooltip 上方指向 bubble。 */
    .bubble.assistant.tap-translate .phrase-translation::before {
      content: "";
      position: absolute;
      bottom: 100%;
      top: auto;
      left: 50%;
      transform: translateX(-50%);
      border: 6px solid transparent;
      border-bottom-color: var(--surface-warm);
      border-top-color: transparent;
    }
    /* Sticky reveal — set by single tap on touch (JS click handler). */
    .bubble.your-turn.show-translation .phrase-translation,
    .bubble.assistant.show-translation .phrase-translation {
      visibility: visible;
      opacity: 1;
      pointer-events: auto;
    }
    /* Hover peek — only on true hover devices (mouse / trackpad).
       (hover: hover) excludes touch screens which would otherwise
       trigger sticky hover on first tap and never release. */
    @media (hover: hover) and (pointer: fine) {
      .bubble.your-turn:hover .phrase-translation,
      .bubble.assistant:has(.bubble-prefix-label):hover .phrase-translation,
      .bubble.assistant.tap-translate:hover .phrase-translation {
        visibility: visible;
        opacity: 1;
      }
    }
    /* 🔍 hint icon removed per user request — translations reveal on hover
       (desktop) or tap (touch) without a visible cue。Scope kept narrow in
       case we want to re-enable selectively later。 */
    .bubble._never_shown_hint::after {
      content: "🔍";
      display: inline-block;
      font-size: 0.85rem;
      margin-left: 6px;
      opacity: 0.55;                           /* dim so it reads as hint, not action */
      cursor: pointer;
      vertical-align: middle;
    }
    .bubble.your-turn:has(.phrase-translation),
    .bubble.assistant:has(.bubble-prefix-label):has(.phrase-translation),
    .bubble.assistant.tap-translate:has(.phrase-translation) {
      cursor: zoom-in;                          /* 🔍-like cursor — `?` 看起來像壞掉 */
      /* user-select: none was here — removed 2026-05 because it
         blocked legitimate copy-the-Italian-phrase use case (paste to
         Translate / dictionary / notes). The original concern was that
         tap-to-reveal-translation on mobile might trigger accidental
         text selection, but in practice the tap fires the JS reveal
         handler before selection kicks in, and long-press → select is
         the user's intentional action anyway. */
    }

    /* Score-feedback bubbles ("[83%] Va bene 💪") — visually distinct
       from NPC dialog. Pill shape, no bubble shadow, narrower padding,
       muted background. Reads as "judge / referee" rank, not a
       conversational participant. JS adds `.score-feedback` when the
       bubble text starts with [NN%]. */
    /* L3 score "pill" — restyled as a floating right-side text label,
       NOT a pill. Mirrors `.bubble-prefix-label` (Try again) but anchored
       to the BOTTOM of the your-turn bubble instead of the top. Same
       font (0.7rem / weight 500 / soft warm gray / 0.04em letter-spacing)
       so [74%] Ci siamo 💪 reads as the verdict META-info, not as a
       conversational bubble. Scoped under `.messages:has(.bubble.your-turn)`
       to keep this L3-only — `.score-feedback` class is only generated
       for L3 anyway, but the scope guards against any future reuse. */
    /* ════════════════════════════════════════════════════════════════════
     * §10B · CHAT — L3 (continued — score-feedback + retry mechanics)
     *     Second L3 block: score-feedback floating chips, l3-retry-shake +
     *     l3-retry-flash keyframes, .bubble.your-turn.retry state,
     *     .l3-round-first margin override.
     * Scope: L3 only
     * ════════════════════════════════════════════════════════════════════ */

    .messages:has(.bubble.your-turn) .bubble.score-feedback {
      background: transparent !important;       /* no chip — floats as text on canvas */
      box-shadow: none !important;
      padding: 0;
      border-radius: 0;
      font-size: 0.7rem;
      font-weight: 500;
      letter-spacing: 0.04em;
      color: var(--meta-gray);                          /* same soft warm gray as prefix-label */
      max-width: max-content;
      /* .messages is display:grid (not flex) — `align-self` was wrong;
         grid uses `justify-self` for horizontal alignment. Mirror what
         .bubble.your-turn does at line 969. */
      justify-self: end;                       /* right side — same column as your-turn */
      margin-top: 3px;                         /* tight — sits right under bubble */
      margin-bottom: 0;
      margin-right: 14px;                      /* matches prefix-label inset */
    }
    /* Two pills in a row (first-miss + retry-pass) stack tightly under
       the same .your-turn — collapse the gap. */
    .messages:has(.bubble.your-turn) .bubble.score-feedback + .bubble.score-feedback {
      margin-top: 1px;
    }
    .bubble.score-feedback .speaker {
      display: none;                            /* label doesn't need speaker */
    }

    /* Retry shake + flash — bubble jiggles AND pulses a red halo twice
       when backend sends [YT_RETRY] (user missed; same prompt being
       re-attempted). Shake = "wrong" body language, double-flash = "look
       again at this" signal. Class set by main.js; remove + reflow + add
       to replay animations on subsequent retries (same bubble can flash
       multiple times if the learner misses 3+). */
    @keyframes l3-retry-shake {
      0%, 100% { transform: translateX(0); }
      15%      { transform: translateX(-4px); }
      30%      { transform: translateX(4px); }
      45%      { transform: translateX(-3px); }
      60%      { transform: translateX(3px); }
      75%      { transform: translateX(-1px); }
    }
    @keyframes l3-retry-flash {
      0%, 100% {
        box-shadow: var(--shadow-bubble);
        background: var(--user-bubble-bg, var(--user));
      }
      25%, 75% {
        box-shadow: 0 0 0 3px rgba(168, 50, 50, 0.55), 0 2px 8px rgba(168, 50, 50, 0.3);
        background: var(--brick-red-wash);     /* pale pink wash — readable, not alarming */
      }
    }
    .bubble.your-turn.retry {
      /* Two-stage: shake (0.5s once, the "wrong, try again" body
         language) → mint yourTurnPulse (starts at 0.5s delay, infinite,
         same as a fresh Your turn). No red — color stays neutral mint
         so the bubble doesn't read as "error", just "talk again". */
      animation:
        l3-retry-shake 0.5s ease-in-out,
        yourTurnPulse 1.8s ease-in-out 0.5s infinite;
    }
    /* Past retry (next round already started) → freeze. Inherits the
       generic :has() rule above, but re-stated here for clarity. */
    .bubble.your-turn.retry:has(~ .bubble.your-turn) {
      animation: none;
    }

    .bubble.your-turn {
      /* Top margin = round divider via whitespace (we removed the
         hairline `::before` rule). 40px gap = ~15px for the floating
         "Your turn" / "Try again" label + ~25px clear whitespace —
         comfortable separation between rounds. Was iterating tighter
         (25/20px) but reverted when L1/L2 user bubbles seemed to be
         disappearing — playing safe with the proven value. */
      margin-top: 40px;
      position: relative;
    }
    .bubble.your-turn.l3-round-first {
      margin-top: 24px;          /* first round: less space — nothing above */
    }
    /* L3 "Your turn:" phrase — experiment: brick red #a83232 to match
       .target-phrase across preview/review/repeat blocks. Unifies the
       "this is what you're meant to say" signal under one color across
       L1/L2/L3, so the learner builds a single visual mental model.
       Weight 550 + bubble halo still differentiate this from passive
       preview phrases. (Was #1d5e55 forest-deep — kept inline as fallback
       reference if we revert.) */
    .bubble.your-turn strong {
      /* User request: phrase NOT bold. Inherits #1B5E20 deep forest
         from .bubble.your-turn so it sits on the pastel mint bg with
         high contrast but without weight-emphasis. The bubble's
         pastel fill + right-alignment + floating "Your turn" label
         already signal "this is what you say"; bold was redundant. */
      color: inherit;
      font-weight: 400;
    }
    body.target-cjk .bubble.your-turn strong {
      font-weight: 400;
    }
    /* L3 mic-capture acknowledgement: shift the mic button to the same
       light-green that user bubbles use, so it visually echoes "this is
       your side" without being loud. Resets to white after ~600ms. */
    .record-btn.mic-captured-pulse {
      background: var(--user);
      color: var(--ink);
      transition: background 0.18s ease;
    }
    /* Legacy inline marker (kept as no-op so plain text shows even if frontend
       split logic miss-fires). */
    .your-turn-prompt { font-weight: 500; }
    body.learner-cjk .your-turn-prompt { font-weight: 400; }
    .next-phrase-label {
      display: inline-block;
      color: var(--correct);
      font-size: 0.95em;
      font-weight: 600;
    }
    body.target-cjk .target-phrase {
      /* 430 (was 450) — CJK fonts (Noto Sans TC/SC) have coarse weight
         granularity that rounds toward the nearest of 400/500/700.
         430 lands closer to 400 = stays comfortably light while still
         a hair heavier than body text (CJK body = 400). Mirrors the
         Latin reduction 515→500. */
      font-weight: 430;
    }
    /* Latin target only: tighten brick-red phrase letter-spacing from
       inherited -0.01em to -0.015em so emphasis short phrases read as
       "solidly typeset" rather than airy. Inter / DM Sans handle the
       extra negative tracking cleanly at 1rem 500. SCOPED via :not()
       to skip CJK targets — Chinese/Japanese/Korean square glyphs
       crush badly under negative letter-spacing. */
    body:not(.target-cjk) .target-phrase {
      letter-spacing: -0.015em;
    }


    /* ════════════════════════════════════════════════════════════════════
     * §12 · CHAT — SHARED tail   (bubble color tints + recording feedback)
     *     .assistant — white bg for NPC / Paro bubbles (left side)
     *     .user — pale mint bg for learner transcript bubbles (right side)
     *     .record-btn.mic-captured-pulse — pulse animation on mic capture
     * Scope: shared (all chat-using levels)
     * ════════════════════════════════════════════════════════════════════ */

    .assistant {
      background: var(--dog);
      border: 1px solid rgba(201, 209, 220, 0.18);
      border-bottom-left-radius: 6px;
      justify-self: start;
      width: fit-content;
    }
    .user {
      background: var(--user);
      /* Body text color = user-voice green. The .speaker label inside
         overrides back to default body grey (see .user .speaker above)
         so the message content gets the visual accent, not the "You:"
         prefix. */
      color: var(--user-ink, var(--forest-deep));
      /* No visible border — bubble relies on the soft green bg alone.
         Keep 1px transparent so the layout (size, baseline) doesn't
         shift relative to .assistant which still has a 1px border. */
      border: 1px solid transparent;
      border-bottom-right-radius: 6px;
      justify-self: end;
      width: fit-content;
    }
    /* ════════════════════════════════════════════════════════════════════
     * §13 · COMPOSER (input row + side tools)
     *     .composer + .composer-tools + .composer-side-tools layout,
     *     💡 .topic-lightbulb-btn (opens topic modal §6),
     *     .input-row (#textInput type field + .button-group),
     *     .dashboard-toggle-btn (mobile collapse right panel).
     * Scope: shared (all levels — composer is the bottom input area)
     * ════════════════════════════════════════════════════════════════════ */

    .composer {
      padding: 12px 14px calc(14px + env(safe-area-inset-bottom));
      border-top: 1px solid var(--line);
      background: var(--composer-bg);
      display: grid;
      gap: 8px;
      /* As a grid child of .phone, min-width:auto would let the toolbar's
         min-content (10× 44px buttons = 512px) widen this whole row past
         the 430px phone, dragging .messages with it. Lock to 0 so the
         phone's grid column wins. */
      min-width: 0;
    }
    .composer-tools {
      position: relative;
      display: flex;
      align-items: center;
      gap: 8px;
      min-height: 38px;
      padding-right: 54px;
      /* As a grid child of .composer, min-width defaults to `auto` and
         lets contents (the vocab toolbar with 10+ buttons) blow up the
         column width. Lock to the column: min-width:0 + max-width:100%
         + overflow:hidden so toolbar overflow stays inside the toolbar's
         own scroll container, never widens this row. */
      min-width: 0;
      max-width: 100%;
      overflow: hidden;
    }
    .composer-side-tools {
      position: absolute;
      right: 8px;
      top: 50%;
      transform: translateY(-50%);
      display: flex;
      align-items: center;
      justify-content: flex-end;
      min-width: 38px;
      z-index: 2;
    }
    .topic-lightbulb-btn {
      flex: 0 0 auto;
      width: 38px;
      height: 38px;
      border-radius: var(--radius-full);
      border: none;
      background: var(--bulb);
      color: var(--on-brand);
      font-size: 17px;
      display: flex;
      align-items: center;
      justify-content: center;
      cursor: pointer;
      box-shadow: 0 6px 16px rgba(226, 107, 62, 0.2);
      transition: filter 0.18s ease;
    }
    .topic-lightbulb-btn:hover {
      filter: brightness(1.08);
    }
    .topic-lightbulb-btn:active {
      filter: brightness(0.95);
    }
    .topic-lightbulb-overlay {
      position: absolute;
      left: 50%;
      top: 50%;
      transform: translate(-50%, -50%);
      z-index: 4;
      width: 52px;
      height: 52px;
      font-size: 22px;
      box-shadow: 0 10px 24px rgba(226, 107, 62, 0.22);
    }
    .topic-lightbulb-overlay[hidden] {
      display: none;
    }
    .dashboard-toggle-btn {
      display: none;
      background: var(--white);
      color: var(--orange);
      box-shadow: var(--shadow-sm);
    }
    .input-row {
      position: relative;
      display: flex;
      align-items: center;
      background: var(--input-bg);
      border-radius: var(--radius-xl);
      padding: 4px;
      /* Cap to parent width so nothing inside (textInput + absolute
         button-group) can push the row past the phone's right edge.
         Without this, record-btn was rendering at x=572 on a 430-wide
         phone because flex:1 + width:100% on #textInput was confusing
         the flex sizer. min-width: 0 lets the flex item shrink. */
      max-width: 100%;
      min-width: 0;
    }
    #textInput {
      flex: 1 1 0;
      min-width: 0;
      border: none;
      background: transparent;
      outline: none;
      font-size: 1rem;
      padding: 10px 100px 10px 15px;
    }
    .button-group {
      position: absolute;
      right: 8px;
      display: flex;
      gap: 6px;
      align-items: center;
      z-index: 2;
    }
    /* ════════════════════════════════════════════════════════════════════
     * §14 · MIC / SEND BUTTONS
     *     .record-btn (mic) — circle button, white when idle, red on
     *                         recording, mint flash on capture.
     *     #sendBtn — circle teal button (↑ arrow).
     *     recordHint keyframes — subtle attention pulse when ready.
     * Scope: shared (all levels — bottom of composer)
     * ════════════════════════════════════════════════════════════════════ */

    .record-btn, #sendBtn {
      flex: 0 0 38px;
      width: 38px;
      height: 38px;
      border-radius: var(--radius-full);
      display: flex;
      align-items: center;
      justify-content: center;
      cursor: pointer;
      border: none;
      transition: all 0.2s;
    }
    .record-btn {
      background: var(--record-btn-bg);
      box-shadow: var(--shadow-btn);
      font-size: 1.1rem;
      color: var(--ink);
    }
    /* Pulse when the lesson reaches "Ready." — 3 cycles × 1.8s = ~5.4s
       so the learner has time to notice the cue before it fades.
       JS gates on a live session so the pulse doesn't fire on page
       load (before Go). */
    .record-btn.ready-hint {
      animation: recordHint 1.8s ease-in-out 3;
    }
    @keyframes recordHint {
      0%, 100% {
        box-shadow: var(--shadow-btn);
        transform: scale(1);
      }
      50% {
        /* Pink halo (was red 220,60,60). Less alarming than red — invites
           the learner to record their reply rather than warning them. */
        box-shadow: 0 1px 3px rgba(0,0,0,0.10), 0 0 0 9px var(--mic-hint-glow);
        transform: scale(1.06);
      }
    }
    .record-btn.recording,
    .record-btn.mic-captured-pulse {
      animation: none;
    }
    #sendBtn {
      background: var(--send-btn);
      color: var(--on-brand);
      font-size: 1.3rem;       /* arrow ↑ larger so it reads at a glance */
      font-weight: 600;
      line-height: 1;
    }
    /* ════════════════════════════════════════════════════════════════════
     * §15 · RESPONSIVE @media queries
     *     Mobile / tablet breakpoints. Hides right dashboard on narrow
     *     viewport; surfaces .dashboard-toggle-btn to bring it back.
     * Scope: shared (all viewports)
     * ════════════════════════════════════════════════════════════════════ */

    @media (max-width: 760px) {
      .dashboard-toggle-btn {
        display: flex;
      }
      /* Mobile：Dashboard 會切成全螢幕覆蓋層 */
      .lesson-panel.mobile-open {
        display: grid;
        position: fixed;
        inset: 0;
        z-index: 35;
        width: 100vw;
        /* `100vh` on iOS Safari includes the address bar height — content
           gets pushed below the visible viewport. `100dvh` adjusts as
           Safari's chrome shows/hides. Fallback for ancient browsers
           (pre Safari 15.4 / Chrome 108) is `100vh`. */
        height: 100vh;
        height: 100dvh;
        border-radius: 0;
        border: 0;
        box-shadow: none;
        background: var(--white);
      }
      .lesson-panel.mobile-open .lesson-back-btn {
        display: flex;
      }
      body.mobile-dashboard-open .phone {
        display: none;
      }
      body.mobile-dashboard-open .lesson-panel.mobile-open {
        display: grid;
      }
    }

    @media (min-width: 980px) {
      body {
        place-items: start center;
      }
      .app-shell {
        grid-template-columns: minmax(420px, 430px) minmax(420px, 430px);
        justify-content: center;
        align-items: stretch;
      }
      .phone,
      .lesson-panel {
        width: min(100%, 430px);
        height: 95vh;
      }
      /* Desktop：右側 Dashboard 固定為第二個 phone frame */
      .lesson-panel {
        display: grid;
        background: var(--white);
        /* Dashboard frame can differ from the phone frame (--frame-border):
           a scene sets --panel-frame to give the right panel its own colour.
           Falls back to --frame-border so they match unless overridden. */
        border: var(--panel-frame, var(--frame-border));
        border-radius: var(--radius-xl);
        box-shadow: var(--shadow);
        overflow: hidden;
      }
      .lesson-back-btn,
      .dashboard-toggle-btn {
        display: none !important;
      }
    }

    /* 錄音中狀態 — red fill (matches voice-memo / WhatsApp convention).
       White icon on var(--recording) #ff4d4d. No pulse animation (kept
       calm — the color shift alone is the cue). */
    .record-btn.recording {
      background-color: var(--recording) !important;
      color: var(--on-brand) !important;
      box-shadow: 0 2px 8px rgba(255, 77, 77, 0.35);
    }

/* ════════════════════════════════════════════════════════════════════
 * §16 · VOCAB TOOLBAR   (headphone row above composer)
 *     The horizontal row of 🎧 phrase buttons that let learner replay
 *     individual phrases. .vocab-tooltip = long-press preview popup.
 *     NOTE: rules from here on are at 0-indent (historical drift —
 *     the rest of the file is 4-space indented).
 * Scope: shared (all levels — visible whenever a lesson has audio
 *        phrases to replay)
 * ════════════════════════════════════════════════════════════════════ */

.vocab-toolbar {
  display: flex;
  flex: 1;
  min-width: 0;
  max-width: calc(100% - 54px);
  /* HARD height lock: 44px (= one .vocab-btn). Keeps the composer row a
     fixed height so the input-row (with record + send buttons) stays in
     view no matter how many phrases the backend stuffs into phrase_list. */
  height: 44px;
  /* Force-scroll (not :auto) so the box is reliably a scroll container
     even before overflow happens — earlier overflow-x:auto was not
     consistently clipping flex children past the right edge (Chrome
     flex shrink quirk). overflow-y:hidden keeps the box at toolbar
     height; scrollbar hidden visually via scrollbar-width + ::-webkit. */
  overflow-x: scroll;
  overflow-y: hidden;
  scrollbar-width: none;
  padding: 0;
  background: transparent;
  gap: 8px;
  align-items: center;
}

/* 隱藏捲軸 */
.vocab-toolbar::-webkit-scrollbar { display: none; }

.vocab-btn {
  flex: 0 0 auto;
  width: 44px;
  height: 44px;
  border-radius: var(--radius-full);
  background-color: var(--vocab-btn-bg);
  color: var(--soft);
  border: none;
  font-size: 18px;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: var(--shadow-sm);
  transition: all 0.2s ease;
  /* anchor for the ::after hover tooltip */
  position: relative;
}

/* Hover lift: tiny float upward + a slightly stronger shadow.
   Desktop-only signal (touch has no :hover) — adds tap affordance
   when scanning the toolbar with a mouse. */
.vocab-btn:hover {
  transform: translateY(-2px);
  box-shadow: var(--shadow-md);
}
.vocab-btn:active {
  background-color: var(--vocab-btn-active);
  transform: scale(0.92);
}
/* L3 mini-dialog: highlight the toolbar icon for the phrase the learner
   should say RIGHT NOW. Pulsing ring + accent bg makes it pop without
   blocking the other (still-tappable) phrases. */
/* Active vocab-btn = headphone for the current target phrase. Slightly
   darker grey than the resting --vocab-btn-bg (#f5f5f7) — neutral "this
   one's selected" cue. No color, no ring, no animation. */
.vocab-btn.active {
  background-color: var(--vocab-btn-current, var(--border));
  color: var(--text-strong);
  box-shadow: none;
}
.vocab-btn.active.dialog {
  /* L3 already uses the static style above — kept as a no-op CSS hook
     so the `.dialog` JS modifier can diverge later if we want. */
}

/* Hover tooltip for vocab buttons.
   Implementation note: we previously did this with `.vocab-btn:hover::after`
   pseudo-element, but that gets CLIPPED by .vocab-toolbar's `overflow-x: auto`
   (which silently clips overflow-y too). Solution: a single body-level
   <div.vocab-tooltip> managed by JS — it lives outside any clipping context.
   See main.js `initVocabTooltip()`. */
.vocab-tooltip {
  position: fixed;
  /* Anchor: top-center of element sits at the (x, y) JS sets, then we lift
     it up by its own height via translate(-50%, -100%). 8px gap above button. */
  transform: translate(-50%, calc(-100% - 8px));
  background: rgba(20, 20, 20, 0.92);
  color: var(--on-brand);
  padding: 6px 10px;
  border-radius: var(--radius-sm);
  font-size: 0.78rem;
  font-weight: 500;
  line-height: 1.3;
  white-space: nowrap;
  max-width: 260px;
  pointer-events: none;
  z-index: 1000;
  box-shadow: var(--shadow-md);
  opacity: 0;
  transition: opacity 0.1s ease;
  top: 0;
  left: 0;
}
.vocab-tooltip.show {
  opacity: 1;
}
/* Two-line variant kept for potential future use (currently no caller —
   showVocabTooltip 用 phrase-only)。Translation 改在 L1/L2 Repeat bubble
   走 tap-translate 機制，跟 L3 共用。 */
.vocab-tooltip.with-translation {
  white-space: pre-line;
  max-width: 240px;
  text-align: center;
  line-height: 1.35;
}
.vocab-tooltip::after {
  content: "";
  position: absolute;
  top: 100%;
  left: 50%;
  transform: translateX(-50%);
  border: 6px solid transparent;
  border-top-color: rgba(20, 20, 20, 0.92);
}

/* --- Auth chip (top-right) --- */
/* ════════════════════════════════════════════════════════════════════
 * §17 · AUTH (legacy standalone chip)
 *     .auth-chip / .auth-avatar / .auth-name / .auth-plan-* badges
 *     ([PRO] [PLUS] [ADMIN] [COMP] [FREE]) / .auth-signin-btn.
 *     Used outside the lesson-settings-row context (e.g. landing page).
 *     The inline-in-dashboard variant is .auth-chip-inline (see §18).
 * Scope: shared (auth UI surface)
 * ════════════════════════════════════════════════════════════════════ */

.auth-chip {
  position: fixed;
  top: 12px;
  right: 12px;
  z-index: 50;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  color: var(--text-body);
  background: rgba(255, 255, 255, 0.92);
  border: 1px solid rgba(0, 0, 0, 0.08);
  border-radius: var(--radius-pill);
  padding: 6px 12px;
  box-shadow: var(--shadow-sm);
}
.auth-signin-btn {
  text-decoration: none;
  color: var(--text-body);
  font-weight: 500;
}
.auth-user {
  display: inline-flex;
  align-items: center;
  gap: 8px;
}
.auth-avatar {
  width: 22px;
  height: 22px;
  border-radius: var(--radius-full);
  object-fit: cover;
}
.auth-avatar-emoji {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 22px;
  height: 22px;
  font-size: 16px;
  line-height: 1;
  user-select: none;
}
.auth-name {
  max-width: 120px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  /* +30 over body default 400 → 430. Matches the settings-row label weight
     so "介面 / nana / Logout" cluster reads as a coherent meta-info strip. */
  font-weight: 430;
}
/* Variant when .auth-name is rendered as an <a> link (clicking the name
   navigates to /user dashboard). Keeps the subtle look — no underline by
   default, hover reveals it. */
.auth-name-link {
  color: inherit;
  text-decoration: none;
  cursor: pointer;
}
.auth-name-link:hover {
  text-decoration: underline;
  color: var(--my-go-btn-color);
}
.auth-plan {
  font-size: 10px;
  font-weight: 600;
  padding: 2px 6px;
  border-radius: var(--radius-sm);
  letter-spacing: 0.5px;
}
.auth-plan-pro { background: #fde68a; color: #92400e; }
.auth-plan-free { background: var(--border); color: var(--text-strong); }
.auth-plan-plus { background: #c7d2fe; color: #312e81; }   /* indigo — Plus tier */
.auth-plan-admin { background: #fbcfe8; color: #9d174d; }  /* pink — admin */
.auth-plan-comp { background: #ddd6fe; color: #4c1d95; }   /* purple — comped */
.auth-link {
  background: transparent;
  border: none;
  padding: 0;
  margin: 0;
  font: inherit;
  /* +30 over inherit 400 → 430. Same as .auth-name; the whole settings
     row (label / name / link) now sits at the same weight cluster. */
  font-weight: 430;
  color: var(--text-subtle);
  cursor: pointer;
  text-decoration: underline;
}
.auth-link:hover { color: var(--text-body); }

/* Settings row at the top of the dashboard —
     [interface picker] | [⚙️ settings chip] | [logout]
   All three left-aligned in a single flex row (was 1fr 1fr 1fr grid
   that scattered the cells across the full width). Clustering them on
   the left reads as a single meta-info strip; the right side stays
   empty so the strip doesn't compete with the main panel below. */
/* ════════════════════════════════════════════════════════════════════
 * §18 · DASHBOARD settings strip
 *     The top strip of the right panel: [Interface: EN ▼] [⚙ Settings]
 *     | [Logout]. .lesson-lang-select = language dropdown,
 *     .settings-cell = the 3-column grid, .auth-chip-inline = the
 *     compact user pill, .user-chip-link = clickable Settings button.
 * Scope: dashboard (right panel header) — shared across all levels
 *
 * Why is this split from §2A? Historical — added later. Could be
 * consolidated into §2A in a future pass but that's a CSS-cascade-
 * sensitive reorder we're avoiding in this refactor.
 * ════════════════════════════════════════════════════════════════════ */

.lesson-settings-row {
  /* Mirror `.lesson-stats-grid` — same 3 equal columns + gap so each
     settings cell sits in the SAME grid column as the stat chip
     directly below it: Interface ↔ Streak, Settings ↔ Level, Logout ↔
     This Topic. Was flex earlier which just packed items left-to-right
     with no column relationship to the cards below; users perceived
     it as "not aligned" because columns 2 and 3 didn't line up. */
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  align-items: center;
  gap: 3px;
  padding: 2px 0;
  font-size: 0.72rem;
  line-height: 1.1;
}
.settings-cell {
  display: flex;
  align-items: center;
  gap: 4px;
  min-width: 0;       /* let flex children shrink instead of overflowing the cell */
  overflow: hidden;
  /* Cell inner left-padding matches `.lesson-stat-chip { padding: 7px
     9px }` so the cell's content (e.g. "Interface:") starts at the
     same x as the chip's label below it ("Streak"). Without this the
     grid columns are aligned but the LABEL TEXT inside isn't, which
     reads as misalignment to the eye. */
  padding-left: 9px;
}
/* All cells flush-left within their column — divider line between
   cells (`.settings-cell + .settings-cell { border-left }` further
   down) carries the "separate controls" signal, no need for
   right-aligning the logout cell. */
.settings-cell-lang,
.settings-cell-user,
.settings-cell-logout { justify-content: flex-start; }
.lesson-lang-label {
  color: var(--text-muted);
  white-space: nowrap;
  /* +30 over earlier baseline 430. Settings row was reading too thin
     against the panel title above — +30 bumps it past 450 so the label
     reads as part of the meta-info bar, not page chrome. */
  font-weight: 460;
}
body.learner-cjk .lesson-lang-label {
  /* CJK +30 from prior 410 → 440. Noto Sans TC/SC interpolates close to
     500 here for visible weight increase. */
  font-weight: 440;
}
.lesson-lang-select {
  font: inherit;
  font-size: 0.72rem;
  color: var(--text-body);
  background: var(--field-bg);
  border: 1px solid rgba(0, 0, 0, 0.08);
  border-radius: var(--radius-sm);
  /* Tight padding — content is just a 2-letter code (EN/TW/CN). The
     browser-supplied dropdown chevron still needs room on the right. */
  padding: 1px 2px 1px 8px;
  width: auto;
  /* Override the global select { width: 100% } rule so we don't span the row.
     ~1.5× the original 64px to give the chevron more breathing room. */
  max-width: 96px;
  cursor: pointer;
}
.lesson-lang-select:hover {
  border-color: rgba(0, 0, 0, 0.18);
}
.lesson-lang-select:focus {
  outline: none;
  border-color: var(--my-go-btn-color);
}
/* Auth chip variant when it lives INLINE inside .lesson-settings-row
   instead of floating top-right. Strip the floating chip styling. */
.auth-chip-inline {
  position: static;
  background: transparent;
  border: 0;
  box-shadow: none;
  padding: 0;
  margin: 0;
  gap: 6px;
  font-size: 0.72rem;
}
.auth-chip-inline .auth-avatar,
.auth-chip-inline .auth-avatar-emoji {
  width: 20px;
  height: 20px;
  font-size: 11px;
}
/* auth-name (Settings) stays at 0.78rem inside its pill chip.
   auth-link (Logout / Sign in) bumped to 0.88rem — without the chip
   chrome (no bg/padding/border) the plain underlined "Logout" still
   read smaller than Settings even at the same px size. +0.1rem brings
   it to comfortable parity with the Settings chip perceived weight. */
.auth-chip-inline .auth-name {
  font-size: 0.78rem;
  font-weight: 430;
  line-height: 1;
}
.auth-chip-inline .auth-link {
  font-size: 0.88rem;
  font-weight: 430;
  line-height: 1;
}
/* The whole user pill is a single <a> → make it visually obvious it's a
   button (not a passive label). Subtle background + border + hover lift
   so "nana ADMIN" reads as "click me for account settings", not a banner
   declaring who you are. Replaces the old subtle underline-on-hover
   pattern that nana herself didn't realise was clickable. */
.user-chip-link {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  /* Symmetric padding — gear emoji is inline (no avatar disc to hug
     the left edge), so the pill reads balanced. Slightly tighter on
     top/bottom (2px) keeps the chip slim against the 0.72rem text. */
  padding: 2px 9px;
  border-radius: var(--radius-pill);
  background: rgba(0, 0, 0, 0.045);
  border: 1px solid rgba(0, 0, 0, 0.09);
  color: inherit;
  text-decoration: none;
  transition: background 0.15s, border-color 0.15s, transform 0.15s;
  line-height: 1;
}
.user-chip-link:hover {
  background: rgba(0, 0, 0, 0.08);
  border-color: rgba(0, 0, 0, 0.18);
  text-decoration: none;
}
.user-chip-link:active {
  transform: translateY(1px);
}
/* Settings-gear icon — sits inline next to the name, no background
   circle (the multi-coloured ⚙️ emoji renders badly on a grey disc;
   pure inline reads cleaner). Slightly larger than body text so the
   gear glyph is recognisable at small chip sizes. */
.user-chip-link .auth-avatar-emoji {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* No bg / no border — emoji is its own visual unit. */
  width: 16px;
  height: 16px;
  font-size: 14px;
  line-height: 1;
}
/* Inside the chip, the name is just plain text now (the <a> wrapper
   handles the click + hover affordances). Strip any leftover link
   decoration from the old .auth-name-link pattern. */
.user-chip-link .auth-name {
  color: inherit;
  text-decoration: none;
}
/* Visual dividers BETWEEN the 3 settings cells. Subtle vertical line on
   the left edge of cells 2 and 3 (cell 1 has no leading divider). Base
   .settings-cell already supplies padding-left: 9px, so this rule only
   needs to add the border — don't override the padding or cell 1's
   content would shift relative to cells 2/3. */
.settings-cell + .settings-cell {
  border-left: 1px solid rgba(0, 0, 0, 0.1);
}

/* --- Upgrade modal --- */
/* ════════════════════════════════════════════════════════════════════
 * §19 · UPGRADE MODAL   (pro plan upsell overlay)
 *     Triggered when learner hits a Plus / Pro feature gate
 *     (locked level or topic). Same overlay pattern as §6 topic-modal.
 * Scope: shared (gated CTA across all levels)
 * ════════════════════════════════════════════════════════════════════ */

.upgrade-modal {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: center;
}
.upgrade-modal[hidden] { display: none; }
.upgrade-modal-backdrop {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
}
/* Upgrade modal — designed to match the friendly 401 error page aesthetic. */
.upgrade-modal-card {
  position: relative;
  background: var(--white);
  border-radius: var(--radius-lg);
  padding: 40px 36px 28px;
  max-width: 460px;
  width: calc(100% - 32px);
  text-align: center;
  box-shadow: var(--shadow-modal);
  font-family: 'Montserrat', 'Noto Sans TC', -apple-system, sans-serif;
  -webkit-font-smoothing: antialiased;
}
.upgrade-modal-emoji {
  font-size: 56px;
  line-height: 1;
  margin-bottom: 16px;
}
.upgrade-modal-label {
  display: inline-block;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.6px;
  text-transform: uppercase;
  color: #b45309;
  background: #fef3c7;
  padding: 4px 12px;
  border-radius: var(--radius-pill);
  margin: 0 auto 14px;
}
.upgrade-modal-card h3 {
  /* Match the friendly /admin 401 page: plain dark title, no gradient
     (gradient was clipping descenders like the "g" in "going"). */
  margin: 0 0 12px;
  font-size: 1.4rem;
  font-weight: 600;
  line-height: 1.3;
  color: var(--text-strong);
  letter-spacing: -0.3px;
}
.upgrade-modal-card p {
  margin: 0 0 24px;
  color: var(--soft);
  font-size: 0.95rem;
  line-height: 1.55;
}
.upgrade-actions {
  display: flex;
  flex-direction: column;
  gap: 4px;
  align-items: stretch;
}
.upgrade-cta {
  display: block;
  width: 100%;
  padding: 13px 20px;
  font-size: 0.95rem;
  font-weight: 600;
  line-height: 1.2;
  border-radius: var(--radius-md);
  border: 0;
  cursor: pointer;
  white-space: nowrap;
  background: var(--brand);  /* solid teal — matches /admin 401 page button */
  color: var(--on-brand);
  text-decoration: none;
  text-align: center;
  transition: background 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
}
.upgrade-cta:hover {
  background: var(--brand-mid);
  transform: translateY(-1px);
  box-shadow: 0 4px 14px rgba(0, 151, 185, 0.25);
}
.signup-cta { /* same teal as upgrade-cta — both inherit from .upgrade-cta now */ }
.upgrade-close {
  display: block;
  margin: 12px auto 0;
  padding: 8px 14px;
  background: transparent;
  border: none;
  color: var(--text-faint);
  cursor: pointer;
  font: inherit;
  font-size: 0.85rem;
  border-radius: var(--radius-sm);
}
.upgrade-close:hover {
  color: var(--text-strong);
  background: var(--surface-alt);
}
