:root {
  --brand: #f97316;
  --brand-soft: #fff1e6;
  --brand-line: #fed7aa;
  --ink: #1f2430;
  --ink-2: #6b7280;
  --ink-3: #9aa4b2;
  --line: #e8ecf3;
  --bg: #eef1f6;
  --card: #ffffff;
  --ok: #16a34a;
  /* v3.83 进度药丸改绿：三档绿，跟 --ok 配套用（药丸 / 明细窗 / 最近学会小标签） */
  --ok-deep: #15803d;
  --ok-soft: #f0fdf4;
  --ok-line: #bbf7d0;
  --radius: 14px;

  /* v3.29 场景镜头比例的唯一开关：16:9 横构图。
     换竖版就改成 `375 / 400`（≈54% 屏高），其它样式一律不用动。 */
  --scene-ratio: 16 / 9;
}

* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }

html, body {
  margin: 0;
  height: 100%;
  background: var(--bg);
  color: var(--ink);
  font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
}

body {
  display: flex;
  /* 【2026-10-23】改竖向：网站底部要挂 ICP 备案号，footer 得是 .phone 的下一行。
     原来是横向居中，footer 会被摆到手机壳右边。没有 footer 时这一条不影响观感。 */
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 20px;
}

/* ---------------- 网站底部：ICP 备案号 ---------------- */

/* ★★ `hidden` 的兜底（2026-10-24，v3.16）
 *
 * `hidden` 属性是靠浏览器默认样式 `[hidden]{display:none}` 生效的，而它的**优先级最低** ——
 * 作者样式的任何一条 `display:flex / grid / block` 都能把它整个顶掉，
 * 于是「设了 hidden，屏幕上照样显示」。
 *
 * 这一天就出了这事：词卡的 `.wc-row { display: flex }` 让词块的「音标」行和
 * **一直空着的「拼写」行**照样显示出来。用户原话：
 *   「词组词块方面，有发音有意义，但是没有音标。拼写后边也是空的，要不拼写这一行去掉吧。」
 * 而当时的手机装置 **26/26 全绿** —— 因为它检查的是 `row.hidden` 这个**属性**，
 * 不是「肉眼看不看得见」。属性对了、屏幕上是错的，这种 bug 只有真按一次才抓得到。
 *
 * 所以补一条带 `!important` 的兜底：hidden 的语义就是「不显示」，没有例外，
 * 也不再需要一个元素一个元素地写 `<选择器>[hidden]`（footer 以前就是单写了一条）。 */
[hidden] { display: none !important; }

/* 备案号是「给核验的人看的」，不该抢注意力：11px、浅灰、居中。
   ★ 默认 hidden＝整条不占位。没有号的时候页面上不该出现一行空白。 */
.site-footer {
  margin-top: 6px;
  font-size: 11px;
  line-height: 1.4;
  color: var(--ink-3);
  text-align: center;
}
.site-footer a {
  color: inherit;
  text-decoration: none;
}
.site-footer a:hover { text-decoration: underline; }

/* ---------------- 手机外壳 ---------------- */

.phone {
  width: 390px;
  /* 【2026-10-07 用户实测修正 —— 用户原话：「在手机上，对话框还是挡在聊天内容上面」】
   *
   * 根因是 vh 在手机上的定义：100vh 指的是「地址栏收起来时」的高度，
   * 比此刻真正看得见的区域高出一截（地址栏 + 底部工具条）。
   * 于是 .phone 比屏幕高 → 底部的输入框被顶出可视区，
   * 或者反过来压住聊天区最后几条消息（用户看到的就是「挡住了」）。
   *
   * 修法：用 dvh（dynamic viewport height）—— 它跟着地址栏伸缩和软键盘实时变，
   * 100dvh 永远等于「此刻真正看得见的高度」。
   * 老浏览器不认识 dvh 会自动丢掉这一行、退回上面的 vh，行为不退化。
   */
  height: min(800px, calc(100vh - 40px));
  height: min(800px, calc(100dvh - 40px));
  /* 软键盘弹出时由 JS 用 visualViewport 写到这个变量上，兜住 iOS 不更新 dvh 的情况 */
  height: min(800px, calc(var(--app-h, 100vh) - 40px));
  background: var(--card);
  border-radius: 26px;
  box-shadow: 0 24px 60px rgba(31, 36, 48, .16), 0 2px 8px rgba(31, 36, 48, .06);
  display: flex;
  flex-direction: column;
  overflow: hidden;
  position: relative;
}

/* 挂了备案号时，手机壳要让出底部那一行（6px 间距 + 15px 字号 ≈ 22px）。
   ★ 这一条只在 body.has-footer 时生效：没有备案号时布局与以前逐像素一致，
     不去动那个 2026-10-07 好不容易调好的「输入框不被顶出去」。 */
body.has-footer .phone {
  height: min(800px, calc(100vh - 62px));
  height: min(800px, calc(100dvh - 62px));
  height: min(800px, calc(var(--app-h, 100vh) - 62px));
}

@media (max-width: 440px) {
  /* 整页不再滚动 —— 只有聊天区滚。否则手机上会出现
     「页面整体能上下拖、输入框跟着乱跑」的观感。 */
  html, body { padding: 0; height: 100%; overflow: hidden; }
  .phone {
    width: 100%;
    height: 100vh;
    height: 100dvh;
    height: var(--app-h, 100vh);
    border-radius: 0;
  }
  /* 手机上整屏被 .phone 占满，footer 要能看见就必须让它让出这 22px */
  body.has-footer .phone {
    height: calc(100vh - 22px);
    height: calc(100dvh - 22px);
    height: calc(var(--app-h, 100vh) - 22px);
  }
}

/* ---------------- 顶栏 ---------------- */

.topbar {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 16px 18px 12px;
  border-bottom: 1px solid var(--line);
  flex: 0 0 auto;
}

.topbar-main { flex: 1; min-width: 0; }

.topbar h1 {
  margin: 0;
  font-size: 17px;
  font-weight: 700;
  letter-spacing: -.2px;
}

/* 【v4.01】顶栏那行「Hi, ___!」是可点的（改名字）。
   四件事必须一起写，少一条就是一个坑：
   ① 用 flex 把「Hi,」/ 名字 /「!」/ 铅笔分成四格：只有**名字**那一格允许被压缩，
      于是名字再长也不会把右边的四个按钮挤走，而又不会连「!」和铅笔一起切掉
      （实测 12 个汉字在 390px 竖屏上只剩 156px 可用，整行 ellipsis 会把铅笔也吃掉）；
   ② 名字那一格自己要 nowrap + ellipsis —— ellipsis 只对「自己有宽度限制」的元素生效；
   ③ cursor / 按下反馈：手机上「点了一下没反应」等于没这个功能；
   ④ 铅笔用 <i> 不用 <button>：它是提示，不是第二个按钮 ——
      点它和点名字落在同一处，不必维护两个点击区。 */
.name-head {
  display: flex;
  align-items: baseline;
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: opacity .12s;
}
.name-head:active { opacity: .55; }
.name-head.nick-shrink { font-size: 14px; }
.name-head .name-fixed { flex: 0 0 auto; }
.name-head #nickname {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.name-pen {
  flex: 0 0 auto;
  margin-left: 4px;
  font-size: 11px;
  font-style: normal;
  opacity: .6;
}

.unit {
  margin-top: 3px;
  font-size: 12px;
  color: var(--ink-2);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.level-badge {
  flex: 0 0 auto;
  min-width: 44px;
  height: 30px;
  padding: 0 10px;
  border-radius: 999px;
  background: var(--brand-soft);
  border: 1px solid var(--brand-line);
  color: #b45309;
  font-size: 13px;
  font-weight: 800;
  font-family: inherit;          /* 它是 <button> 了，不写会变成系统默认字体 */
  display: flex;
  align-items: center;
  justify-content: center;
  letter-spacing: .5px;
  cursor: pointer;
  transition: transform .25s cubic-bezier(.34,1.56,.64,1);
}

/* 小三角只是「这里能点」的暗示。用伪元素画，不写进 textContent ——
   回归测试钉的是「徽章上正好是 L1」，多一个字符就红。 */
.level-badge::after {
  content: '';
  width: 0;
  height: 0;
  margin-left: 5px;
  border-left: 3px solid transparent;
  border-right: 3px solid transparent;
  border-top: 4px solid currentColor;
  opacity: .55;
}

.level-badge:active { transform: scale(.94); }

.level-badge.bump { transform: scale(1.22); }

/* 场景区样式已随「场景图片位」一并删除（2026-10-08 用户反馈：挡住对话内容） */

/* ---------------- 场景框（v3.29，2026-10-26） ----------------

   ★ 为什么 2026-10-08 删过一次、现在又要加回来：
     那次出问题的不是「有场景」，而是**场景没有高度上限** —— 一张图按原始比例撑开，
     能长到半屏，把对话顶下去。这次用 `aspect-ratio` 把镜头比例锁死，
     它永远只占「屏宽 × 9/16」，对话区拿剩下的，不会再被顶。

   ★ 比例只有一个开关：`--scene-ratio`。
     想换回竖版 54%，只改成 `--scene-ratio: 375 / 400` 一处即可，其它不用动。
     16:9 的由来：他要求「看字幕练读」，实测一轮（英文＋中文两段）占 100～150px，
     竖版 54% 只剩 328px ≈ 2.5 轮，16:9 给 493px ≈ 4 轮。 */

.scene {
  position: relative;
  flex: 0 0 auto;
  width: 100%;
  aspect-ratio: var(--scene-ratio, 16 / 9);
  overflow: hidden;
  border-bottom: 1px solid var(--line);
  /* 【v3.35 真背景】长城雪景横图（他提供 2048×1152，右下角「豆包AI生成」水印已除，
     压成 570KB 渐进 JPEG：web/images/scene-bg.jpg）。图本身就是 16:9，
     cover 正好铺满不裁；短屏 2:1 时 cover 会裁掉上下一点，可接受。
     底色保留：图片万一加载失败，还有一片浅蓝兜底，不会露白块。 */
  background: #e4eff8 url('/images/scene-bg.jpg') center / cover no-repeat;
}

/* 短屏（如 iPhone SE 667）：镜头改成 2:1 矮一点，把高度让回对话区。
   宁可画面矮，也不让字幕只剩一行 —— 「练读」是硬需求，「画面好看」是软的。 */
@media (max-height: 720px) {
  .scene { aspect-ratio: 2 / 1; }
}

/* 【v3.35】CSS 手画的占位背景（天空渐变 + 两座山）已被真图替代（.scene 的 background-image）。
   DOM 留着不删：想回退手绘版，把下面这行 display:none 去掉即可。 */
.scene-sky,
.scene-hill { display: none; }

.scene-hill {
  position: absolute; bottom: 14%;
  border-radius: 60% 60% 0 0 / 72% 72% 0 0;
}
.scene-hill.h1 { left: -8%; width: 58%; height: 40%; background: #cfe2ec; }
.scene-hill.h2 { right: -10%; width: 62%; height: 54%; background: #c2d9e6; }

/* 地面：半身取景里**故意藏起来**。
   因为她是在腰胯处被框裁掉的，一条地面带正好横切在她身上，
   看着像「站在及腰的草丛里」。远景（全景取景）才需要它，那时去掉这条 display:none。 */
.scene-ground {
  display: none;
  position: absolute; left: 0; right: 0; bottom: 0; height: 10%;
  background: #e3eddc;
}

/* 灵妹立绘：半身取景。
   height 190% + bottom -98% ⇒ 头顶落在画面 8% 处，腰部以下被框裁掉。
   ★ 这是「取景」，不是「她跑到框外」：.scene 有 overflow:hidden，
     人始终被这个框 clip 住，正是他 2026-10-26 定的那条边界。

   ★ v3.30 起立绘外面套了三层（.ling-box / .ling-breath / .ling-talk）：
     · .ling-box    只负责「站在哪、多大」——定位与尺寸，和以前一字不差
     · .ling-breath 一直开着的呼吸
     · .ling-talk   只在出声时开着的说话律动
     宽度不再靠 <img> 自己撑开，改成 aspect-ratio 按立绘原比例算出来，
     这样外面两层 div 才有确定的盒子可以转。 */
.ling-box {
  position: absolute;
  right: 10%;
  height: 190%;
  bottom: -98%;
  aspect-ratio: 400 / 1458;
  filter: drop-shadow(0 8px 12px rgba(31, 36, 48, .10));
  pointer-events: none;
  user-select: none;
}

/* 两层动画都以脚为轴：她的脚在画面外（bottom -98%），轴落在脚上，
   摆动才是「一个人站着轻轻晃」，不是「整张图横移」。 */
.ling-breath,
.ling-talk {
  position: absolute;
  inset: 0;
  transform-origin: 50% 100%;
  will-change: transform;
}

.scene-ling {
  width: 100%;
  height: 100%;
  display: block;
}

/* 呼吸：4.6 秒一轮，幅度小到「说不出哪里在动，但画面是活的」。
   scale 1.005 在 415px 高的立绘上 ≈ 头顶上下 2px —— 正好是能被余光察觉、
   又不会让人盯着看的量。说话时它继续跑，两层的位移叠在一起才不呆板。 */
@keyframes ling-breath {
  0%, 100% { transform: scale(1) translateY(0); }
  50%      { transform: scale(1.005) translateY(-0.7px); }
}
.ling-breath { animation: ling-breath 4.6s ease-in-out infinite; }

/* 【v3.32】说话的动作交给「手势帧」，这里不再让整张图摆。
   v3.30 用 0.6s 一轮的整图侧摆、v3.31 改成 3.2s 点一下头 —— 两次都被他否掉，
   他 2026-10-26 的原话：「是人在左右晃动，并不是手在动、肢体在动」。
   根因是素材：扁平图没有可单独运动的部件，任何 transform 都是整幅画在动。
   【v3.36】真正动的是 .ling-arm（手部独立图层）；这一层的位移恒为 0（等于静态），
   只保留「什么时候算在说话」这个状态往下传的挂点。 */
@keyframes ling-talk {
  0%, 100% { transform: none; }
}
.scene.talking .ling-talk { animation: ling-talk 3.2s linear infinite; }

/* 【v3.36】手：腕以下的独立图层，说话时绕手腕旋转。
   几何（源图 400×1458 坐标，构建脚本 tools/build-arm-layer.py 里有完整标定）：
     精灵矩形 x[330,400] y[719,842]，腕轴点 (366,719)
     ⇒ left 82.5% / top 49.313% / width 17.5% / height 8.436%
     transform-origin = ((366-330)/70, 0) = 51.43% 0%（轴点压在精灵顶边）
   百分比而不是 px：立绘随场景缩放，图层一起缩放才对得上。 */
.ling-arm {
  position: absolute;
  left: 82.5%;
  top: 49.313%;
  width: 17.5%;
  height: 8.436%;
  /* ★ 手必须绕「手腕」转，不是绕自己的中心转：腕轴点在源图 (366,719)，
     精灵矩形 x[330,400] y[719,842] ⇒ (366-330)/70 = 51.43%，y 压在精灵顶边 0%。
     【v3.36 漏写】这行曾只活在注释里，导致手绕自身中心转 —— 挥动轴不对、看起来怪。 */
  transform-origin: 51.43% 0%;
  pointer-events: none;
  user-select: none;
  will-change: transform;
}

/* 2.8 秒一轮：抬手 → 摆两下 → 放下，像讲课时自然的手势。
   ★ 方向：负角度 = 手向**外**（画面右侧）挥，衬在长城背景上看得清；
     正角度会挥向白裙内侧 —— 白上白看不见，还把填补区亮出来，别改回去。
   ★ 用 ease-in-out 而不是 steps：手是连续的弧线运动，硬切会像跳帧。 */
@keyframes ling-arm {
  0%   { transform: rotate(0deg); }
  14%  { transform: rotate(-26deg); }
  38%  { transform: rotate(-14deg); }
  58%  { transform: rotate(-28deg); }
  82%  { transform: rotate(-12deg); }
  100% { transform: rotate(0deg); }
}
.scene.talking .ling-arm { animation: ling-arm 2.8s ease-in-out infinite; }

/* 嘴：一张 86×48 的小图，压在同一个位置上，出声时和底图交替显示。

   ★ 这一块前后废掉两版，值得写清楚，免得以后又走回去：
     第一版 叠一块暗红色 —— 真机上一看就废：实色把她的唇形整个盖住，
            像「嘴里塞了颗深色的东西」。
     第二版 backdrop-filter: brightness —— 也不行：她的脸是白的，
            任何亮度变化都会在脸上圈出一块可见的灰斑，边缘再羽化也还是「贴在嘴上的东西」。
     ★ 结论：**嘴是已经画好的，不许再往上画。** 只能真的改像素。
     所以现在这张 lingmei-mouth-open.png 是用 make_open.py 在下唇上做的纵向位移
     （唇缝拉长 6px、下唇下移 6px，下巴不动），是「她真的把嘴张开了」，不是覆盖。

   定位是算出来的：小图裁自原图 (160,180)-(246,228)，除以立绘的 400×1458
   ⇒ left 40% / top 12.346% / width 21.5% / height 3.292%。
   百分比而不是 px：立绘随场景缩放，两者一起缩放才对得上。
   小图边缘已羽化 5px，就算定位差半个像素也不会有接缝。 */
/* 嘴贴片的「窗口层」：定位和原来一致（按底图位置裁自原图），默认隐藏（不说话不张嘴）。
   talking 时按 3.6s 与手势帧对齐 —— 只在「底图窗口」（段1 0–25% / 段3 50–75%）显示，
   A/B 帧时段隐藏，否则嘴贴片会「飘」在手势帧的脸旁边变成双嘴。
   内层 .ling-mouth 才负责张合节奏，两个独立动画互不打架。 */
.mouth-wrap {
  position: absolute;
  left: 40%;
  top: 12.346%;
  width: 21.5%;
  height: 3.292%;
  opacity: 0;
  pointer-events: none;
  user-select: none;
}
.mouth-wrap > img { width: 100%; height: 100%; display: block; }

/* 0.34 秒一轮 ≈ 每秒 3 次开合，接近正常语速的音节率。
   中间那 1% 的过渡只有 3 毫秒，肉眼等同于硬切 —— 嘴本来就只有「开/合」两态。
   ★ 这件动画只管「张合」，不管「什么时候该显示」—— 显隐交给 .mouth-wrap。 */
@keyframes ling-mouth {
  0%, 35%   { opacity: 0; }
  36%, 69%  { opacity: 1; }
  70%, 100% { opacity: 0; }
}
/* 【v3.36】手势帧已废，嘴不再需要「窗口显隐」——说话期间一直亮着，
   只由内层 img 的张合节奏动画驱动。 */
.scene.talking .mouth-wrap { opacity: 1; }
.scene.talking .ling-mouth { animation: ling-mouth .34s linear infinite; }

/* 系统开了「减弱动态效果」就全部停掉，只留静态画面。 */
@media (prefers-reduced-motion: reduce) {
  .ling-breath,
  .scene.talking .ling-talk,
  .scene.talking .ling-arm,
  .scene.talking .mouth-wrap,
  .scene.talking .ling-mouth { animation: none; }
}

/* 词图：只在本轮「目标词」有图时出现（v3.27 的口径不变）。
   不写单词 —— 词由灵妹说出来，图只是辅助。 */
.scene-word {
  position: absolute; left: 12px; top: 12px;
  width: 64px; height: 64px; border-radius: 10px;
  background: #fff; border: 1px solid #cbd5e1;
  overflow: hidden;
  display: flex; align-items: center; justify-content: center;
  opacity: 0; transform: scale(.88) translateY(-6px);
  transition: opacity .25s ease, transform .25s ease;
  pointer-events: none;
}
.scene-word.on { opacity: 1; transform: none; }
.scene-word img { width: 100%; height: 100%; object-fit: cover; display: block; }
.scene-word .sw-cap { display: none; }

/* ---------------- 对话区 ---------------- */

.chat {
  flex: 1 1 auto;
  overflow-y: auto;
  /* 18px 是左右留白；底部那 56px 是给「最后一条消息」留的呼吸位，
     否则滚到底时最后一条会贴着输入框上方，看着像被压住。 */
  padding: 16px 18px 56px;
  display: flex;
  flex-direction: column;
  gap: 14px;
  scroll-behavior: smooth;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
}

.chat::-webkit-scrollbar { width: 4px; }
.chat::-webkit-scrollbar-thumb { background: #dfe4ec; border-radius: 4px; }

/* 【v3.75 微信式对话】一行 = 头像列 + 内容列。
 *
 * ★ 头像为什么用 `::before` 而不是真的插一个 <img>：
 *   `renderTyping`（正在输入三点）和 `showFirstSentence`（首句先出声）
 *   都是 `row.innerHTML = ...` **整行覆盖**，真元素会被冲掉、伪元素不会。
 *   少改三处高风险代码，流式那一瞬头像也不会闪掉。
 *
 * ★ 头像为什么 34px（微信是 40px）：
 *   `.row` 原来的上限是 88%，多一列头像会挤窄气泡；而气泡里**每个词底下
 *   还挂着一行中文小字**（`.w.glossed`）—— 窄 10px 就会让长句多折一行。
 *   所以头像收到 34px、间距 6px，同时把行宽放到 96% 把损失补回来，
 *   气泡实际可用宽度和改之前基本持平。
 */
.row {
  display: grid;
  grid-template-columns: 34px minmax(0, 1fr);
  column-gap: 6px;
  max-width: 96%;
}
.row.ai { align-self: flex-start; }
.row.me { align-self: flex-end; grid-template-columns: minmax(0, 1fr) 34px; }

/* 头像只挂在真对话上（`.ai` / `.me`）。
 * `.row.tip`（「新词有点多」提醒胶囊）**刻意长得不像对话** —— 见它自己那段注释，
 * 给它加头像就等于告诉孩子「这是该回答的问题」，而他根本答不了。 */
.row.ai::before,
.row.me::before {
  content: '';
  grid-row: 1;
  width: 34px;
  height: 34px;
  border-radius: 50%;
  background: center / cover no-repeat;
  /* 白圈 + 一圈极淡外描边：用户那头像是橙色底，和橙色气泡贴在一起会糊成一片
     （实测截图里"我的消息"和头像几乎连成一块）。白圈能把它从任何底色上分出来。 */
  border: 2px solid #fff;
  box-shadow: 0 0 0 1px rgba(15, 23, 42, .10), 0 1px 3px rgba(15, 23, 42, .08);
  /* 长消息时头像跟**第一行**对齐（微信就是这样），不跟着气泡漂到中间 */
  align-self: start;
  margin-top: 2px;
}

.row.ai::before {
  grid-column: 1;
  background-image: url('/images/lingmei.jpg');
  /* 这张是半身像，圆形裁切要往上偏，否则切掉头顶、露出一大片肩膀 */
  background-position: center 18%;
}

/* 用户那头像是他挑的卡通形象（8 选 1，存在本机，不上传服务器）。
 * 用 `content: var(--my-av-emoji)` 是为了让「换一个」只改一个 CSS 变量，
 * 不必把屏幕上已有的每一行都重新画一遍。 */
.row.me::before {
  grid-column: 2;
  content: var(--my-av-emoji, '🐱');
  display: grid;
  place-items: center;
  font-size: 17px;
  line-height: 1;
  background-color: var(--my-av-bg, #fef3c7);
  background-image: none;
}

/* 内容列：ai 一律第 2 列，me 一律第 1 列。
 * 不写这两行，`.meta`（难度标签）和 `.advance`（换课卡）会掉进头像那一列。 */
.row.ai > .bubble,
.row.ai > .meta,
.row.ai > .advance { grid-column: 2; }
.row.me > .bubble,
.row.me > .meta { grid-column: 1; }

/* 气泡按内容收窄（微信里短句就是个小气泡，不会撑满整行） */
.row.ai > .bubble { justify-self: start; }
.row.me > .bubble { justify-self: end; }
.row.ai > .advance { justify-self: stretch; }

.bubble {
  padding: 10px 14px;
  border-radius: 16px;
  font-size: 15.5px;
  line-height: 1.55;
  word-break: break-word;
  animation: pop .22s ease-out;
}

@keyframes pop {
  from { opacity: 0; transform: translateY(6px); }
  to { opacity: 1; transform: none; }
}

.row.ai .bubble {
  background: #f4f6fa;
  border: 1px solid var(--line);
  border-bottom-left-radius: 5px;
}

.row.me .bubble {
  background: var(--brand-soft);
  border: 1px solid var(--brand-line);
  border-bottom-right-radius: 5px;
}

.bubble .en { font-weight: 600; letter-spacing: .1px; }
.bubble .zh {
  margin-top: 5px;
  padding-top: 5px;
  border-top: 1px dashed var(--brand-line);
  font-size: 12.5px;
  font-weight: 400;
  color: var(--ink-2);
}

/* 【v3.56】「新词有点多，一个一个来。」—— 一句提醒，不是教学内容。
 *
 * 为什么刻意长得不像对话：它要是和灵妹的气泡一个样，孩子会以为这是
 * 「该回答的问题」，于是去答它 —— 而他根本答不了（那就是他刚说不出来的词）。
 * 所以：居中小胶囊、字号小、颜色淡、没有头像、5 秒后自己淡出。
 * 点一下会停在原地并展开「是哪几个词」—— 他想看就看得见，不想看就让它过去。
 */
/* 【v3.75】`.row` 改成 grid 了，这一行必须显式退回 flex —— 提醒胶囊没有头像列，
   而且在竖屏字幕带里它是居中漂浮的，不能被 34px 的网格列推歪。 */
.row.tip { display: flex; flex-direction: column; align-items: center; align-self: center; max-width: 92%; margin: 2px 0 4px; }
.row.tip .bubble {
  padding: 6px 12px;
  border-radius: 999px;
  background: #fff8e8;
  border: 1px solid #f0dfb8;
  font-size: 12.5px;
  line-height: 1.5;
  color: #8a6a1f;
  cursor: pointer;
  animation: tipIn .28s ease-out;
  transition: opacity .6s ease;
}
.row.tip .bubble .en { font-weight: 600; }
.row.tip .bubble .zh {
  margin-top: 4px;
  padding-top: 4px;
  border-top: 1px dashed #eadcbb;
  font-size: 11.5px;
  color: #9b8355;
  letter-spacing: .2px;
}
.row.tip.gone .bubble { opacity: 0; }
.row.tip.out { display: none; }

@keyframes tipIn {
  from { opacity: 0; transform: translateY(4px) scale(.96); }
  to { opacity: 1; transform: none; }
}

.meta {
  margin-top: 5px;
  font-size: 11px;
  color: var(--ink-3);
  display: flex;
  align-items: center;
  gap: 6px;
  flex-wrap: wrap;
}

.tag {
  display: inline-flex;
  align-items: center;
  height: 18px;
  padding: 0 7px;
  border-radius: 999px;
  font-size: 10.5px;
  font-weight: 700;
  letter-spacing: .2px;
}

.tag.review { background: #ede9fe; color: #6d28d9; }
.tag.up { background: #dcfce7; color: #15803d; }
.tag.down { background: #ffe4e6; color: #be123c; }
.tag.guard { background: #fef3c7; color: #b45309; }
.tag.hold { background: #f1f5f9; color: #64748b; }

/* ---------------- HUD ---------------- */

.hud {
  flex: 0 0 auto;
  padding: 12px 18px 10px;
  border-top: 1px solid var(--line);
  display: flex;
  flex-direction: column;
  gap: 12px;
  background: #fcfdff;
}

.hud-title {
  font-size: 11.5px;
  font-weight: 700;
  color: var(--ink-2);
  letter-spacing: .3px;
  display: flex;
  align-items: baseline;
  gap: 7px;
  margin-bottom: 7px;
}

.hud-hint {
  font-weight: 400;
  font-size: 10.5px;
  color: var(--ink-3);
}

.words { display: flex; flex-wrap: wrap; gap: 6px; }

.word {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  height: 26px;
  padding: 0 9px;
  border-radius: 8px;
  background: #f6f8fb;
  border: 1px solid var(--line);
  font-size: 12px;
  font-family: ui-monospace, "SF Mono", Menlo, monospace;
  color: var(--ink-2);
  transition: all .2s;
}

.word.done {
  background: #f0fdf4;
  border-color: #bbf7d0;
  color: var(--ok);
  font-weight: 700;
}

.word.active {
  background: var(--brand-soft);
  border-color: var(--brand-line);
  color: #b45309;
  font-weight: 700;
  box-shadow: 0 0 0 3px rgba(249, 115, 22, .1);
}

.dots { display: inline-flex; gap: 2px; }
.dot {
  width: 5px; height: 5px; border-radius: 999px;
  background: #d7dde8;
}
.dot.on { background: currentColor; }

.bar {
  position: relative;
  height: 22px;
  border-radius: 999px;
  background: #eef1f6;
  overflow: hidden;
  border: 1px solid var(--line);
}

.bar-fill {
  height: 100%;
  width: 0;
  border-radius: 999px;
  background: linear-gradient(90deg, #fdba74, #f97316);
  transition: width .5s cubic-bezier(.22,.9,.28,1);
}

.bar-text {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 11px;
  font-weight: 700;
  color: #7c2d12;
  letter-spacing: .4px;
}

/* ---------------- 输入区 ---------------- */

.composer {
  flex: 0 0 auto;
  /* 底部留出 iPhone 那条「home 指示条」的高度，否则输入框会压到它下面 */
  /* 【v3.39】底栏瘦身：竖向内边距收紧，给上方聊天区多腾一点空间 */
  padding: 7px 14px calc(10px + env(safe-area-inset-bottom, 0px));
  border-top: 1px solid var(--line);
  background: #ffffff;
}

/* 【v3.40】话筒本次会话实测可用时，移动端只留话筒：隐藏输入框+发送（及键盘语音引导卡）。
   默认不挂 .mic-proven → 输入框+发送常显（保底）；PC 端永不挂（保留打字）。 */
body.mic-proven .composer-row input,
body.mic-proven .send { display: none; }
body.mic-proven .composer-row { justify-content: center; }

/* 【v3.44】手机上**默认**只留话筒（他 10-27 拍板：能动口就不动手，输入框能没有就没有）。
   与上面 .mic-proven 的区别只在「什么时候挂」：.mic-proven 是「实测可用才藏」，
   .voice-only 是「一进页面就藏」。样式效果一样，两条规则并存不冲突。
   ⚠ 唯一的回来路径是 openKeyboard() —— 它先把 .voice-only 摘掉再聚焦（见 app.js）。
   这保证了「藏」只发生在能说话的时候，微信/麦克风被拒/不支持录音/识别挂掉都会自动把框放回来。 */
body.voice-only .composer-row input,
body.voice-only .send { display: none; }
body.voice-only .composer-row { justify-content: center; }
/* 只剩一个话筒时不必再留一排的纵向浪费；话筒给到 48（原 42），好按又不把底栏顶高 */
body.voice-only .composer { padding-top: 5px; }
body.voice-only .composer-row .mic { width: 48px; height: 48px; font-size: 19px; }

.voice-note {
  font-size: 11px;
  color: var(--ink-3);
  margin-bottom: 8px;
  min-height: 0;
  line-height: 1.5;
  display: none;
}

.voice-note.show { display: block; }

/* ---------------- 输入法语音引导卡 ----------------
   【2026-10-07 第四轮 · 用户原话：
     「这次 AI 给我说的话有语音了，但是我不能用语音给他回答，怎么办？」】
   听的那一半已经换成「服务器预生成音频」，跟手机环境无关了。
   说的这一半还卡在浏览器上：网页语音识别在安卓由 Google 提供，
   华为等没有 Google 服务的机型上根本不可用；微信内置浏览器干脆不给这个接口。
   ——这一层改代码解决不了，但输入法自带的语音输入跟浏览器完全无关，
   每台安卓机都有，微信里也能用。
   所以答案不该是「不支持」，而是一张能照着点的步骤卡。 */

.mic-tip {
  display: none;
  margin-bottom: 10px;
  padding: 11px 13px 12px;
  border-radius: 12px;
  background: #fff7ed;
  border: 1px solid #fed7aa;
  color: #7c3f00;
  font-size: 12.5px;
  line-height: 1.7;
}

.mic-tip.show { display: block; }

.mic-tip-head {
  font-weight: 700;
  color: #9a3412;
  margin-bottom: 5px;
  font-size: 13px;
}

.mic-tip-steps { margin: 0; padding-left: 19px; }
.mic-tip-steps li { margin: 2px 0; }
.mic-tip-steps b { color: #9a3412; }

.mic-tip-alt {
  margin: 7px 0 0;
  padding-top: 7px;
  border-top: 1px dashed #f0c9a0;
  color: #a1652c;
  font-size: 11.5px;
  line-height: 1.6;
  display: none;
}

.mic-tip-alt.show { display: block; }
.mic-tip-alt b { color: #9a3412; }

/* 兜底那句：万一程序唤键盘失败，告诉他手动点一下输入框也一样 */
.mic-tip-fallback {
  margin: 5px 0 0;
  color: #b45309;
  font-size: 11.5px;
}

.mic-tip-ok {
  margin-top: 9px;
  width: 100%;
  height: 34px;
  border-radius: 999px;
  background: var(--brand);
  color: #fff;
  font-size: 13px;
  font-weight: 600;
}

.composer-row { display: flex; gap: 8px; align-items: center; }

.composer-row input {
  flex: 1;
  min-width: 0;
  height: 42px;
  padding: 0 14px;
  border-radius: 999px;
  border: 1px solid var(--line);
  background: #f6f8fb;
  font-size: 14px;
  color: var(--ink);
  outline: none;
  transition: border-color .2s, background .2s;
  font-family: inherit;
}

.composer-row input:focus {
  border-color: var(--brand-line);
  background: #fff;
}

.composer-row input::placeholder { color: var(--ink-3); }

button {
  font-family: inherit;
  border: none;
  cursor: pointer;
  transition: transform .12s, opacity .2s;
}

button:active { transform: scale(.94); }
button:disabled { opacity: .5; cursor: not-allowed; }

.mic {
  flex: 0 0 auto;
  width: 42px; height: 42px;
  border-radius: 999px;
  background: #f1f5f9;
  font-size: 17px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.mic.on {
  background: var(--brand);
  box-shadow: 0 0 0 6px rgba(249, 115, 22, .16);
}

.send {
  flex: 0 0 auto;
  height: 42px;
  padding: 0 18px;
  border-radius: 999px;
  background: var(--brand);
  color: #fff;
  font-size: 14px;
  font-weight: 700;
  letter-spacing: .5px;
  box-shadow: 0 4px 12px rgba(249, 115, 22, .3);
}

/* 等待灵妹回复时的三点动画 —— 强化版（v3.68） */
/* 用户耐心低时，要有明确的「灵妹在想」信号：放大 + 灵妹橙 + 明显的依次跳起 */
.typing { display: inline-flex; gap: 7px; align-items: center; height: 26px; padding: 3px 2px; }
.typing i {
  width: 9px; height: 9px; border-radius: 999px; background: var(--brand);
  animation: think 1.2s infinite ease-in-out;
}
.typing i:nth-child(2) { animation-delay: .2s; }
.typing i:nth-child(3) { animation-delay: .4s; }

@keyframes think {
  0%, 60%, 100% { transform: translateY(0) scale(.6); opacity: .4; }
  30% { transform: translateY(-8px) scale(1); opacity: 1; }
}

/* ---------------- 注册浮层 ---------------- */

.overlay {
  position: fixed;
  inset: 0;
  background: rgba(31, 36, 48, .42);
  backdrop-filter: blur(6px);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  z-index: 50;
  transition: opacity .25s;
}

.overlay.hide { opacity: 0; pointer-events: none; }

.card {
  width: 100%;
  max-width: 320px;
  background: #fff;
  border-radius: 20px;
  padding: 28px 24px 22px;
  text-align: center;
  box-shadow: 0 20px 50px rgba(31, 36, 48, .25);
}

.card-emoji { font-size: 40px; line-height: 1; }

.card h2 {
  margin: 12px 0 6px;
  font-size: 20px;
  font-weight: 800;
  letter-spacing: -.3px;
}

.card-sub {
  margin: 0 0 20px;
  font-size: 13px;
  color: var(--brand);
  font-weight: 600;
}

.card input {
  width: 100%;
  height: 46px;
  padding: 0 16px;
  border-radius: 12px;
  border: 1px solid var(--line);
  background: #f6f8fb;
  font-size: 15px;
  text-align: center;
  outline: none;
  font-family: inherit;
  color: var(--ink);
}

.card input:focus { border-color: var(--brand-line); background: #fff; }

.card .primary {
  width: 100%;
  height: 46px;
  margin-top: 14px;
  border-radius: 12px;
  background: var(--brand);
  color: #fff;
  font-size: 15px;
  font-weight: 700;
  letter-spacing: 1px;
  box-shadow: 0 6px 18px rgba(249, 115, 22, .32);
}

.card-tip {
  margin: 16px 0 0;
  font-size: 11px;
  color: var(--ink-3);
  line-height: 1.6;
}

/* 网站名称（＝ICP 备案名）。故意比提示文字更淡、更小：
   它是给「核对名称」和「记住名字」用的，不该抢走「开始」按钮的注意力。 */
.card-brand {
  margin: 8px 0 0;
  font-size: 11px;
  letter-spacing: .08em;
  color: var(--ink-3);
  opacity: .6;
}

/* 注册/开始失败时的提示，空着的时候不占位置 */
.card-err {
  margin: 12px 0 0;
  font-size: 12.5px;
  color: #b91c1c;
  line-height: 1.6;
  font-weight: 600;
}

.card-err:empty { display: none; }

/* ---------------- 服务不可达 / 脚本未加载提示 ----------------
   以前服务停了只往控制台写一行错，人在页面上什么都看不到，
   表现为「点了开始没反应」。现在一律在界面上说清楚。 */

.offline-bar {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  z-index: 60;
  padding: 10px 16px 11px;
  background: #fef2f2;
  color: #b91c1c;
  font-size: 12.5px;
  line-height: 1.65;
  font-weight: 600;
  border-bottom: 1px solid #fecaca;
  transform: translateY(-102%);
  transition: transform .25s ease-out;
}

.offline-bar.show { transform: translateY(0); }

.offline-bar .cmd {
  display: inline-block;
  margin-top: 4px;
  padding: 2px 7px;
  border-radius: 6px;
  background: #fff;
  border: 1px solid #fecaca;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 11.5px;
  font-weight: 500;
  word-break: break-all;
}

/* 聊天流里的错误气泡 */
.row.ai .bubble.err {
  background: #fef2f2;
  border-color: #fecaca;
  color: #b91c1c;
}

/* ---------------- 换课提示卡 ----------------
   一个单元的内容是有限的：L1-U01 只有 4 个新词、十几个句式。
   聊到 suggestedTurns 之后，硬撑在同一课里只会让他听到第二遍 ——
   所以这里给一个明确的出口，而不是继续重复。
   （这个字段以前在服务端定义了却没人用，实测导致用户在「打招呼」单元里聊了 21 轮。） */

.row.ai .advance {
  margin: 2px 0 4px;
  padding: 12px 14px;
  border-radius: 12px;
  background: var(--brand-soft);
  border: 1px solid var(--brand-line);
}

.row.ai .advance .title {
  font-size: 13px;
  font-weight: 700;
  color: #9a3412;
  margin-bottom: 4px;
}

.row.ai .advance .desc {
  font-size: 11.5px;
  color: var(--ink-2);
  line-height: 1.65;
  margin-bottom: 10px;
}

.row.ai .advance button {
  width: 100%;
  padding: 9px 12px;
  border: none;
  border-radius: 9px;
  background: var(--brand);
  color: #fff;
  font-size: 13px;
  font-weight: 600;
  font-family: inherit;
  cursor: pointer;
  transition: opacity .15s;
}

.row.ai .advance button:hover:not(:disabled) { opacity: .9; }
.row.ai .advance button:disabled { opacity: .55; cursor: default; }

/* ---------------- 语音（说 + 听） ---------------- */
/*
 * 【2026-10-07 用户实测修正 —— 用户原话：
 *   「我现在是不能调用语音，但是 AI 给我发出对话之后，为什么 AI 也没有声音呢？」】
 *
 * 查完的结论不是「哪里没设置」，而是这两个方向根本没接：
 *   · 说：AI 回的话只被画成文字，代码里没有任何播报逻辑
 *   · 听：麦克风点了只弹一句「语音识别还没接服务商」
 * 先用浏览器自带的 Web Speech API 把两头接上（零凭证、零成本、手机上可用）。
 */

/* 顶栏的「声音开关」 */
.voice-toggle {
  flex: 0 0 auto;
  width: 34px;
  height: 34px;
  border-radius: 50%;
  border: 1px solid var(--line);
  background: #fff;
  font-size: 15px;
  line-height: 1;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: background .15s, border-color .15s;
}

/* 【v3.86】语气词关掉时：按钮淡下去，一眼能看出「它现在不说话」。
   不换图标位置、不改尺寸 —— 顶栏这一排必须对齐，跳动会显得坏掉。 */
.voice-toggle.off {
  opacity: .45;
  background: #f6f6f6;
}

.voice-toggle.on {
  background: var(--brand-soft);
  border-color: var(--brand-line);
}

/* 每条 AI 消息右下角的「再念一遍」 */
.bubble .say {
  margin-top: 6px;
  padding: 2px 8px;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: #fff;
  color: var(--ink-2);
  font-size: 11px;
  font-family: inherit;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  gap: 4px;
}

.bubble .say:active { background: var(--brand-soft); }
.bubble.speaking { box-shadow: 0 0 0 2px var(--brand-line); }

/* 【2026-10-23】按下「再念一遍」却出不了声（他的话筒正开着）时：
   按钮**在他手指底下变样子**，把原因写在这里 —— 写在别处他看不见。 */
.bubble .say.say-wait,
.wc-btn.say-wait {
  /* 配色跟话筒「等她说完」的琥珀色一家人：都表示「现在还不成，等一下就行」，
     而不是「坏了」（红色那种）。 */
  background: #fffbeb;
  border-color: #fcd34d;
  color: #92400e;
  font-weight: 600;
}

/* 正在收音时的麦克风 */
.mic.listening {
  background: #ef4444;
  color: #fff;
  animation: pulse 1.1s ease-in-out infinite;
}

/* 「她还在说，你在等」—— 这个状态必须长在按钮上。
   【2026-10-22 用户第四次报「点话筒没反应」】
   排队原来只写一行 11px 浅灰小字（.voice-note），手机上常在输入法/底栏后面，
   他手指落在话筒上看到的就是「按钮一动不动」。
   颜色刻意跟 listening 的红色分开：红色＝轮到你了，琥珀＝还在等她。 */
.mic.waiting {
  background: #f59e0b;
  color: #fff;
  animation: waitpulse 1.4s ease-in-out infinite;
}

@keyframes waitpulse {
  0%, 100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, .5); }
  50% { box-shadow: 0 0 0 7px rgba(245, 158, 11, 0); }
}

@keyframes pulse {
  0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, .45); }
  50% { box-shadow: 0 0 0 8px rgba(239, 68, 68, 0); }
}

/* ---------------- 环境提示条 ----------------
   【2026-10-07 用户实测第二轮：「手机上还是不能用麦克风，也听不到声音」】
   代码发布没问题，问题出在**运行环境**（多半是在微信里打开的）。
   手机上没法看控制台，所以把「为什么不能用 + 怎么办」直接印在屏幕上。
   只在确实有问题时出现，正常浏览器里永远看不到它。 */

.notice-bar {
  flex: 0 0 auto;
  display: none;
  padding: 10px 16px 11px;
  background: #fffbeb;
  color: #8a5a00;
  border-bottom: 1px solid #fde68a;
  font-size: 12.5px;
  line-height: 1.65;
}

.notice-bar.show { display: block; }
.notice-bar b { color: #6b4400; }

/* 技术自检：手机截图发过来就能定位，不用用户描述 */
.notice-bar .diag {
  margin-top: 7px;
  padding-top: 7px;
  border-top: 1px dashed #f0d49a;
  color: #9b7a3a;
  font-size: 11px;
  line-height: 1.55;
  word-break: break-all;
}

/* 【2026-10-07 第四轮】不再把话筒画成灰色。
   以前「点了必然没反应」所以灰掉是合理的；现在点它一定会做一件有用的事
   （唤出键盘 + 展开上面的输入法引导卡），灰掉反而让人不敢点。 */
.mic.off { opacity: .45; }

/* 喊了朗读但一直没出声 —— 顶栏的声音图标给出视觉信号 */
.voice-toggle.warn {
  background: #fff1f0;
  border-color: #fecaca;
}

/* ---------------- 内测量尺 · 把记录交出去 ---------------- */
/*
 * 这一块不是给学员用的功能，是给「拿到链接帮忙试一下」的人用的出口。
 * 所以整块默认不存在（display:none，且 JS 发现本地真的有记录才加 .show）——
 * 没有记录时把它摆在那里，只会让人按下去然后困惑。
 *
 * 为什么非要把它做出来：记录本来就存在他自己的浏览器里，
 * 而微信内置浏览器没有开发者工具 —— 没有这个出口，
 * 每测一轮能拿回来的只有一句「还行，聊了七八分钟」。
 */
.report-btn {
  flex: 0 0 auto;
  display: none;
  width: 34px;
  height: 34px;
  border-radius: 50%;
  border: 1px solid var(--line);
  background: #fff;
  font-size: 15px;
  line-height: 1;
  cursor: pointer;
  align-items: center;
  justify-content: center;
  transition: background .15s, border-color .15s;
}

.report-btn.show { display: flex; }
.report-btn:active { background: var(--brand-soft); border-color: var(--brand-line); }

.report-card {
  flex: 0 0 auto;
  display: none;
  margin: 0 12px 10px;
  padding: 12px 13px 13px;
  border-radius: var(--radius);
  background: #f5f7fb;
  border: 1px solid #dfe5ef;
}

.report-card.show { display: block; }

.report-head {
  font-size: 13px;
  font-weight: 700;
  color: var(--ink);
  margin-bottom: 4px;
}

.report-tag {
  margin-left: 6px;
  padding: 1px 6px;
  border-radius: 999px;
  background: var(--brand-soft);
  border: 1px solid var(--brand-line);
  color: #b45309;
  font-size: 10.5px;
  font-weight: 800;
  letter-spacing: .4px;
}

.report-sub {
  margin: 0 0 8px;
  font-size: 12px;
  line-height: 1.65;
  color: var(--ink-2);
}

.report-summary {
  margin: 0 0 8px;
  padding: 8px 10px;
  border-radius: 9px;
  background: #fff;
  border: 1px solid var(--line);
  color: var(--ink);
  font-size: 12px;
  line-height: 1.7;
  white-space: pre-wrap;
  word-break: break-word;
  font-family: inherit;
}

/* 记录本体：等宽、字小、能整段选中。
   复制失败时它就是唯一的退路（长按全选），所以高度不能太矮。 */
.report-text {
  width: 100%;
  height: 64px;
  padding: 8px 10px;
  border-radius: 9px;
  border: 1px solid var(--line);
  background: #fff;
  color: var(--ink-3);
  font-size: 10.5px;
  line-height: 1.5;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  resize: none;
  word-break: break-all;
}

.report-actions {
  display: flex;
  gap: 8px;
  margin-top: 9px;
}

.report-copy,
.report-close {
  height: 34px;
  border-radius: 9px;
  font-size: 13px;
  font-weight: 700;
  cursor: pointer;
}

.report-copy {
  flex: 1;
  border: 1px solid var(--brand-line);
  background: var(--brand);
  color: #fff;
}

.report-copy:active { opacity: .85; }

.report-close {
  flex: 0 0 88px;
  border: 1px solid var(--line);
  background: #fff;
  color: var(--ink-2);
}

.report-hint {
  margin: 8px 0 0;
  font-size: 11.5px;
  line-height: 1.6;
  color: var(--ok);
}

/* 复制没成功时换成琥珀色 —— 它同时是一句「往下怎么走」的说明，不是报错 */
.report-hint.warn { color: #b45309; }

/* ---------------- 点单词：发音 + 音标 + 意思 ----------------
 *
 * 【2026-10-16】他在手机上一连五轮问「怎么拼 / 怎么念 / 音标」，
 * 而那个词就在屏幕上。所以气泡里的英文词一律可点，点开是一张小词条卡。
 *
 * 「能点」必须看得出来 —— 完全没痕迹的话，这个功能等于不存在。
 * 痕迹又要足够轻：一整句每个词都挂粗下划线，读起来就是噪音。
 * 所以只给一条最淡的虚线，按下去才变色。
 */

.bubble .w {
  border-bottom: 1px dotted var(--ink-3);
  cursor: pointer;
}

.bubble .w:active {
  background: var(--brand-soft);
  border-radius: 3px;
}

/* 词块：`little by little` 这一整块是一个热区。
   【为什么必须和单词长得不一样】点下去的结果不同（整块意思 vs 单词意思），
   而用户在下手之前只有**下划线的样子**这一个线索。实线/虚线混着用会让人以为
   这是两种「不同重要程度」的词；这里用「同一条线、但连成一整段」来表达「它是一块」。 */
.bubble .w-chunk {
  border-bottom-style: solid;
  border-bottom-color: var(--brand, #38b2ac);
}

/* 【v3.45 中文桥】英文在上、中文小字压在**同一个词**下面。
   ── 为什么用 inline-flex 竖排、而不是另起一行写中文 ──
   他 2026-10-27 的原话：「让它变成英文，不要把中文显示在上面。把这个中文显示在对应的英文下面。」
   另外起一行的话，一句里配 3 个词就多出 3 行，手机上对话区只剩一半；
   压在词下面只长高一点点，而且**中文永远跟着那个词**，不会看串行。
   ── 为什么下划线放在 <b>（英文）上 ──
   下划线是这个项目的「能点」线索。它必须跟着**英文那个词**走，
   而不是跟着下面那行中文 —— 否则他会去点中文。 */
.bubble .w.glossed {
  display: inline-flex;
  flex-direction: column;
  align-items: center;
  vertical-align: baseline;
  line-height: 1.12;
  margin: 0 1px;
  border-bottom: 0;          /* 底下的虚线由 <b> 自己画，见下一条 */
}
.bubble .w.glossed > b {
  font-weight: inherit;
  border-bottom: 1px dotted var(--ink-3);
}
.bubble .w.glossed > i {
  font-style: normal;
  font-weight: 400;
  font-size: 10.5px;
  line-height: 1.15;
  color: var(--ink-3);
  margin-top: 1px;
  max-width: 7em;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.wordcard {
  position: fixed;
  left: 50%;
  bottom: 18px;
  transform: translateX(-50%);
  width: min(400px, calc(100vw - 24px));
  z-index: 60;
  padding: 14px 16px 12px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--card);
  box-shadow: 0 14px 34px rgba(31, 36, 48, .18);
  animation: wc-in .16s ease-out;
  /* 【v3.61】卡片里现在有三段（释义 / 搭配 / 例句），必须能滚。
     不设上限时它只会往上长，手机上一屏装不下 —— 上面那几行他永远看不到，
     而且卡片是 `position:fixed; bottom`，长过头就会顶到屏幕外面去。
     66vh：留得下键盘弹起时的空间，也不至于把对话全挡住。 */
  max-height: min(66vh, 560px);
  overflow-y: auto;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
}

@keyframes wc-in {
  from { opacity: 0; transform: translateX(-50%) translateY(8px); }
  to   { opacity: 1; transform: translateX(-50%) translateY(0); }
}

.wc-head {
  display: flex;
  align-items: baseline;
  gap: 8px;
  margin-bottom: 4px;
}

/* 【v3.61】词性跟着词头走（n. / v. / adj.）：词典的第一格就是这个，
   但它比词小得多 —— 它回答的是「这词归哪一类」，不是「这词是什么」。 */
.wc-pos {
  flex: 0 0 auto;
  align-self: center;
  font-size: 12px;
  font-style: italic;
  letter-spacing: .02em;
  color: var(--ink-3);
}

.wc-word {
  flex: 0 1 auto;
  font-size: 20px;
  font-weight: 600;
  letter-spacing: .2px;
  color: var(--ink);
  word-break: break-word;
}

.wc-x {
  flex: 0 0 auto;
  /* 【v3.61】词性跟着单词走，关闭钮自己靠右 ——
     不这么写时 `wc-word` 曾撑满整行，`n.` 被挤到最右边、挨着 ✕，
     读起来像「✕ 的注解」，而不是「health 的词性」。 */
  margin-left: auto;
  border: 0;
  background: transparent;
  color: var(--ink-3);
  font-size: 15px;
  line-height: 1;
  padding: 4px 2px 4px 8px;
  cursor: pointer;
}

/* 「词组」小标签：告诉他这次点开的是一整块，不是一个词 */
.wc-tag {
  flex: 0 0 auto;
  align-self: center;
  font-size: 11px;
  line-height: 1;
  padding: 3px 6px;
  border-radius: 999px;
  color: var(--brand-ink, #0f766e);
  background: var(--brand-soft, #e6fffb);
}

.wc-row {
  display: flex;
  align-items: baseline;
  gap: 10px;
  padding: 3px 0;
}

/* v3.25：词卡视觉区 —— 有图用词图，无图回退灵妹头像
   【v3.61 已拆】他 2026-10-28 说「可以把那个头像去掉，空白地方就形成这样的感觉」。
   这一格 120×120 让出去了，位置给了搭配和例句 —— 卡片上是词典，不是她的照片。
   （他说词时右上角那个小浮图 `.word-float` 是另一条路，还在。） */

/* ---------------- 词典条目的两段（v3.61） ----------------
 *
 * 排版照着他给的样子做：词性 → 释义（带括注）→ 怎么用 → 搭配 → 例句。
 * 两段之间用**虚线**而不是实线：实线会把卡片切成两个盒子，
 * 虚线读起来还是「同一张卡往下写」，这正是词典页面的样子。
 */
.wc-sec {
  margin-top: 11px;
  padding-top: 9px;
  border-top: 1px dashed var(--line);
}

.wc-sec-t {
  font-size: 11px;
  letter-spacing: .08em;
  color: var(--ink-3);
  margin-bottom: 5px;
}

/* 「怎么用」那一句 —— 整张卡里最像老师说话的，所以比搭配例子低一档：
   它是提示，例子才是他要记住的。 */
.wc-use {
  font-size: 12.5px;
  line-height: 1.5;
  color: var(--ink-2);
  margin-bottom: 5px;
}

.wc-col {
  display: flex;
  align-items: baseline;
  gap: 8px;
  padding: 2px 0;
}

/* 搭配的英文可以折行（`care about your health` 在窄屏上放不下），中文不折 —— 
   中文折成两行会让「这一条搭配」看起来像两条。 */
.wc-col .en {
  flex: 1 1 auto;
  font-size: 14px;
  line-height: 1.4;
  color: var(--ink);
}

.wc-col .zh {
  flex: 0 0 auto;
  font-size: 12.5px;
  color: var(--ink-3);
  white-space: nowrap;
}

.wc-ex-en {
  font-size: 14.5px;
  line-height: 1.45;
  color: var(--ink);
}

.wc-ex-zh {
  font-size: 12.5px;
  line-height: 1.45;
  color: var(--ink-3);
  margin-top: 3px;
}

/* 骨架：这一块正在问模型（1.6–2.5 秒）。
   【为什么给骨架而不是什么都不显示】什么都不显示时，卡片会先矮后高 ——
   他刚看完两行，卡片突然往上窜一截，像是点错了什么。
   三条灰条把「正在查」说出来，也把将来那块地方先占住。
   只在服务端说 `dict: live` 时才出现（见 app.js 的 loadDictEntry）。 */
.wc-skel {
  display: flex;
  flex-direction: column;
  gap: 7px;
  margin-top: 11px;
  padding-top: 9px;
  border-top: 1px dashed var(--line);
}

.wc-skel i {
  display: block;
  height: 9px;
  border-radius: 5px;
  background: linear-gradient(90deg, #f1f4f8 25%, #e6ecf5 37%, #f1f4f8 63%);
  background-size: 400% 100%;
  animation: wc-skel 1.15s ease-in-out infinite;
}

.wc-skel i:nth-child(1) { width: 58%; }
.wc-skel i:nth-child(2) { width: 88%; }
.wc-skel i:nth-child(3) { width: 72%; }

@keyframes wc-skel {
  from { background-position: 100% 0; }
  to   { background-position: 0 0; }
}

/* v3.26 B：灵妹说到有图词时，右上角自动浮出小图（淡入 4.2s 后淡出，不挡点击） */
.word-float {
  position: fixed;
  top: 76px;
  right: 12px;
  z-index: 50;
  opacity: 0;
  transform: translateY(-6px) scale(0.96);
  transition: opacity 0.25s ease, transform 0.25s ease;
  pointer-events: none;
}
.word-float.on {
  opacity: 1;
  transform: none;
}
.word-float img {
  display: block;
  width: 84px;
  height: 84px;
  object-fit: cover;
  border-radius: 12px;
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.15);
}
.word-float .wf-cap {
  display: block;
  text-align: center;
  font-size: 12px;
  color: var(--ink-3);
  margin-top: 2px;
}

.wc-k {
  flex: 0 0 30px;
  font-size: 11px;
  color: var(--ink-3);
}

.wc-v {
  flex: 1 1 auto;
  font-size: 15px;
  color: var(--ink);
  word-break: break-word;
}

/* 【v3.61】释义是这张卡里最大的一行字 —— 他点开一个词，第一眼要找的就是它。
   以前它和音标一样大（15px），于是「健康」和「/helθ/」看起来同等重要；
   实际上一张词典卡里，音标是注脚，释义才是正文。 */
.wc-cn {
  font-size: 16.5px;
  font-weight: 500;
  line-height: 1.45;
}

/* v3.43 义项语境化：上面那行是「这句里它的意思」，这一行是「它还有别的意思」。
   小一号、灰一档 —— 两行长得一样他会以为这词同时有两个释义。
   ⚠ 词卡里 `[hidden]` 已被顶部 `[hidden]{display:none !important}` 统一兜住，
   这里不要再写 display，否则会把 hidden 顶掉（教训 105）。 */
.wc-altrow .wc-v {
  font-size: 13px;
  color: var(--ink-3);
}

/* 音标单独给一个等宽字号：/ˈkreɪɑːn/ 里的符号挤在一起很难认 */
.wc-ph {
  font-family: "SF Mono", Menlo, Consolas, monospace;
  font-size: 15px;
  color: var(--brand);
}

.wc-sp {
  font-family: "SF Mono", Menlo, Consolas, monospace;
  letter-spacing: 1px;
  color: var(--ink);
}

.wc-acts {
  display: flex;
  gap: 8px;
  margin-top: 11px;
}

.wc-btn {
  flex: 1 1 0;
  padding: 9px 0;
  font-size: 14px;
  border-radius: 10px;
  border: 1px solid var(--line);
  background: #fff;
  color: var(--ink);
  cursor: pointer;
}

.wc-btn:active { opacity: .8; }

.wc-say {
  border-color: var(--brand-line);
  background: var(--brand-soft);
  color: var(--brand);
  font-weight: 600;
}



/* ---------------- 选课浮层（2026-10-18） ---------------- */
/*
 * 打开方式：点右上角的级别徽章。
 * 为什么做成浮层而不是新页面：它只是「换一课」，换完还是原来那个输入框、原来那个话筒。
 */

.picker {
  max-width: 340px;
  padding: 18px 16px 14px;
  text-align: left;
  display: flex;
  flex-direction: column;
  max-height: 76vh;
}

.picker-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 2px;
}

.picker-head h2 {
  margin: 0;
  font-size: 17px;
  font-weight: 800;
  letter-spacing: -.2px;
}

.picker-x {
  width: 28px;
  height: 28px;
  border: none;
  border-radius: 50%;
  background: #f1f4f9;
  color: var(--ink-2);
  font-size: 13px;
  line-height: 1;
  cursor: pointer;
}

.picker-sub {
  margin: 0 0 12px;
  font-size: 12px;
  color: var(--ink-3);
}

.picker-levels {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-bottom: 10px;
}

.pl-chip {
  min-width: 38px;
  height: 28px;
  padding: 0 9px;
  border-radius: 999px;
  border: 1px solid var(--line);
  background: #fff;
  color: var(--ink-2);
  font-size: 12px;
  font-weight: 700;
  font-family: inherit;
  cursor: pointer;
}

.pl-chip.on {
  background: var(--brand);
  border-color: var(--brand);
  color: #fff;
}

/* 有的级别一节课都还没有（L1 只有手写那几课），按着不给点比点进去看到空白好 */
.pl-chip.empty { opacity: .35; }

.picker-list {
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  border-top: 1px solid var(--line);
  padding-top: 6px;
}

.pc-row {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  padding: 9px 6px;
  border: none;
  border-bottom: 1px solid #f3f6fa;
  background: none;
  text-align: left;
  font-family: inherit;
  cursor: pointer;
}

.pc-row:active { background: var(--brand-soft); }

.pc-main { flex: 1 1 auto; min-width: 0; }

.pc-title {
  display: block;
  font-size: 14px;
  font-weight: 600;
  color: var(--ink);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.pc-meta {
  display: block;
  margin-top: 2px;
  font-size: 11px;
  color: var(--ink-3);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* 【2026-10-23】「第 3 课」是**位置**，后面那串词是**内容**。
   两者一样灰就会粘成一团，序号也就不成为序号了 —— 所以序号用品牌色加粗，
   一排排下去能当目录号扫读；词保持灰色，说明这一课练什么。 */
.pc-seq {
  font-weight: 700;
  color: var(--brand);
}

.pc-done {
  flex: 0 0 auto;
  font-size: 11px;
  font-weight: 700;
  color: var(--ok);
}

/* 「在上的」要用品牌色，别和绿色的「练过」混成一个意思：
   绿色说的是历史（练过），品牌色说的是此刻（你就在这一课里）。 */
.pc-here {
  color: var(--brand);
  margin-right: 6px;
}

.picker-empty {
  padding: 18px 6px;
  font-size: 13px;
  color: var(--ink-3);
  text-align: center;
}

/* ---------------- 找回码（v3.19） ----------------
 *
 * 两处用到：① 开场卡 / 404 卡上那行入口（`.card-link`）；
 *           ② 「我的进步」卡最下面的「我的找回码」（`.rec-mine`）。
 *              2026-11-03 v4.23 从 📋 卡搬来：📋 要有本地记录才露，换域名的人正好什么本地记录都没有。
 *
 * 为什么入口做成一条**文字链**而不是按钮：它是一次备用动作（只有换了设备、清了缓存
 * 才用得上），不该和「开始」抢注意力。但它必须能点、必须看得见 ——
 * 灰字小字放到最底下就等于没做（教训 77）。
 */
.card-link {
  display: block;
  width: 100%;
  margin: 12px 0 0;
  padding: 6px 2px;
  border: none;
  background: none;
  color: var(--brand);
  font-size: 12.5px;
  font-weight: 700;
  font-family: inherit;
  line-height: 1.5;
  text-align: center;
  text-decoration: underline;
  text-underline-offset: 3px;
  cursor: pointer;
}

.card-link:active { opacity: .7; }

/* 接回来了 —— 同一行位置换成好消息，不许继续用红字（红字＝出事了） */
.card-err.ok { color: var(--ok); }

.rec-tip {
  margin: 14px 0 0;
  font-size: 11.5px;
  line-height: 1.7;
  color: var(--ink-3);
}

.rec-mine {
  margin-top: 12px;
  padding-top: 11px;
  border-top: 1px dashed #dfe5ef;
}

.rec-mine-head {
  font-size: 12px;
  font-weight: 700;
  color: var(--ink-2);
}

.rec-mine-hint {
  margin-left: 6px;
  font-size: 11px;
  font-weight: 600;
  color: var(--ink-3);
}

.rec-mine-row {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 6px;
}

/* 等宽 + 大字：他要一眼抄下来，别让他数小字 */
.rec-mine-code {
  flex: 1;
  font-size: 17px;
  font-weight: 800;
  letter-spacing: 1.5px;
  color: var(--ink);
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  /* 复制被拒时他得能长按选中 —— 这一行必须可选中 */
  user-select: text;
  -webkit-user-select: text;
}

.rec-mine-copy {
  flex: 0 0 auto;
  height: 30px;
  padding: 0 12px;
  border-radius: 8px;
  border: 1px solid var(--brand-line);
  background: #fff;
  color: var(--brand);
  font-size: 12.5px;
  font-weight: 700;
  font-family: inherit;
  cursor: pointer;
}

.rec-mine-copy:active { background: var(--brand-soft); }

.rec-mine-tip {
  margin: 7px 0 0;
  font-size: 11px;
  line-height: 1.6;
  color: var(--ink-3);
}

/* 【v4.21】认领入口（`.card-link` 的通用样式照用，这里只压下间距）
 * 它跟「我的找回码」是一对：上面看自己的码，下面用别人的码接回。
 * 【v4.23】两块一起搬进「我的进步」卡 —— 📋 卡要有本地记录才露，
 * 换域名过来的人正是本地什么都没有，所以认领入口不能再挂在它上面。 */
.rec-claim {
  margin: 10px 0 0;
  font-size: 12px;
}

/* =========================================================================
   【v3.38 竖屏沉浸布局】2026-10-27 拍板
   设计：竖版长城做全屏背景 + 灵妹站景中（右侧）+ 左侧字幕带 + 顶部 50% 预留
         （天空 / 灵妹头部，不压字幕）。滚动方式＝选 A：字幕带在固定高度内滚，
         老消息出带但可下拉回看（原生 scroll），不滚到手机顶端。

   ★ 横屏 16:9 旧布局**原样保留**，这里是纯叠加：所有竖屏规则都圈在
     `body.layout-portrait` 作用域里，不动任何横屏规则。
   ★ 一键切回横屏：网址加 ?layout=scene（applySceneLayout 读这个参数）。
   ★ 文字可读性（产品原则「看字幕练读」）：字幕带背后垫半透明深色遮罩，
     文字固定白色＋阴影；遮罩深浅由 adaptScrim() 按背景平场亮度自适应
     （图越亮遮罩越深），不是逐行换字色（会闪）。
   ========================================================================= */

/* 顶栏 / 提示条 / 找回卡 / 备案脚：竖屏里 .scene 是 absolute 铺满，会盖住普通流元素，
   这几样必须抬到 scene 之上。 */
body.layout-portrait .topbar,
body.layout-portrait .offline-bar,
body.layout-portrait .notice-bar,
body.layout-portrait .report-card,
body.layout-portrait .site-footer {
  position: relative;
  z-index: 5;
}

/* 场景：从「16:9 横框」变成「全屏背景」。背景换成竖版长城
   （web/images/scene-bg-portrait.jpg，1600×2848≈9:16）。深色兜底，图失败不露白。 */
body.layout-portrait .scene {
  position: absolute;
  inset: 0;
  height: 100%;
  aspect-ratio: auto;
  border-bottom: none;
  z-index: 0;
  background: #0b1220 url('/images/scene-bg-portrait.jpg') center / cover no-repeat;
}

/* 灵妹：竖屏里站景中右侧，全高取景（不是半身裁切），脚落到底部输入框之上。 */
body.layout-portrait .ling-box {
  height: 58%;
  right: 2%;
  bottom: 86px;     /* 让开底部输入框 */
}

/* 词图：避开顶栏，下移到头部以下 */
body.layout-portrait .scene-word { top: 64px; }

/* HUD（本单元新词 / 难度标尺）：竖屏沉浸下先隐藏，保持画面干净；
   想回来去掉这行 display:none 并定个位即可，不影响功能。 */
body.layout-portrait .hud { display: none; }

/* 字幕带：左侧、顶部 50% 起、底部留输入框高度。背后遮罩（深浅自适应），
   顶部渐隐让上沿软切进背景，不做硬边。原生 scroll ⇒ 选 A（老消息可下拉回看）。 */
body.layout-portrait .chat {
  position: absolute;
  left: 0;
  top: 50%;
  width: 60%;
  bottom: 86px;
  z-index: 4;
  padding: 16px 14px 24px;
  border-radius: 0 16px 0 0;
  background:
    linear-gradient(180deg,
      rgba(15,23,42,0) 0%,
      rgba(15,23,42,var(--chat-scrim, .55)) 16%,
      rgba(15,23,42,var(--chat-scrim, .55)) 100%);
  backdrop-filter: blur(2px);
  -webkit-backdrop-filter: blur(2px);
}

/* 字幕带里的气泡：去掉卡片底色，文字直接压在遮罩上（字幕感），白色＋阴影保证可读。 */
body.layout-portrait .row.ai,
body.layout-portrait .row.me { align-self: flex-start; max-width: 100%; }
body.layout-portrait .bubble {
  background: transparent !important;
  border: none !important;
  padding: 6px 8px;
  color: #fff;
  text-shadow: 0 1px 3px rgba(0,0,0,.6);
}
body.layout-portrait .bubble .en { color: #fff; }
body.layout-portrait .bubble .zh {
  margin-top: 4px;
  padding-top: 4px;
  border-top: 1px dashed rgba(255,255,255,.32);
  color: rgba(255,255,255,.85);
}

/* 输入框：竖屏里钉在底部，半透明以融入背景 */
body.layout-portrait .composer {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 5;
  background: rgba(255,255,255,.92);
}

/* =========================================================================
   【v3.75 · 微信式对话布局（默认）】
   顶部 16:9 小场景（她站在框里）＋ 下方整块聊天区：头像 + 左右气泡。
   ★ 竖屏沉浸版（网址加 ?layout=portrait）**一个像素都不动**：
     头像关掉、行退回 flex 竖排，见下面三条。
   ========================================================================= */

/* 竖屏字幕带里不挂头像：一是它本来就是"字幕"，二是灵妹立绘已经站在场景里，
   再挂个头像等于同一个角色在屏幕上出现两次。 */
body.layout-portrait .row.ai::before,
body.layout-portrait .row.me::before { display: none; }
body.layout-portrait .row {
  display: flex;
  flex-direction: column;
  max-width: 100%;
}
body.layout-portrait .row.tip { align-self: center; }

/* 对话行之间收一点空 —— 微信比原来的 14px 更紧凑 */
body:not(.layout-portrait) .chat { gap: 10px; }

/* ---------------- 我的头像（顶栏入口） ----------------
   为什么入口在顶栏、而不是"点聊天里自己的头像"：
   聊天区的头像是 `::before` 伪元素，接不了点击事件（它的好处是不会被
   `row.innerHTML` 冲掉，见 .row 那段说明）。而顶栏本来就该有一处「我的身份」。 */
.me-avatar {
  flex: 0 0 auto;
  width: 34px;
  height: 34px;
  padding: 0;
  border-radius: 50%;
  border: 1px solid var(--line);
  background-color: var(--my-av-bg, #fef3c7);
  display: grid;
  place-items: center;
  font-size: 17px;
  line-height: 1;
  font-family: inherit;
  cursor: pointer;
  transition: transform .12s;
}
.me-avatar:active { transform: scale(.93); }

/* ---------------- 选头像浮层 ---------------- */
.av-sheet {
  position: fixed;
  inset: 0;
  z-index: 60;
  display: none;
  align-items: flex-end;
  justify-content: center;
  background: rgba(15, 23, 42, .42);
}
.av-sheet.open { display: flex; }

.av-card {
  width: 100%;
  max-width: 390px;
  background: #fff;
  border-radius: 18px 18px 0 0;
  padding: 16px 16px calc(16px + env(safe-area-inset-bottom, 0px));
  animation: avUp .24s ease-out;
}
@keyframes avUp {
  from { transform: translateY(18px); opacity: .6; }
  to { transform: none; opacity: 1; }
}

.av-head { font-size: 14px; font-weight: 700; }
.av-sub {
  margin: 4px 0 12px;
  font-size: 11.5px;
  line-height: 1.55;
  color: var(--ink-2);
}
.av-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
.av-item {
  aspect-ratio: 1;
  border: 2px solid transparent;
  border-radius: 14px;
  display: grid;
  place-items: center;
  font-size: 24px;
  line-height: 1;
  font-family: inherit;
  cursor: pointer;
  transition: transform .12s, border-color .12s;
}
.av-item:active { transform: scale(.92); }
.av-item.on { border-color: var(--brand); }

.av-close {
  width: 100%;
  margin-top: 14px;
  padding: 10px;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: #fff;
  font-family: inherit;
  font-size: 13px;
  color: var(--ink-2);
  cursor: pointer;
}

/* ---------------- 改名字浮层（v4.01） ---------------- */
/* 外壳复用 .av-sheet / .av-card（同一个底部浮层，不多造一种弹法），
   这里只补输入框和两个按钮。尺寸口径跟 .card input 对齐：46px 高、12px 圆角。 */
.name-input {
  width: 100%;
  height: 46px;
  padding: 0 16px;
  border-radius: 12px;
  border: 1px solid var(--line);
  background: #f6f8fb;
  font-size: 16px;          /* ⚠ 不能小于 16px：iOS 上 15px 会让整个页面被放大 */
  text-align: center;
  outline: none;
  font-family: inherit;
  color: var(--ink);
}
.name-input:focus { border-color: var(--brand-line); background: #fff; }

.name-err {
  margin: 8px 2px 0;
  min-height: 15px;         /* 占位：不写的话弹错时整个卡片会跳一下 */
  font-size: 11.5px;
  line-height: 1.4;
  color: #dc2626;
}

.name-actions {
  display: flex;
  gap: 10px;
  margin-top: 8px;
}
.name-actions button {
  flex: 1;
  height: 44px;
  border-radius: 12px;
  font-family: inherit;
  font-size: 14.5px;
  font-weight: 700;
  cursor: pointer;
}
.name-cancel {
  border: 1px solid var(--line);
  background: #fff;
  color: var(--ink-2);
}
.name-save {
  border: none;
  background: var(--brand);
  color: #fff;
  box-shadow: 0 6px 18px rgba(249, 115, 22, .28);
}
.name-save:disabled { opacity: .6; box-shadow: none; }

/* =========================================================================
   【v3.76 · 默认对话版不再显示顶部 16:9 场景】
   他 2026-10-29 22:06 的原话：「先把那个上面的 16:9 的图片去掉吧，去掉后看看效果」。

   为什么可以直接去掉而不伤产品：
   ① 聊天区每条灵妹消息左侧已经有她的照片头像（v3.75），
      「她在场」这件事不再只靠场景框里那张立绘；
   ② 场景占了 390×9/16 ≈ 219px，是一整轮对话的高度 ——
      去掉之后对话区一次能多看到一到两轮，孩子不用一直滚；
   ③ 场景里那张图是静态立绘，说话时只是嘴巴动，信息量其实很低。

   ★ 想把它拿回来（对比用）：网址加 `?layout=scene`，
     代码会给 body 挂 `with-scene`，下面这条就不再命中。
   ★ 竖屏沉浸版 ?layout=portrait 不受影响：那条路本来就是全屏场景，
     这条规则只对「非竖屏」生效。 */
body:not(.layout-portrait):not(.with-scene) .scene { display: none; }

/* 【v3.77】录音这条路的占位气泡：「正在识别你说的话…」
   它出现的时机是「请求已发出、服务器还没认出他的话」，
   作用不是告知进度，而是**占住顺序** —— 她的话必须排在它下面。
   所以样式要一眼看出「这不是他的话、还没填好」：淡一点、不加粗、不加中文小字。 */
.row.me .bubble.pending { opacity: .8; }
.row.me .bubble.pending .en { font-weight: 500; color: var(--ink-2); }

/* ---------------- 进度药丸 + 进步明细窗（v3.82） ---------------- */

/* 浮动在对话区右上角：不挤占顶栏、不碰逐词小字。
   z-index 低于 .overlay(50)，打开明细窗时会被盖住。 */
.learned-pill {
  position: absolute;
  top: 56px;
  right: 10px;
  z-index: 40;
  display: flex;
  align-items: center;
  gap: 4px;
  padding: 5px 11px;
  border: 1px solid var(--ok-line, #bbf7d0);
  background: rgba(240, 253, 244, .94);
  color: var(--ok-deep, #15803d);
  border-radius: 999px;
  font-size: 12.5px;
  font-weight: 700;
  font-family: inherit;
  box-shadow: 0 4px 14px rgba(22, 163, 74, .18);
  cursor: pointer;
  -webkit-backdrop-filter: blur(3px);
  backdrop-filter: blur(3px);
  transition: transform .25s cubic-bezier(.34, 1.56, .64, 1);
}
.learned-pill b { font-size: 14px; }
.learned-pill:active { transform: scale(.95); }
.learned-pill.bump { transform: scale(1.18); }

/* 「+N」飘字：从药丸上方升起淡出，落在药丸 z-index 之上。 */
.learned-float {
  position: absolute;
  top: 26px;
  right: 14px;
  z-index: 41;
  color: #16a34a;
  font-weight: 800;
  font-size: 15px;
  pointer-events: none;
  animation: learnedFloat 1.1s ease-out forwards;
}
@keyframes learnedFloat {
  0%   { opacity: 0; transform: translateY(6px) scale(.8); }
  20%  { opacity: 1; }
  100% { opacity: 0; transform: translateY(-26px) scale(1.05); }
}

.learned-card { width: 320px; max-width: 86vw; }

/* 【v3.91】一条竖线：文字一律左对齐顶到卡片左边，数字一律靠右对齐到同一条竖线。
   上一版是「大数字居中 + 文字居中 + 数字靠右」三条轴，所以看着不整齐。
   数字用 tabular-nums：1 和 8 等宽，位数变的时候右边缘不会抖。 */
.learned-sub { margin: 2px 0 16px; color: var(--ink-3); font-size: 12.5px; line-height: 1.5; }

/* 总数行（不是卡，是这一节的标题行）：文字左、数字右，底线把它和「里面两层」分开 */
.learned-hero {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  padding-bottom: 12px;
  margin-bottom: 14px;
  border-bottom: 1px solid var(--line);
}
.learned-hero .hero-cap { font-size: 14px; font-weight: 600; color: var(--ink); }
.learned-hero .hero-num {
  font-size: 38px;
  font-weight: 800;
  color: var(--ok-deep, #15803d);
  line-height: .9;
  letter-spacing: -.5px;
  font-variant-numeric: tabular-nums;
}

/* 【v3.90】三层漏斗：外层是上面那个总数，这里给里面两层。
   两条 bar 的分母都是最外层（spokenSaid）—— 所以「说得很熟」一定比「听懂了，也用上了」
   短或相等，视觉上永远是套着的，不会出现子层比父层长的荒唐样子。
   左边那道竖线是「这两层属于上面那个数」的唯一提示，去掉就会被读成三件并列的事。 */
.learned-funnel {
  padding-left: 11px;
  margin: 0 0 16px;
  border-left: 2px solid var(--ok-line, #bbf7d0);
}
.funnel-row { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
.funnel-cap { font-size: 13px; color: var(--ink-2); }
.funnel-num {
  font-size: 19px;
  font-weight: 800;
  color: var(--ok-deep, #15803d);
  line-height: 1;
  font-variant-numeric: tabular-nums;
}
.funnel-bar { height: 6px; margin: 7px 0 13px; border-radius: 999px; background: var(--ok-soft, #f0fdf4); overflow: hidden; }
.funnel-bar:last-child { margin-bottom: 0; }
.funnel-bar i { display: block; height: 100%; width: 0; border-radius: 999px; transition: width .45s ease; }
#barConfirmed { background: linear-gradient(90deg, #4ade80, var(--ok-deep, #15803d)); }
/* 更内层：淡一档，读起来是「里面那圈」而不是另一条并列的进度 */
#barSolid { background: linear-gradient(90deg, #a7f3d0, #34d399); }

/* 拆分收成一行灰字（原来是两张各 100px 高的灰卡，只放了两个 3 位数）。
   两者相加正好等于上面那个总数，所以放在数字下面而不是另起一屏。 */
.learned-meta { margin: 0 0 16px; font-size: 12.5px; color: var(--ink-2); }
.learned-meta b { color: var(--ink); font-weight: 700; font-variant-numeric: tabular-nums; }

.learned-recent-head { margin: 0 0 8px; font-size: 12.5px; color: var(--ink-3); }
.learned-recent { display: flex; flex-wrap: wrap; gap: 7px; max-height: 184px; overflow-y: auto; }
.learned-recent .lr { background: var(--ok-soft, #f0fdf4); border: 1px solid var(--ok-line, #bbf7d0); color: var(--ok-deep, #15803d); border-radius: 999px; padding: 4px 10px; font-size: 12.5px; font-weight: 700; }
.learned-recent .lr em { font-style: normal; opacity: .65; font-weight: 500; margin-left: 3px; }
.learned-recent .lr-empty { color: var(--ink-3); font-size: 12.5px; }
